GET /v1/projects/:projectId/content/hooks
Fetch a brand-adapted bank of hook strings ready to pass into generate-content.
/v1/projects/:projectId/content/hooks- Auth
- Bearer
- Scope
- content:read
Returns a fresh bank of short hook strings adapted to the project's brand voice and language. Present these to your end-user, let them pick one, and pass the chosen string as hook on POST /v1/projects/:projectId/content. The string is consumed verbatim — the line-break marker (the two-character escape \n) and any emoji are preserved.
Setup/payoff breaks are encoded as the literal two-character sequence \n (backslash + n), not a real newline (0x0a). If you display the string directly without replacing \n you'll see the escape on screen. Replace before rendering:
hook.replace(/\\n/g, "\n")Same brand-voice resolution and language resolution as the in-app hooks picker — Layers' copy agent reads the project's brandVoice + primaryLanguage (or the wired influencer's overrides) and writes a bank to those constraints. Fresh-on-every-call; re-fetch to show the user a different bank.
Path parameters
projectIdstring (uuid)requiredThe project to generate hooks for.
Preconditions
The project must have app_name and app_description populated — both anchor the brand-adapted prompt. If either is empty the endpoint returns 422 VALIDATION with details.missingFields[] so you know exactly which PATCH /v1/projects/:id field to populate (or which ingest endpoint to run).
Voice and language resolution
Two-tier — same as the in-app picker:
- Influencer wired to the project's first Social Content layer (
config.customInfluencerId). When present, that influencer'sbrand_voiceandprimary_languagewin. - Project defaults otherwise —
projects.brand_voice,projects.primary_language.
Hard fallbacks (authentic voice, en language) protect against legacy null rows.
Request
curl https://api.layers.com/v1/projects/{projectId}/content/hooks \
-H "Authorization: Bearer $LAYERS_API_KEY"const res = await fetch(
`https://api.layers.com/v1/projects/${projectId}/content/hooks`,
{ headers: { 'Authorization': `Bearer ${process.env.LAYERS_API_KEY}` } },
);
const { hooks } = await res.json();import os, httpx
r = httpx.get(
f"https://api.layers.com/v1/projects/{project_id}/content/hooks",
headers={"Authorization": f"Bearer {os.environ['LAYERS_API_KEY']}"},
)
hooks = r.json()["hooks"]Responses
{
"hooks": [
"wait for it...\\nthis simple habit changed everything about my mindset 🧠",
"POV: you finally stopped doom-scrolling and started doing this instead",
"the 5-minute morning routine I wish I started 10 years ago"
]
}Each string is ready to pass verbatim as hook on POST /v1/projects/:projectId/content. The setup/payoff break is the literal two-character escape \n (shown above as \\n in JSON-escaped form) — convert it to a real newline before rendering. Emoji round-trip exactly as returned.
{
"error": {
"code": "VALIDATION",
"message": "Project needs app_name and app_description before hooks can be generated.",
"requestId": "req_...",
"details": {
"missingFields": ["app_description"],
"remedy": "Run POST /v1/projects/:id/ingest/{website,appstore,github} or PATCH /v1/projects/:id with the missing fields."
}
}
}Notes
Hooks are not persisted on the project — every call returns a fresh bank. The picker is stateless: there is no "previously shown hooks" history, no de-dup across calls, no list endpoint for past banks. If you want to remember which hook a user chose, save it on your end before passing it to generate.
- No filters or pagination. A bank ships per call (20+ strings). Re-fetch for a new bank.
- Free. No credit cost; only
content:readscope.
Errors
| Code | When |
|---|---|
VALIDATION | Project missing app_name and/or app_description. |
NOT_FOUND | Project id not in this org. |
FORBIDDEN_SCOPE | Key lacks content:read. |
INTERNAL | Hook agent failed mid-run. Safe to retry. |
See also
- Generate content
- Patch a project (set
app_name,app_description,brand_voice,primary_language) - Influencers (which influencer's voice the picker reads from)