# Invitation Batches

## Submit a batch of invitations

**post** `/v1/invitation_batches`

Submits a batch of invitations as one run. Returns HTTP 202 with a run
id immediately: the fan-out, pacing, and retries are handled for you,
and none of it happens inside your request.

**Behavior**

- Validation covers the whole batch and names every row: each unusable
  row is reported at once, with your row index and field
  (`/recipients/7/to`), so the number of entries is exactly the number
  of rows to fix. `on_invalid` decides whether bad rows refuse the whole
  submit (HTTP 422 `code` 1005, nothing created) or are dropped
  while the rest proceed (HTTP 202 with a `skipped` array).
- `validate_only: true` runs every screen and creates nothing: no run,
  no recipients, and the `Idempotency-Key` stays unclaimed. Success is
  HTTP 200 with a report instead of HTTP 202 with a run.
- Consent is checked at submit, so you learn which numbers lack an
  `invitation` opt-in immediately instead of reading delivery failures
  for hours. It is also re-decided before every attempt: a customer who
  opts out after you submit is suppressed then.
- The run is admitted against the delivery backlog. HTTP 429
  `backlog_horizon_exceeded` (with `Retry-After`, legitimately hours)
  means your queue plus this run would exceed the delivery horizon;
  retry later. HTTP 422 `code` 1004 means this run alone can
  never fit; the message names a run size that will, rounded down to a
  round number, and the remedy is to split it. In one uncommon
  configuration we name no size and ask you to send fewer per run. Both
  create nothing, and the same
  `Idempotency-Key` succeeds on retry.
- A scheduled (`not_before`) or smoothed (`spread_over_seconds`) run is
  admitted as it materializes instead of at submit: it waits rather than
  failing when your queue is full, its `eta` says so, and it can be
  cancelled like any other.
- Per-row problems arrive inside `error.details` (or `skipped`), never
  as the response status: a batch's answer is about the list, and no
  single status is truthful when row 3 has a bad number and row 40 has
  no consent.

**Errors (run-level)**

- HTTP 400: `body_invalid`, `idempotency_key_required`
- HTTP 403: `invitation_card_not_granted`: your brand is no longer
  approved for the card shape the template renders
- HTTP 413: `too_large`: the body exceeds 8 MiB; split the batch
- HTTP 422: `template_id_required`, `unknown_template`,
  `recipients_required`, `too_many_recipients`, `batch_exceeds_horizon`;
  `field_removed` if the request still carries the retired
  `brand_logo_att_id` field or a recipient still carries `parameters`
- HTTP 429: `backlog_horizon_exceeded`, with `Retry-After`

### Header Parameters

- `"Idempotency-Key": string`

### Body Parameters

- `recipients: array of object { reference_id, to, locale }`

  The recipient set, in your order. 1–10,000 rows per submit: the ceiling is
  what one request can carry and still be validated, stored and reported on
  as a unit, and a larger campaign is more than one batch (the run id makes
  each one trackable). An empty array is `422 recipients_required`; over the
  ceiling is `422 too_many_recipients`, both naming `/recipients`.

  - `reference_id: string`

    REQUIRED per recipient: an order number, case id or similar, echoed on that invitation's delivery webhooks. A batch does not relax it: a run of 10,000 sends with one shared correlation handle is not reconcilable.

  - `to: string`

    The customer's phone number, bare E.164 (partner formatting tolerated). Normalized to a canonical `tel:+E164` handle at submit: an unparseable one is a `to_invalid` naming that row, and two rows that normalize alike are a `duplicate_recipient`. Your own string is stored as sent and echoed back in the run's per-recipient results.

  - `locale: optional string`

    Overrides the run's `locale` for this recipient.

- `template_id: string`

  One of YOUR invitation template ids (`invt_…`). ONE template per run: a run is a campaign, and two templates are two batches. The card's name and logo come from the template, resolved ONCE at submit and frozen into the run, so editing the template mid-run cannot change what queued recipients receive.

