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