# POST /v1/jobs/:jobId/cancel (/docs/api/reference/jobs/cancel-job)



<Endpoint method="POST" path="/v1/jobs/{jobId}/cancel" auth="Bearer" scope="jobs:cancel" phase="1" />

Requests cancellation for a running job. Cancellation is cooperative, not forceful: an in-flight platform API call may complete first, and some stages refuse cancellation because rolling them back would leave partial external state, such as a half-uploaded asset or a PR already opened against your repo.

The response tells you whether the signal landed. It does **not** tell you the job is already canceled - keep polling [`GET /v1/jobs/:jobId`](/docs/api/reference/jobs/get-job) until `status` flips to `canceled` or a terminal state.

<Parameters
  title="Path"
  rows="[
  { name: 'jobId', type: 'string (job_<ULID>)', required: true, description: 'The jobId returned from the originating 202 response.' },
]"
/>

## Example request [#example-request]

<Tabs items="['curl', 'TypeScript', 'Python']">
  <Tab value="curl">
    ```bash
    curl -X POST https://api.layers.com/v1/jobs/job_01HXA1NHKJZXPV8R7Q6WSM5BCD/cancel \
      -H "Authorization: Bearer lp_..."
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts
    const res = await fetch(
      `https://api.layers.com/v1/jobs/${jobId}/cancel`,
      { method: "POST", headers: { Authorization: `Bearer ${apiKey}` } },
    );
    const { accepted, reason } = await res.json();
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import httpx

    r = httpx.post(
        f"https://api.layers.com/v1/jobs/{job_id}/cancel",
        headers={"Authorization": f"Bearer {api_key}"},
    )
    payload = r.json()
    ```
  </Tab>
</Tabs>

## Response [#response]

<Response status="202" description="Cancel signal accepted. Poll the job to see status flip to canceled.">
  ```json
  {
    "jobId": "job_01HXA1NHKJZXPV8R7Q6WSM5BCD",
    "accepted": true
  }
  ```

  `reason` is only present on the 200 "already terminal" shape below. On a fresh 202 accept, it's omitted.
</Response>

<Response status="200" description="Already terminal - nothing to cancel. Flat rejection shape with the terminal reason.">
  ```json
  {
    "jobId": "job_01HXA1NHKJZXPV8R7Q6WSM5BCD",
    "accepted": false,
    "reason": "ALREADY_COMPLETED"
  }
  ```
</Response>

<Response status="409" description="Current stage refuses cancellation. Canonical CONFLICT envelope with details.subcode = JOB_CANCEL_UNAVAILABLE.">
  ```json
  {
    "error": {
      "code": "CONFLICT",
      "message": "Job cannot be canceled during this stage.",
      "requestId": "req_01HXA1NHZ4KYE8GP9Q2WX3BCDE",
      "details": {
        "subcode": "JOB_CANCEL_UNAVAILABLE",
        "jobId": "job_01HXA1NHKJZXPV8R7Q6WSM5BCD",
        "stage": "opening_pr"
      }
    }
  }
  ```

  Branch on `error.details.subcode === "JOB_CANCEL_UNAVAILABLE"` and read `details.stage` for the current non-cancelable stage.
</Response>

<Response status="404" description="Unknown jobId in your organization." />

## Non-cancelable stages [#non-cancelable-stages]

Some stages would leave external state in an inconsistent place if interrupted. The API refuses cancellation during them and returns `STAGE_NOT_CANCELABLE` with the current `stage`.

| Kind                       | Stages that refuse cancel                                                                                                   |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `project_ingest_github`    | `opening_pr`, `finalizing`                                                                                                  |
| `project_ingest_website`   | `persisting`                                                                                                                |
| `content_generate`         | `finalizing`                                                                                                                |
| `content_regenerate`       | `finalizing`                                                                                                                |
| `content_clone_from_post`  | `finalizing`                                                                                                                |
| `influencer_create`        | `persisting`                                                                                                                |
| `appstore_ingest`          | `persisting`                                                                                                                |
| `marketing_bootstrap`      | `project_create`, `ingest_website`, `sdk_app_create`, `layer_provision`, `influencer_create`, `first_content`, `finalizing` |
| `ad_optimizer_run`         | `applying_actions`, `finalizing`                                                                                            |
| `project_keywords_refresh` | `finalizing`                                                                                                                |

`marketing_bootstrap` currently refuses cancel at every stage. Bootstrap is a fan-out orchestrator and cancellation mid-flight could leak half-built projects (project row exists, no influencer, etc.); this is a v1 limitation while bootstrap stages don't yet have rollback semantics. Partners should treat bootstrap as an atomic operation — if it fails, retry with the same idempotency key (which short-circuits to the prior `bootstrapJobId`) once the underlying issue is fixed.

Everything else is fair game. If the job is still in an early cancellable stage, the cancel request is usually accepted.

## Notes [#notes]

* `accepted: true` means the cancel signal was delivered, not that the job is already canceled. Poll [`GET /v1/jobs/:jobId`](/docs/api/reference/jobs/get-job) for the terminal flip.
* Canceled jobs are sticky - once `status: "canceled"`, they never revert.
* Cancel is best-effort. If a platform API call is already in flight, it will finish before the job exits.
* Canceling a `content_generate` job before `finalizing` reverses any reserved credits. Canceling after does not - the content exists.

## See also [#see-also]

* [`GET /v1/jobs/:jobId`](/docs/api/reference/jobs/get-job) - poll for terminal state
* [Jobs](/docs/api/concepts/jobs) - the 202 → poll pattern
