# POST /v1/projects/:projectId/keywords/refresh (/docs/api/reference/keywords/refresh-keywords)



<Endpoint method="POST" path="/v1/projects/:projectId/keywords/refresh" scope="projects:write" />

Kicks off Layers' keyword research agent against the project's current `appDescription` and upserts the result into the project's keyword bank when it lands. The route is **async** — it returns a [job envelope](/docs/api/concepts/jobs) immediately; the agent runs in the background for **4 – 5 minutes** end-to-end.

Partners typically fire this fire-and-forget and poll [`GET /v1/projects/:projectId/keywords`](/docs/api/reference/keywords/list-keywords) until `refreshedAt` advances (or `hashtags.length > 0` on first refresh). The job envelope is there if you want stage-level visibility.

## When to call [#when-to-call]

Most partners never need to call this endpoint directly — Layers auto-triggers keyword research from [`POST /v1/projects`](/docs/api/reference/projects/create-project) (when `appDescription` is supplied) and from [`PATCH /v1/projects/:id`](/docs/api/reference/projects/patch-project) (when `appDescription` changes). The auto-trigger is fire-and-forget; observe the result via [`GET /v1/projects/:projectId/keywords`](/docs/api/reference/keywords/list-keywords).

Reach for this endpoint when you want:

* **A jobId for stage-level observability.** The auto-trigger doesn't surface a job envelope; this endpoint does.
* **A manual re-run** if the auto-trigger failed (rare — `GET /keywords` returns `refreshedAt: null` and an empty bank when it never landed).
* **A forced refresh** that doesn't depend on `appDescription` changing.

Send an `Idempotency-Key` header so a retry on connection error replays the cached job envelope instead of starting a second run.

## Precondition [#precondition]

`projects.app_description` must be set. Without it, the route returns `422 VALIDATION` with `missingFields: ["appDescription"]`. Set it on the project first via [`POST /v1/projects`](/docs/api/reference/projects/create-project) or [`PATCH /v1/projects/:id`](/docs/api/reference/projects/patch-project).

## Body [#body]

Empty body. The handler reads `appDescription` from the project row.

```bash
curl -X POST https://api.layers.com/v1/projects/$PROJECT_ID/keywords/refresh \
  -H "Authorization: Bearer $LAYERS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{}'
```

## Response [#response]

<Response status="202" description="Job accepted. Poll /v1/jobs/:jobId for terminal state, or poll /v1/projects/:id/keywords for the bank.">
  ```json
  {
    "jobId": "job_01HZX...",
    "kind": "project_keywords_refresh",
    "status": "running",
    "projectId": "prj_01HX...",
    "startedAt": "2026-05-11T19:02:11.959Z",
    "locationUrl": "/v1/jobs/job_01HZX..."
  }
  ```
</Response>

## Errors [#errors]

| Status | Code              | When                                                                                 |
| ------ | ----------------- | ------------------------------------------------------------------------------------ |
| 422    | `VALIDATION`      | Project has no `appDescription`. Response carries `missingFields` + a `remedy` hint. |
| 401    | `UNAUTHENTICATED` | Missing or invalid key.                                                              |
| 403    | `FORBIDDEN_SCOPE` | Key lacks `projects:write`.                                                          |
| 404    | `NOT_FOUND`       | Project not in this org.                                                             |

Job-level failures (the agent failed mid-run) surface on `GET /v1/jobs/:jobId` as `status: "failed"` with a structured `error`. Safe to retry with the same `Idempotency-Key`.

## Notes [#notes]

* Expected end-to-end latency is **4 – 5 minutes**. The agent runs an iterative keyword-expansion + scoring loop and consults third-party TikTok data for popularity signals.
* The agent filters aggressively — banks of 0 hashtags happen when the project's `appDescription` is too vague or off-domain. Tighten the description and call refresh again.
* Curated hashtags meet score, view-count, and post-count floors. Anything below is dropped before the bank lands.

## See also [#see-also]

* [List keywords](/docs/api/reference/keywords/list-keywords)
* [Source recommendations](/docs/api/reference/content/source-recommendations)
* [Concepts → Jobs](/docs/api/concepts/jobs) — how to poll the job envelope