- `locale: optional string`

  The run's default locale; a recipient may override it. Absent renders the template in its own default.

- `not_before: optional string`

  SCHEDULE THE RUN: do not start before this instant (RFC 3339, with an
  offset). Absent: the default; starts as soon as the run is accepted.

  A value in the PAST is accepted and starts the run immediately; it is not an
  error, because a retry of a submit whose start time has since elapsed must
  give you the run you asked for rather than a refusal you cannot fix. At most
  7 days ahead (`422 not_before_too_far`): every recipient's opt-in is read at
  SUBMIT, so a run scheduled further out would send against consent nobody has
  re-checked.

  A scheduled run sits at `status: "queued"` until its time comes, costs you
  nothing meanwhile, and can be cancelled like any other. If our workers are
  down across the boundary the run starts LATE, never skipped: the start is a
  stored instant, not an event anyone has to be awake for.

- `on_invalid: optional "reject_batch" or "skip_invalid"`

  What to do when some rows cannot be sent to. Defaults to `reject_batch`:
  partial acceptance is something you ask for, never something that happens to
  you, because "half my campaign went out" is not a state to reach by
  omission.

  - `"reject_batch"`

  - `"skip_invalid"`

- `spread_over_seconds: optional number`

  SMOOTH THE RUN: spread it evenly over this many seconds, starting at
  `not_before` (or at acceptance). `0`: the default; hands every recipient to
  the delivery machinery as fast as it will take them, which is what every run
  did before this field existed. Capped at 7 days
  (`422 spread_over_seconds_invalid`).

  It can only ever make a run SLOWER. Your delivery rate is enforced
  independently and is unaffected by this field: a window longer than your rate
  needs gets you the window, a shorter one gets you your rate. Use it when the
  thing that cannot absorb a burst is on your side: a support queue, a
  fulfilment pipeline, a landing page.

- `validate_only: optional boolean`

  DRY RUN. Every screen runs: the template grant, the parameter schemas, the
  phone numbers, the duplicates, the consent records, and NOTHING is created:
  no run, no recipients, and the `Idempotency-Key` is not claimed, so the real
  submit can use the same one. The answer is the answer the identical live
  request would give, except that success is `200` with a report instead of
  `202` with a run: so a `422` here is exactly the `422` you would have got.

### Returns

- `accepted_count: number`

  How many rows a live submit would have accepted.

- `recipient_count: number`

  The length of the recipient array you submitted.

- `validate_only: true`

  Always `true`. Echoed rather than implied, so a response read out of a log cannot be mistaken for an accepted campaign.

  - `true`

- `skipped: optional array of object { code, message, param }`

  The rows a live `skip_invalid` submit would have dropped. Absent when nothing would be.

  - `code: string`

    `to_required` | `to_invalid` | `duplicate_recipient` | `consent_required` | or a reference/locale code (`required`, `too_long`, `forbidden_character`).

  - `message: string`

  - `param: string`

    JSON pointer at the offending row AND field of your own request (`/recipients/7/to`).

### Example

```http
curl https://messages.api.linqapp.com/v1/invitation_batches \
    -H 'Content-Type: application/json' \
    -H "Authorization: Bearer $LINQ_AMB_API_KEY" \
    -d '{
          "recipients": [
            {
              "reference_id": "ride-88214",
              "to": "+15555550142"
            },
            {
              "reference_id": "ride-88215",
              "to": "+15555550188"
            }
          ],
          "template_id": "invt_a4f2c718d0"
        }'
```

#### Response

```json
{
  "id": "invb_0d5c19",
  "status": "queued",
  "template_id": "invt_a4f2c718d0",
  "recipient_count": 2,
  "accepted_count": 2,
  "created_at": "2026-08-06T14:00:00Z"
}
```

## Get a batch run's progress

**get** `/v1/invitation_batches/{batch}`

