# GET /v1/projects/:projectId/content/hooks (/docs/api/reference/content/list-hooks)



<Endpoint method="GET" path="/v1/projects/:projectId/content/hooks" scope="content:read" phase="1" />

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`](/docs/api/reference/content/slideshow-builder). The string is consumed verbatim — the line-break marker (the two-character escape `\n`) and any emoji are preserved.

<Callout type="warn">
  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:

  ```ts
  hook.replace(/\\n/g, "\n")
  ```
</Callout>

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 [#path-parameters]

<Parameters
  rows="[
  { name: 'projectId', type: 'string (uuid)', required: true, description: 'The project to generate hooks for.' },
]"
/>

## Preconditions [#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 [#voice-and-language-resolution]

Two-tier — same as the in-app picker:

1. **Influencer wired to the project's first Social Content layer** (`config.customInfluencerId`). When present, that influencer's `brand_voice` and `primary_language` win.
2. **Project defaults** otherwise — `projects.brand_voice`, `projects.primary_language`.

Hard fallbacks (`authentic` voice, `en` language) protect against legacy `null` rows.

## Request [#request]

<Tabs items="['curl', 'TypeScript', 'Python']">
  <Tab value="curl">
    ```sh title="terminal"
    curl https://api.layers.com/v1/projects/{projectId}/content/hooks \
      -H "Authorization: Bearer $LAYERS_API_KEY"
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts title="list-hooks.ts"
    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();
    ```
  </Tab>

  <Tab value="Python">
    ```py title="list_hooks.py"
    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"]
    ```
  </Tab>
</Tabs>

## Responses [#responses]

<Response status="200" description="Bank of hook strings.">
  ```json
  {
    "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`](/docs/api/reference/content/slideshow-builder). 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.
</Response>

<Response status="422" description="Project missing brand context required for hook generation.">
  ```json
  {
    "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."
      }
    }
  }
  ```
</Response>

## Notes [#notes]

<Callout type="info">
  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.
</Callout>

* **No filters or pagination.** A bank ships per call (20+ strings). Re-fetch for a new bank.
* **Free.** No credit cost; only `content:read` scope.

## Errors [#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 [#see-also]

* [Generate content](/docs/api/reference/content/slideshow-builder)
* [Patch a project](/docs/api/reference/projects/patch-project) (set `app_name`, `app_description`, `brand_voice`, `primary_language`)
* [Influencers](/docs/api/concepts/influencers) (which influencer's voice the picker reads from)
