## Cancel a batch run

**post** `/v1/invitation_batches/{batch}/cancel`

Cancels a run: every recipient not yet attempted is stopped before any
attempt.

**Behavior**

- Stops both the recipients still waiting to become invitations and the
  ones already sitting in the delivery queue; cancelling seconds into a
  large run does not let the hundreds already queued go out.
- Cannot stop a request already on Apple's wire or anything Apple has
  accepted. The `cancellation` object reports those as `in_flight` and
  `already_sent` rather than counting them as cancelled.
- Exact, not best-effort: a cancel and the run's own fan-out serialize
  on the run, so there is no window where a recipient is both cancelled
  and sent.
- Safe to repeat, never refused. Cancelling a cancelled run returns
  HTTP 200 with the same body. Cancelling a `completed` run also works;
  `completed` means every recipient became an invitation, not that all
  were delivered, and a large run is routinely `completed` with
  thousands of sends still queued; this call still stops everything
  unattempted. If nothing was left, `cancellation` is all zeros. Read
  the numbers, not the status code.
- Stopped recipients appear as `cancelled` in `counts` and on the
  recipients page (`?state=cancelled`). No per-recipient webhook fires
  for them; the run's one `invitation_batch.completed` event fires with
  `status: "cancelled"` and the final counts.
- Unknown runs, and another brand's; return HTTP 404.

### Path Parameters

- `batch: string`

### Returns

- `id: string`

  Run id (`invb_…`).

- `accepted_count: number`

  How many recipients the run would have attempted: `recipient_count` minus the rows the submit screen skipped.

- `cancellation: object { already_sent, in_flight, queued, unreleased }`

  What a cancel actually stopped, split by how far each recipient had got.

  The four numbers exist because two of them are NOT cancelled and you have to
  know which. Cancel is EXACT for everything that has not been attempted and
  cannot recall anything that has: a request already on Apple's wire is on
  Apple's wire.

  `unreleased + queued` is what this call stopped. `in_flight + already_sent` is
  what went out anyway. The four PARTITION the run; every recipient that became
  an invitation is in exactly one of them, and so is every recipient we stopped
  before it became one, so nothing falls between them. All four are recomputed
  from the run's rows on every call, so cancelling twice reports the same split
  rather than a set of zeros.

  - `already_sent: number`

    The send was already ATTEMPTED before your cancel landed. NOT stopped, and not recallable; by us or by anyone. Whether Apple accepted it is that invitation's own delivery state; this counts the attempt, so a send that was attempted and refused is here rather than nowhere.

  - `in_flight: number`

    An attempt was on the wire when your cancel landed. NOT stopped: it either reached Apple or did not, and we report rather than guess.

  - `queued: number`

    Released to the delivery queue but never attempted. STOPPED; this is the set that makes a cancel three seconds into a 50,000-recipient run mean something.

  - `unreleased: number`

    Never handed to the delivery queue at all; recipients we had not reached yet, plus invitations the pacer had not released. STOPPED.

- `counts: object { cancelled, failed, pending, 2 more }`

  The run's recipients by state, the same object the progress read returns. Rows this cancel stopped before they were attempted are now `cancelled`; a row that had already become an invitation stays `sent`: the invitation exists, and whether it went out is that invitation's own delivery state.

  - `cancelled: number`

    Stopped by a cancel before it was attempted.

  - `failed: number`

    Accepted at submit, then permanently refused before sending; consent withdrawn in between, a superseded or closed chat, a revoked template grant. The row's `reason` says which.

  - `pending: number`

    Not yet attempted. This is the number the ETA projects.

  - `sent: number`

    Handed to the invitation pipeline: an `invitation_id` exists on that row. Delivery to Apple is reported by that invitation's own status and webhooks, not here.

  - `skipped: number`

    Dropped by the SUBMIT screen under `on_invalid: skip_invalid`, with the reason the submit response gave. Frozen at creation; nothing later moves a row into or out of it.

- `created_at: string`

- `recipient_count: number`

  How many recipients the run was SUBMITTED with, frozen at creation. Always `counts`' total.

- `status: "cancelled" or "completed"`

  `cancelled` when this call (or an earlier one) stopped a live run.
  `completed` when the run's own fan-out had ALREADY finished; every
  recipient was materialized before you cancelled; in which case the run
  keeps that status and `cancellation` reports what the cancel still caught in
  the delivery queue. It is never any other value: those two are the only
  states a run can be in after this call.

  - `"cancelled"`

  - `"completed"`

- `template_id: string`

- `updated_at: string`

  When the run reached `cancelled`. Unchanged by a repeated cancel.

### Example

```http
curl https://messages.api.linqapp.com/v1/invitation_batches/$BATCH/cancel \
    -X POST \
    -H "Authorization: Bearer $LINQ_AMB_API_KEY"
```

#### Response

```json
{
  "id": "invb_0d5c19",
  "status": "cancelled",
  "template_id": "invt_a4f2c718d0",
  "recipient_count": 2,
  "accepted_count": 2,
  "counts": {
    "pending": 0,
    "sent": 1,
    "failed": 0,
    "cancelled": 1,
    "skipped": 0
  },
  "cancellation": {
    "unreleased": 1,
    "queued": 0,
    "in_flight": 0,
    "already_sent": 1
  },
  "created_at": "2026-08-06T14:00:00Z",
  "updated_at": "2026-08-06T14:00:31Z"
}
```