Retrieves a run's progress: its status, how many recipients stand in
each state, and when the rest are projected to finish.

**Behavior**

- One call regardless of run size: the cost does not grow with the
  recipient count.
- `counts` always carries all five states, zeros included, and always
  sums to `recipient_count`.
- `eta` is an estimate from a rate that moves, and is absent rather than
  zero when none can honestly be quoted.
- Unknown ids, and another brand's; return HTTP 404, the same answer.

### Path Parameters

- `batch: string`

### Returns

- `id: string`

  Run id (`invb_…`).

- `accepted_count: number`

  How many recipients the run will attempt: `recipient_count` minus the rows the submit screen skipped. The same number the submit response returned, and frozen with it.

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

  How many of a run's recipients stand in each state. EVERY key is always present, zero included: a client reads five numbers instead of branching on which keys exist, and a state that has not happened yet is `0` rather than missing.

  - `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; dropped rows included. Always the length of the array you sent, and always `counts`' total.

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

  `queued` (created, nothing attempted yet) → `running` (at least one
  recipient attempted, some still pending) → `completed` (no recipient is
  pending; every one is `sent`, `failed`, `cancelled` or `skipped`), or
  `cancelled` when you stopped it. `completed` does NOT mean every invitation
  was delivered: it means the run has nothing left to attempt, and per-row
  outcomes are in `counts` and the recipient results.

  - `"queued"`

  - `"running"`

  - `"completed"`

  - `"cancelled"`

- `template_id: string`

- `updated_at: string`

  Last change to the run row itself (its `status`). NOT bumped by a per-recipient transition, so it is a run-lifecycle timestamp and not a progress heartbeat; read `counts` for progress.

- `eta: optional object { at, queued_ahead, rate_per_second, 2 more }`

  The completion estimate. Absent when none can honestly be quoted; see the model.

  - `at: string`

    The same estimate as a timestamp on OUR clock; `seconds` from when this response was composed, so a client does not have to correct for its own skew.

  - `queued_ahead: number`

    How many other invitations of yours are queued ahead of this run's remaining rows in the same brand queue. A run does not clear in isolation; another campaign submitted first is counted here, and so are this run's own recipients that have already been handed to the delivery queue.

  - `rate_per_second: number`

    Recipients per second the estimate was taken at: `min(your brand's effective rate, our overall rate / active brands)`.

  - `remaining: number`

    How many of this run's recipients the estimate covers; `counts.pending`.

  - `seconds: number`

    Estimated seconds until the last remaining recipient is released. `0` when nothing is pending.

- `not_before: optional string`

  The run's scheduled start, if you gave it one. A `queued` run with a `not_before` in the future is waiting for its own schedule, not for us.

- `spread_over_seconds: optional number`

  The run's smoothing window, if you gave it one.

### Example

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

#### Response

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

## List a batch run's recipients

**get** `/v1/invitation_batches/{batch}/recipients`

Lists a run's per-recipient results, in the order you submitted them,
one page at a time.

**Behavior**

- Page with `cursor`: pass the previous page's `next_cursor` back
  verbatim, and stop when it comes back empty. Rows are ordered by your
  own array index, which never changes, so a page cannot skip or repeat
  a row while the run progresses underneath you.
- Filter with `state` to avoid paging 10,000 rows to find 40 problems;
  `failed` or `skipped` and read one page. An unknown value is refused,
  never silently answered with an empty page.
- Unknown runs, and another brand's; return HTTP 404.

### Path Parameters

- `batch: string`

### Query Parameters

- `cursor: optional string`

  The previous page's `next_cursor`, verbatim; absent starts at your first recipient. Not a cursor this API issued returns HTTP 400 `code` 1013.

- `limit: optional number`

  Page size; absent (or non-numeric) reads as 25, above 100 reads as 100: the same page bounds every list on this API applies.

- `state: optional string`

  Only recipients in this state (`pending`, `sent`, `failed`, `cancelled`, `skipped`); absent means every row. Anything else returns HTTP 400 `code` 1066.

### Returns

- `data: array of object { idx, reference_id, state, 3 more }`

  - `idx: number`

    This row's position in the array you submitted, zero-based: the same index each refusal names (`/recipients/7/to`), and this run's identity for that row. Never renumbered: skipped rows stay in place, so the index cannot develop holes.

  - `reference_id: string`

    Your correlation handle for this recipient, echoed back.

  - `state: "pending" or "sent" or "failed" or 2 more`

    - `"pending"`

    - `"sent"`

    - `"failed"`

    - `"cancelled"`

    - `"skipped"`

  - `to: string`

    The recipient string AS YOU SENT IT, not the canonical form, so a result matches a row of your own list without you re-normalizing anything.

  - `invitation_id: optional string`

    The invitation this recipient became, once it has been attempted; read `GET /v1/invitations/{inv}` for its delivery state, the customer's response and its webhooks. Absent while the row is `pending`, and for any row that never became one (`skipped`, `cancelled`, and a `failed` row refused before an invitation existed).

  - `reason: optional object { code, message }`

    Present exactly on the rows that will not send (`skipped`, `failed`), absent on every other.

    - `code: string`

      `to_required` | `to_invalid` | `duplicate_recipient` | `consent_required` | a `reference_id` code (`too_long`, `forbidden_character`), or, for a row that failed after acceptance, the send-time refusal: `consent_required`, `chat_superseded`, `chat_closed`, or `invitation_template_not_authorized` (only here: your brand's approval was withdrawn while the run was in flight; at submit the same condition is the run-level `403 invitation_card_not_granted`).

    - `message: string`

- `next_cursor: string`

  Pass back as `?cursor=` for the next page. EMPTY when this page exhausted the run, that, not an empty `data`, is how paging ends.

### Example

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

#### Response

```json
{
  "data": [
    {
      "idx": 1,
      "to": "tel:+15555550188",
      "reference_id": "ride-88215",
      "state": "failed",
      "reason": {
        "code": "consent_required",
        "message": "this recipient withdrew consent after the batch was accepted"
      }
    }
  ],
  "next_cursor": ""
}
```

## 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"
}
```

## Domain Types

### Invitation Batch Create Response

- `InvitationBatchCreateResponse object { accepted_count, recipient_count, validate_only, skipped }`

  `validate_only`'s report: what a live submit of the same body would have accepted, with no run created.

  - `accepted_count: number`

    How many rows a live submit would have accepted.

  - `recipient_count: number`

    The length of the recipient array you submitted.

  - `validate_only: true`

    Always `true`. Echoed rather than implied, so a response read out of a log cannot be mistaken for an accepted campaign.

    - `true`

  - `skipped: optional array of object { code, message, param }`

    The rows a live `skip_invalid` submit would have dropped. Absent when nothing would be.

    - `code: string`

      `to_required` | `to_invalid` | `duplicate_recipient` | `consent_required` | or a reference/locale code (`required`, `too_long`, `forbidden_character`).

    - `message: string`

    - `param: string`

      JSON pointer at the offending row AND field of your own request (`/recipients/7/to`).

### Invitation Batch Retrieve Response

- `InvitationBatchRetrieveResponse object { id, accepted_count, counts, 8 more }`

  A run's progress. Poll this, not the recipients.

  - `id: string`

    Run id (`invb_…`).

  - `accepted_count: number`

    How many recipients the run will attempt: `recipient_count` minus the rows the submit screen skipped. The same number the submit response returned, and frozen with it.

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

    How many of a run's recipients stand in each state. EVERY key is always present, zero included: a client reads five numbers instead of branching on which keys exist, and a state that has not happened yet is `0` rather than missing.

    - `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; dropped rows included. Always the length of the array you sent, and always `counts`' total.

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

    `queued` (created, nothing attempted yet) → `running` (at least one
    recipient attempted, some still pending) → `completed` (no recipient is
    pending; every one is `sent`, `failed`, `cancelled` or `skipped`), or
    `cancelled` when you stopped it. `completed` does NOT mean every invitation
    was delivered: it means the run has nothing left to attempt, and per-row
    outcomes are in `counts` and the recipient results.

    - `"queued"`

    - `"running"`

    - `"completed"`

    - `"cancelled"`

  - `template_id: string`

  - `updated_at: string`

    Last change to the run row itself (its `status`). NOT bumped by a per-recipient transition, so it is a run-lifecycle timestamp and not a progress heartbeat; read `counts` for progress.

  - `eta: optional object { at, queued_ahead, rate_per_second, 2 more }`

    The completion estimate. Absent when none can honestly be quoted; see the model.

    - `at: string`

      The same estimate as a timestamp on OUR clock; `seconds` from when this response was composed, so a client does not have to correct for its own skew.

    - `queued_ahead: number`

      How many other invitations of yours are queued ahead of this run's remaining rows in the same brand queue. A run does not clear in isolation; another campaign submitted first is counted here, and so are this run's own recipients that have already been handed to the delivery queue.

    - `rate_per_second: number`

      Recipients per second the estimate was taken at: `min(your brand's effective rate, our overall rate / active brands)`.

    - `remaining: number`

      How many of this run's recipients the estimate covers; `counts.pending`.

    - `seconds: number`

      Estimated seconds until the last remaining recipient is released. `0` when nothing is pending.

  - `not_before: optional string`

    The run's scheduled start, if you gave it one. A `queued` run with a `not_before` in the future is waiting for its own schedule, not for us.

  - `spread_over_seconds: optional number`

    The run's smoothing window, if you gave it one.

### Invitation Batch List Recipients Response

- `InvitationBatchListRecipientsResponse object { data, next_cursor }`

  One page of a run's recipients, in your submitted order.

  - `data: array of object { idx, reference_id, state, 3 more }`

    - `idx: number`

      This row's position in the array you submitted, zero-based: the same index each refusal names (`/recipients/7/to`), and this run's identity for that row. Never renumbered: skipped rows stay in place, so the index cannot develop holes.

    - `reference_id: string`

      Your correlation handle for this recipient, echoed back.

    - `state: "pending" or "sent" or "failed" or 2 more`

      - `"pending"`

      - `"sent"`

      - `"failed"`

      - `"cancelled"`

      - `"skipped"`

    - `to: string`

      The recipient string AS YOU SENT IT, not the canonical form, so a result matches a row of your own list without you re-normalizing anything.

    - `invitation_id: optional string`

      The invitation this recipient became, once it has been attempted; read `GET /v1/invitations/{inv}` for its delivery state, the customer's response and its webhooks. Absent while the row is `pending`, and for any row that never became one (`skipped`, `cancelled`, and a `failed` row refused before an invitation existed).

    - `reason: optional object { code, message }`

      Present exactly on the rows that will not send (`skipped`, `failed`), absent on every other.

      - `code: string`

        `to_required` | `to_invalid` | `duplicate_recipient` | `consent_required` | a `reference_id` code (`too_long`, `forbidden_character`), or, for a row that failed after acceptance, the send-time refusal: `consent_required`, `chat_superseded`, `chat_closed`, or `invitation_template_not_authorized` (only here: your brand's approval was withdrawn while the run was in flight; at submit the same condition is the run-level `403 invitation_card_not_granted`).

      - `message: string`

  - `next_cursor: string`

    Pass back as `?cursor=` for the next page. EMPTY when this page exhausted the run, that, not an empty `data`, is how paging ends.

### Invitation Batch Cancel Response

- `InvitationBatchCancelResponse object { id, accepted_count, cancellation, 6 more }`

  A stopped run: where it now stands, and exactly what the cancel caught.

  - `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.
