# Chats

## List chats

**get** `/v1/chats`

Lists your brand's chats, newest first, one page at a time.

**Behavior**

- Scoped to your API key's brand by construction: no parameter names a
  brand, and another brand's chats are invisible rather than forbidden.
- Page with `cursor`: pass the previous response's `next_cursor` back
  verbatim, and stop when it comes back empty.

### Query Parameters

- `assigned_operator: optional string`

  Filter by the operator holding the chat; absent means every row, assigned or not.

- `cursor: optional string`

  The previous page's `next_cursor`, verbatim; absent starts from the first page. 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.

- `owner: optional string`

  Filter by turn owner (`partner`, `flow`, `human_pending`, `human`); absent means every owner.

- `state: optional string`

  Filter by lifecycle phase (`open`, `closed`); absent means both.

### Returns

- `data: array of object { capabilities, chat_id, customer_handle, 10 more }`

  - `capabilities: array of string`

    The device's most recently announced capability tokens. Empty means unknown, never "supports nothing".

  - `chat_id: string`

    Chat id (`chat_…`); pass it to `GET /v1/chats/{chat}` for the full view.

  - `customer_handle: string`

    The customer identifier: an Apple Opaque ID, or canonical `tel:+E164`, per `handle_kind`.

  - `handle_kind: "opaque" or "tel"`

    Which identifier kind `customer_handle` holds; branch on this, never read the string itself.

    - `"opaque"`

    - `"tel"`

  - `origin: "customer" or "invitation"`

    How the chat began. Not derivable from `handle_kind`: an accepted invitation arrives under a brand-new opaque id.

    - `"customer"`

    - `"invitation"`

  - `owner: string`

    Turn owner: `partner`, `flow`, `human_pending` or `human`. `human_pending` is the queue.

  - `owner_since: string`

    When the chat entered its CURRENT owner state: the queue clock, NOT `updated_at` (which any progress bumps). Stamped only when the owner actually changes; see `GET /v1/chats/{chat}` for the one self-clearing exception.

  - `state: string`

    Lifecycle phase: `open` or `closed`.

  - `updated_at: string`

    Last change of any kind: the list's sort key, and the first half of the cursor.

  - `assigned_operator: optional string`

    The operator holding this chat; absent when nobody does (the common case).

  - `business_id: optional string`

    The Apple business UUID your brand currently resolves to on this channel: the channel binding of record, present only when one is on file. The same value `GET /v1/chats/{chat}` serves.

  - `last_inbound_at: optional string`

    When we last heard from the customer; any inbound, a message or a chat close. ABSENT when they have never written (an invitation nobody has answered yet).

  - `last_message: optional object { actor, at, has_attachment, 2 more }`

    The newest message on this chat, so the row has a subject line instead of
    only a handle and a wait age. ABSENT when the chat has nothing
    previewable: an invitation nobody has answered, a chat whose only events
    are ownership changes, or a message that decoded to nothing. Render
    nothing in that case; never invent a preview.

    - `actor: string`

      Who spoke: `customer` on an inbound, `partner` on an outbound. It is the
      event log's actor, not the composer: a send is recorded by the worker
      that delivered it, so a reply a human agent typed still reads `partner`
      here. `GET /v1/chats/{chat}/transcript` is where a human is named.

    - `at: string`

      When that message was recorded. Distinct from both of its neighbours on
      this row: `last_inbound_at` moves only on inbound, and `updated_at` moves
      on any machine progress. Do not treat the three as one clock.

    - `has_attachment: boolean`

      Whether the message carried files. Deliberately ORTHOGONAL to `kind`,
      because the two answer different questions: `kind` says what to write on
      the row and this says whether to badge it. `kind: "text"` with this `true`
      is words PLUS a file: the one multi-part combination Apple admits.

    - `kind: "text" or "attachment" or "interactive" or "reply"`

      What kind of thing it was: a CLOSED set. `text`: there were words.
      `attachment`: there were none, only files. `reply`: the customer answered
      an interactive message instead of typing. `interactive`: the business sent
      one. Words win when a message has both.

      - `"text"`

      - `"attachment"`

      - `"interactive"`

      - `"reply"`

    - `preview: optional string`

      The opening words, with attachment placeholders (`￼`) removed and a single
      `…` where it was cut. ABSENT rather than empty when no words survived: an
      attachment-only message has a body of exactly `￼`, and a preview built
      from it verbatim would put an invisible character on the row. Say "sent a
      file" in your own words instead; this API does not ship desk copy.

- `next_cursor: string`

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

### Example

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

#### Response

```json
{
  "data": [
    {
      "chat_id": "chat_4f81b2",
      "state": "open",
      "owner": "human_pending",
      "customer_handle": "urn:mbid:AQAAY7c1",
      "handle_kind": "opaque",
      "origin": "customer",
      "capabilities": [
        "TEXT",
        "LIST",
        "TIME",
        "QUICK"
      ],
      "owner_since": "2026-08-06T14:02:11Z",
      "last_inbound_at": "2026-08-06T14:01:58Z",
      "updated_at": "2026-08-06T14:02:11Z",
      "last_message": {
        "actor": "customer",
        "at": "2026-08-06T14:01:58Z",
        "kind": "text",
        "has_attachment": false,
        "preview": "I need a human agent"
      }
    }
  ],
  "next_cursor": "MjAyNi0wOC0wNlQxNDowMTo1OFogY2hhdF80ZjgxYjI"
}
```

## Get a chat

**get** `/v1/chats/{chat}`

Retrieves a chat, including the customer's per-category consent and whether a send would currently be refused.

### Path Parameters

- `chat: string`

### Returns

- `ChatView object { id, brand, capabilities, 14 more }`

  The partner view of a chat.

  - `id: string`

    Chat id (`chat_…`). The LIST endpoint spells the same fact `chat_id`; neither is renamed to match the other.

  - `brand: object { id, business_hours, display_name, hours_timezone }`

    This chat's brand and its configured display/hours.

    - `id: string`

      The brand this chat belongs to; always your own.

    - `business_hours: optional unknown`

      Business hours as stored: a LIST of `["HH:MM","HH:MM"]` ranges per lowercase weekday key (`mon`…`sun`), so a split shift is expressible. Absent when no hours are configured.

    - `display_name: optional string`

      The brand's display name; absent when it has none.

    - `hours_timezone: optional string`

      The IANA zone the hours are stated in (`America/New_York`). Hours are never configured without it; it may be configured without hours.

  - `capabilities: array of string`

    The device's most recently announced capability tokens. Empty means unknown, never "supports nothing".

  - `consent: unknown`

    Per-category consent for this chat's subject, scoped to the caller's brand (empty when none is recorded).

  - `customer_handle: string`

    The customer identifier: an Apple Opaque ID, or canonical `tel:+E164`, per `handle_kind`.

  - `handle_kind: "opaque" or "tel"`

    Which identifier kind `customer_handle` holds; branch on this, never read the string itself. A `tel` chat is not addressable for sends or typing.

    - `"opaque"`

    - `"tel"`

  - `origin: "customer" or "invitation"`

    How the chat began. Not derivable from `handle_kind`: an accepted invitation arrives under a brand-new opaque id.

    - `"customer"`

    - `"invitation"`

  - `owner: string`

    Turn owner: `partner`, `flow`, `human_pending` or `human`. Sends while not `partner` return HTTP 409 `code` 2011. `flow` is a platform flow holding the turn, typically the survey `/resolve` starts or the question `/clarify` asks: it ends on the customer's answer or its own timeout, and ownership then returns, recorded by `chat.owner_changed`.

  - `owner_since: string`

    When the chat entered its CURRENT owner state: the queue clock, NOT `updated_at` (which any progress bumps). Stamped only when the owner actually changes. One exception, and it is self-clearing: a chat that has not changed owner since the column was deployed reports the deploy time instead, which is a CEILING: deploy time is later than the true entry, so a wait derived from it reads SHORTER than the real one until that chat's next owner move. Don't page on it alone.

  - `send_blocked: object { blocked, code, message }`

    Whether a send would be refused right now, and why; read it BEFORE composing.

    - `blocked: boolean`

    - `code: number`

      The code the send would return, byte-identical to the `error.code` a real send would answer with; `0` when `blocked` is false.

    - `message: string`

      The message the send would return, verbatim; empty when `blocked` is false.

  - `state: string`

    Lifecycle phase: `open` or `closed`. Sends to a closed chat returns HTTP 409 `code` 2010.

  - `updated_at: string`

    Last change of any kind, machine progress included.

  - `assigned_operator: optional string`

    The operator holding this chat; absent when nobody does (the common case).

  - `business_id: optional string`

    The Apple business UUID your brand currently resolves to on this channel: the channel binding of record, present only when one is on file. This is the current binding, not a per-message historical value, so it is the id you can act on today. Distinct from `brand.id`, which is your logical brand on this platform.

  - `capabilities_announced_at: optional string`

    When a device last ANNOUNCED a `capability-list`; NOT when we last heard from them (an inbound carrying no capability header moves `last_inbound_at` and leaves this alone). Absent means no device has ever announced.

  - `capture: optional object { expires_at, group_id, opened_at, page_id }`

    The chat's open reply capture (a degraded form mid-collection), so a takeover desk can warn before ending it: the group id `form.response` will carry, your page id, and the open/expiry instants. Absent when none is open; an expired capture is filtered out here, never closed by a read.

    - `expires_at: string`

    - `group_id: string`

      The degraded fan's correlation id: the primary prompt message's own id.

    - `opened_at: string`

    - `page_id: optional string`

      Your form page's id.

  - `last_inbound_at: optional string`

    When we last heard from the customer; any inbound, a message or a chat close. ABSENT when they have never written (an invitation nobody has answered yet). A typing indicator is not recorded and does not move it; neither does anything the business sends.

### Example

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

#### Response

```json
{
  "id": "chat_4f81b2",
  "state": "open",
  "owner": "partner",
  "capabilities": [
    "TEXT",
    "LIST",
    "TIME",
    "QUICK",
    "FORM"
  ],
  "customer_handle": "urn:mbid:AQAAY7c1",
  "handle_kind": "opaque",
  "origin": "customer",
  "owner_since": "2026-08-06T14:02:11Z",
  "last_inbound_at": "2026-08-06T14:01:58Z",
  "updated_at": "2026-08-06T14:02:11Z",
  "capabilities_announced_at": "2026-08-06T14:01:58Z",
  "consent": {
    "marketing": {
      "state": "granted"
    },
    "account_notification": {
      "state": "granted"
    }
  },
  "brand": {
    "id": "biz_9f2c1a",
    "display_name": "CurbFare"
  },
  "send_blocked": {
    "blocked": false,
    "code": 0,
    "message": ""
  }
}
```

## Clarify an unclassifiable turn

**post** `/v1/chats/{chat}/clarify`

Asks the customer one clarification question on your behalf, or
escalates when this chat has already used it.

**Behavior**

- No request body.
- Each chat gets one clarification question. A second call escalates
  instead of asking again, and the response's `action` says which
  happened.
- Returns HTTP 202: the question is applied a moment after the response.
- The question runs as a platform flow: the chat's `owner` becomes
  `flow` while it awaits the customer, and your sends during that window
  return HTTP 409 `code` 2011. A rephrase hands the turn straight back
  to you to classify; a reply asking for a person, or five minutes of
  silence, escalates instead. Every ownership movement is recorded by
  `chat.owner_changed`, and the flow's own steps appear on
  `GET /v1/chats/{chat}/events`.

**Errors**

- HTTP 409 `code` 2011: you no longer hold the send turn.

### Path Parameters

- `chat: string`

### Returns

- `action: "clarified" or "escalated"`

  `clarified`: the one bounded question was asked; `escalated`: this chat had already used its question, so we escalated instead.

  - `"clarified"`

  - `"escalated"`

### Example

```http
curl https://messages.api.linqapp.com/v1/chats/$CHAT/clarify \
    -X POST \
    -H "Authorization: Bearer $LINQ_AMB_API_KEY"
```

#### Response

```json
{
  "action": "clarified"
}
```

## Resolve a chat

**post** `/v1/chats/{chat}/resolve`

Resolves the chat and starts the satisfaction survey Apple asks for at
resolution.

**Errors**

- HTTP 404 `code` 2001 when the chat does not exist for your brand:
  the same answer `/clarify` gives: both resolve the chat before they
  touch a flow.

**Behavior**

- No request body.
- Returns HTTP 202: the resolution is applied a moment after the
  response.
- Idempotent for 10 minutes: a second resolve inside that window returns
  HTTP 202 with `action: "already_resolved"` and does nothing, so a
  customer who answers the survey late is never surveyed twice. Past the
  window, a chat that genuinely finishes again can be resolved again.
- The survey runs as a platform flow: the chat's `owner` becomes `flow`
  while the satisfaction card awaits an answer, and your sends during
  that window return HTTP 409 `code` 2011. The flow ends on the
  customer's reply (the next message is consumed as the survey answer;
  one asking for a person escalates instead) or after 120 seconds of
  silence, and the turn then returns, recorded by `chat.owner_changed`.
  So resolve when the conversation is finished: a customer who writes
  back inside that window is answering the survey, not restarting the
  thread with you.

### Path Parameters

- `chat: string`

### Returns

- `action: "resolved" or "already_resolved"`

  `resolved`: the CSAT flow was started; `already_resolved`: this chat was resolved within the last 10 minutes, so the call was a no-op; never retry it.

  - `"resolved"`

  - `"already_resolved"`

### Example

```http
curl https://messages.api.linqapp.com/v1/chats/$CHAT/resolve \
    -X POST \
    -H "Authorization: Bearer $LINQ_AMB_API_KEY"
```

#### Response

```json
{
  "action": "resolved"
}
```

## Domain Types

### Chat View

- `ChatView object { id, brand, capabilities, 14 more }`

  The partner view of a chat.

  - `id: string`

    Chat id (`chat_…`). The LIST endpoint spells the same fact `chat_id`; neither is renamed to match the other.

  - `brand: object { id, business_hours, display_name, hours_timezone }`

    This chat's brand and its configured display/hours.

    - `id: string`

      The brand this chat belongs to; always your own.

    - `business_hours: optional unknown`

      Business hours as stored: a LIST of `["HH:MM","HH:MM"]` ranges per lowercase weekday key (`mon`…`sun`), so a split shift is expressible. Absent when no hours are configured.

    - `display_name: optional string`

      The brand's display name; absent when it has none.

    - `hours_timezone: optional string`

      The IANA zone the hours are stated in (`America/New_York`). Hours are never configured without it; it may be configured without hours.

  - `capabilities: array of string`

    The device's most recently announced capability tokens. Empty means unknown, never "supports nothing".

  - `consent: unknown`

    Per-category consent for this chat's subject, scoped to the caller's brand (empty when none is recorded).

  - `customer_handle: string`

    The customer identifier: an Apple Opaque ID, or canonical `tel:+E164`, per `handle_kind`.

  - `handle_kind: "opaque" or "tel"`

    Which identifier kind `customer_handle` holds; branch on this, never read the string itself. A `tel` chat is not addressable for sends or typing.

    - `"opaque"`

    - `"tel"`

  - `origin: "customer" or "invitation"`

    How the chat began. Not derivable from `handle_kind`: an accepted invitation arrives under a brand-new opaque id.

    - `"customer"`

    - `"invitation"`

  - `owner: string`

    Turn owner: `partner`, `flow`, `human_pending` or `human`. Sends while not `partner` return HTTP 409 `code` 2011. `flow` is a platform flow holding the turn, typically the survey `/resolve` starts or the question `/clarify` asks: it ends on the customer's answer or its own timeout, and ownership then returns, recorded by `chat.owner_changed`.

  - `owner_since: string`

    When the chat entered its CURRENT owner state: the queue clock, NOT `updated_at` (which any progress bumps). Stamped only when the owner actually changes. One exception, and it is self-clearing: a chat that has not changed owner since the column was deployed reports the deploy time instead, which is a CEILING: deploy time is later than the true entry, so a wait derived from it reads SHORTER than the real one until that chat's next owner move. Don't page on it alone.

  - `send_blocked: object { blocked, code, message }`

    Whether a send would be refused right now, and why; read it BEFORE composing.

    - `blocked: boolean`

    - `code: number`

      The code the send would return, byte-identical to the `error.code` a real send would answer with; `0` when `blocked` is false.

    - `message: string`

      The message the send would return, verbatim; empty when `blocked` is false.

  - `state: string`

    Lifecycle phase: `open` or `closed`. Sends to a closed chat returns HTTP 409 `code` 2010.

  - `updated_at: string`

    Last change of any kind, machine progress included.

  - `assigned_operator: optional string`

    The operator holding this chat; absent when nobody does (the common case).

  - `business_id: optional string`

    The Apple business UUID your brand currently resolves to on this channel: the channel binding of record, present only when one is on file. This is the current binding, not a per-message historical value, so it is the id you can act on today. Distinct from `brand.id`, which is your logical brand on this platform.

  - `capabilities_announced_at: optional string`

    When a device last ANNOUNCED a `capability-list`; NOT when we last heard from them (an inbound carrying no capability header moves `last_inbound_at` and leaves this alone). Absent means no device has ever announced.

  - `capture: optional object { expires_at, group_id, opened_at, page_id }`

    The chat's open reply capture (a degraded form mid-collection), so a takeover desk can warn before ending it: the group id `form.response` will carry, your page id, and the open/expiry instants. Absent when none is open; an expired capture is filtered out here, never closed by a read.

    - `expires_at: string`

    - `group_id: string`

      The degraded fan's correlation id: the primary prompt message's own id.

    - `opened_at: string`

    - `page_id: optional string`

      Your form page's id.

  - `last_inbound_at: optional string`

    When we last heard from the customer; any inbound, a message or a chat close. ABSENT when they have never written (an invitation nobody has answered yet). A typing indicator is not recorded and does not move it; neither does anything the business sends.

### Consent Summary

- `ConsentSummary object { state, expires_at }`

  A subject's current consent for one category.

  - `state: string`

    `granted` or `revoked`.

  - `expires_at: optional string`

    Grant expiry; absent when the record carries none.

### Chat List Response

- `ChatListResponse object { data, next_cursor }`

  One page of chats, newest-first.

  - `data: array of object { capabilities, chat_id, customer_handle, 10 more }`

    - `capabilities: array of string`

      The device's most recently announced capability tokens. Empty means unknown, never "supports nothing".

    - `chat_id: string`

      Chat id (`chat_…`); pass it to `GET /v1/chats/{chat}` for the full view.

    - `customer_handle: string`

      The customer identifier: an Apple Opaque ID, or canonical `tel:+E164`, per `handle_kind`.

    - `handle_kind: "opaque" or "tel"`

      Which identifier kind `customer_handle` holds; branch on this, never read the string itself.

      - `"opaque"`

      - `"tel"`

    - `origin: "customer" or "invitation"`

      How the chat began. Not derivable from `handle_kind`: an accepted invitation arrives under a brand-new opaque id.

      - `"customer"`

      - `"invitation"`

    - `owner: string`

      Turn owner: `partner`, `flow`, `human_pending` or `human`. `human_pending` is the queue.

    - `owner_since: string`

      When the chat entered its CURRENT owner state: the queue clock, NOT `updated_at` (which any progress bumps). Stamped only when the owner actually changes; see `GET /v1/chats/{chat}` for the one self-clearing exception.

    - `state: string`

      Lifecycle phase: `open` or `closed`.

    - `updated_at: string`

      Last change of any kind: the list's sort key, and the first half of the cursor.

    - `assigned_operator: optional string`

      The operator holding this chat; absent when nobody does (the common case).

    - `business_id: optional string`

      The Apple business UUID your brand currently resolves to on this channel: the channel binding of record, present only when one is on file. The same value `GET /v1/chats/{chat}` serves.

    - `last_inbound_at: optional string`

      When we last heard from the customer; any inbound, a message or a chat close. ABSENT when they have never written (an invitation nobody has answered yet).

    - `last_message: optional object { actor, at, has_attachment, 2 more }`

      The newest message on this chat, so the row has a subject line instead of
      only a handle and a wait age. ABSENT when the chat has nothing
      previewable: an invitation nobody has answered, a chat whose only events
      are ownership changes, or a message that decoded to nothing. Render
      nothing in that case; never invent a preview.

      - `actor: string`

        Who spoke: `customer` on an inbound, `partner` on an outbound. It is the
        event log's actor, not the composer: a send is recorded by the worker
        that delivered it, so a reply a human agent typed still reads `partner`
        here. `GET /v1/chats/{chat}/transcript` is where a human is named.

      - `at: string`

        When that message was recorded. Distinct from both of its neighbours on
        this row: `last_inbound_at` moves only on inbound, and `updated_at` moves
        on any machine progress. Do not treat the three as one clock.

      - `has_attachment: boolean`

        Whether the message carried files. Deliberately ORTHOGONAL to `kind`,
        because the two answer different questions: `kind` says what to write on
        the row and this says whether to badge it. `kind: "text"` with this `true`
        is words PLUS a file: the one multi-part combination Apple admits.

      - `kind: "text" or "attachment" or "interactive" or "reply"`

        What kind of thing it was: a CLOSED set. `text`: there were words.
        `attachment`: there were none, only files. `reply`: the customer answered
        an interactive message instead of typing. `interactive`: the business sent
        one. Words win when a message has both.

        - `"text"`

        - `"attachment"`

        - `"interactive"`

        - `"reply"`

      - `preview: optional string`

        The opening words, with attachment placeholders (`￼`) removed and a single
        `…` where it was cut. ABSENT rather than empty when no words survived: an
        attachment-only message has a body of exactly `￼`, and a preview built
        from it verbatim would put an invisible character on the row. Say "sent a
        file" in your own words instead; this API does not ship desk copy.

  - `next_cursor: string`

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

### Chat Clarify Response

- `ChatClarifyResponse object { action }`

  What `/clarify` did on your behalf.

  - `action: "clarified" or "escalated"`

    `clarified`: the one bounded question was asked; `escalated`: this chat had already used its question, so we escalated instead.

    - `"clarified"`

    - `"escalated"`

### Chat Resolve Response

- `ChatResolveResponse object { action }`

  What `/resolve` did.

  - `action: "resolved" or "already_resolved"`

    `resolved`: the CSAT flow was started; `already_resolved`: this chat was resolved within the last 10 minutes, so the call was a no-op; never retry it.

    - `"resolved"`

    - `"already_resolved"`

# Messages

## Send a message

**post** `/v1/chats/{chat}/messages`

Sends a message into a chat.

**Requirements**

- `Idempotency-Key` header: required. A UUID you mint per logical send.
- The chat must be open, and you must hold the send turn (see Chat
  control).

**Behavior**

- Replaying the same `Idempotency-Key` within 24 hours returns the
  original response. A refused request records no key, so the same key
  succeeds on retry.
- An `https` URL in message text may be promoted to its own rich-link
  message. Each resulting message has its own `message.sent` or
  `message.failed` lifecycle, correlated by `group_id`.
- Rate limiting is per brand: only your own traffic can produce it, and
  `Retry-After` is computed from your brand's own refill rate. A
  rate-limited request is not validated: an invalid send still fails
  with its own error once you are back within rate. A request that fans
  out into several messages costs one rate credit per message.

**Errors**

- HTTP 400: `idempotency_key_required`, `body_invalid`
- HTTP 403: `consent_required`
- HTTP 409: `chat_closed`, `chat_owned`, `capability_unsupported`,
  `idempotency_key_reused`, `idempotency_key_processing` while another
  request with the same key is still in flight
- HTTP 413: `attachment_source_too_large`
- HTTP 422: field-level validation (`category_invalid`, `degrade_invalid`,
  `attachment_source_invalid`, `attachment_source_empty`, and per-part
  codes), each naming the exact field at fault
- HTTP 429: `rate_limited`, with `Retry-After`
- HTTP 502: `attachment_source_unavailable`
- HTTP 503: `send_paused`, `attachment_source_busy` (with `Retry-After`),
  or `attachment_storage_unavailable`

### Path Parameters

- `chat: string`

### Query Parameters

- `force: optional boolean`

  `true` bypasses the exclusive-owner gate (sends while
  `owner != partner`). A real override of a non-partner owner is audited
  (`chat.send_forced`). Does NOT override consent policy.

### Header Parameters

- `"Idempotency-Key": string`

### Body Parameters

- `parts: array of TextPart or ChoicesPart or ListPickerPart or 8 more`

  The message content, ≤20 parts. Structural rule (adapter L3): at most one
  interactive part per message, and `text` may not be combined with an
  interactive part; send two messages. An empty array returns HTTP 422 `code` 1052.

  - `TextPart object { body, type, subject }`

    A plain text message. subject renders bold on AMB.

    - `body: string`

    - `type: "text"`

      - `"text"`

    - `subject: optional string`

  - `ChoicesPart object { items, summary_text, type }`

    AMB Quick Reply: single-select items. summary_text is the transcript label, not the prompt; send the prompt as a preceding text message.

    - `items: array of object { id, label }`

      - `id: string`

      - `label: string`

    - `summary_text: string`

    - `type: "choices"`

      - `"choices"`

  - `ListPickerPart object { sections, summary_text, type, 4 more }`

    AMB List Picker: sections of items, per-section multi-select. Item image_id and bubble_image_id reference the shared images pool by id.

    - `sections: array of object { items, title, multi_select }`

      - `items: array of object { id, label, detail, image_id }`

        - `id: string`

        - `label: string`

        - `detail: optional string`

        - `image_id: optional string`

      - `title: string`

      - `multi_select: optional boolean`

    - `summary_text: string`

    - `type: "list_picker"`

      - `"list_picker"`

    - `bubble_image_id: optional string`

    - `bubble_style: optional "icon" or "small" or "large"`

      Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble.

      - `"icon"`

      - `"small"`

      - `"large"`

    - `images: optional array of object { id, att_id, description }`

      - `id: string`

      - `att_id: string`

        A stored attachment's att_id; never inline base64.

      - `description: optional string`

        Read aloud by VoiceOver.

    - `subtitle: optional string`

  - `TimeSlotsPart object { event, slots, type, 5 more }`

    AMB Time Picker: whole-minute, future-only slot starts; duration in seconds; timezone offset in minutes.

    - `event: object { title, image_id, location }`

      - `title: string`

      - `image_id: optional string`

      - `location: optional object { latitude, longitude, radius_m, title }`

        - `latitude: optional number`

        - `longitude: optional number`

        - `radius_m: optional number`

        - `title: optional string`

    - `slots: array of object { id, duration_s, start }`

      - `id: string`

      - `duration_s: number`

      - `start: string`

    - `type: "time_slots"`

      - `"time_slots"`

    - `bubble_image_id: optional string`

    - `bubble_style: optional "icon" or "small" or "large"`

      Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble.

      - `"icon"`

      - `"small"`

      - `"large"`

    - `images: optional array of object { id, att_id, description }`

      - `id: string`

      - `att_id: string`

        A stored attachment's att_id; never inline base64.

      - `description: optional string`

        Read aloud by VoiceOver.

    - `subtitle: optional string`

    - `timezone_offset_min: optional number`

  - `FormPart object { header, pages, type, 7 more }`

    AMB Message Form (dynamic v1.2). Page kinds: select, picker, datePicker, input. The last page is the submit page. Omitting show_summary means true.

    - `header: string`

    - `pages: array of object { id, kind, subtitle, 15 more }`

      - `id: string`

      - `kind: "select" or "picker" or "datePicker" or "input"`

        - `"select"`

        - `"picker"`

        - `"datePicker"`

        - `"input"`

      - `subtitle: string`

        The question shown to the user.

      - `date_format: optional string`

        datePicker only. ICU pattern, default MM/dd/yyyy. It governs how the device reads the three date strings.

      - `hint_text: optional string`

        datePicker only.

      - `items: optional array of object { id, label, image_id }`

        select and picker only.

        - `id: string`

        - `label: string`

        - `image_id: optional string`

          select items only.

      - `keyboard_type: optional "default" or "asciiCapable" or "numbersAndPunctuation" or 7 more`

        input only.

        - `"default"`

        - `"asciiCapable"`

        - `"numbersAndPunctuation"`

        - `"URL"`

        - `"numberPad"`

        - `"phonePad"`

        - `"namePhonePad"`

        - `"emailAddress"`

        - `"decimalPad"`

        - `"webSearch"`

      - `label_text: optional string`

        datePicker only; defaults to "Date".

      - `max_chars: optional number`

        input only.

      - `maximum_date: optional string`

        datePicker only, in date_format.

      - `minimum_date: optional string`

        datePicker only, in date_format.

      - `multiple: optional boolean`

        select only.

      - `next_page_id: optional string`

      - `picker_title: optional string`

        picker only; empty centers the field.

      - `required: optional boolean`

        input only.

      - `selected_item_index: optional number`

        picker only.

      - `start_date: optional string`

        datePicker only, in date_format.

      - `title: optional string`

    - `type: "form"`

      - `"form"`

    - `bubble_image_id: optional string`

      Icon on the received/reply message bubble.

    - `bubble_style: optional "icon" or "small" or "large"`

      Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble.

      - `"icon"`

      - `"small"`

      - `"large"`

    - `images: optional array of object { id, att_id, description }`

      - `id: string`

      - `att_id: string`

        A stored attachment's att_id; never inline base64.

      - `description: optional string`

        Read aloud by VoiceOver.

    - `show_summary: optional boolean`

    - `splash_button_title: optional string`

      Defaults to "Start". The splash view shows when any splash field is set.

    - `splash_image_id: optional string`

    - `splash_text: optional string`

  - `RichLinkPart object { title, type, url, 5 more }`

    A rich link card. Omit image_att_id to get the default card. The image must be a real PNG.

    - `title: string`

    - `type: "rich_link"`

      - `"rich_link"`

    - `url: string`

    - `app_clip: optional boolean`

      Set true when url is an App Clip page, so the App Clip payload is constructed server side (POST /v1/constructPayload). Apple Music and Apple Maps URLs need no flag; they are detected by host. The page must carry App Clip elements and its image must be under 4 MB; if construction fails, the send fails rather than delivering a plain card.

    - `image_att_id: optional string`

    - `store_region: optional string`

      The App Store region Apple resolves the App Clip in, as a 2-letter uppercase ISO-3166 code. Omit for the US, which is Apple's default. Requires app_clip: true; Apple scopes this to App Clips, so setting it on any other rich link is rejected rather than ignored.

    - `video_mime_type: optional string`

      MIME type of the video at video_url. video/mp4 is the recommended container.

    - `video_url: optional string`

      Public https URL of a video asset to play inline in the card. Apple's device fetches this URL client-side when the customer taps play; it must serve the video bytes from the open internet. We never fetch, validate, or re-host them. Requires video_mime_type and image_att_id.

  - `AttachmentPart object { att_id, type }`

    An attachment by reference: att_id names a file the platform holds for your brand, such as an inbound attachment we retained (the id on the message.received event). To send a file you host, use this part's source_url form instead; never inline base64.

    - `att_id: string`

    - `type: "attachment"`

      - `"attachment"`

  - `ApplePayPart object { payment_request, received_message, type }`

    AMB Apple Pay payment request. We fill in the merchant identity, gateway URL and merchant session from your brand's configuration, so merchant_identifier, endpoints and merchant_session are rejected as unknown fields. Amounts are decimal strings.

    - `payment_request: object { country_code, currency_code, line_items, 7 more }`

      - `country_code: string`

      - `currency_code: string`

      - `line_items: array of object { amount, label, type }`

        - `amount: string`

        - `label: string`

        - `type: optional "final" or "pending" or ""`

          Empty means unset.

          - `"final"`

          - `"pending"`

          - `""`

      - `merchant_capabilities: array of "supports3DS" or "supportsCredit" or "supportsDebit" or "supportsEMV"`

        - `"supports3DS"`

        - `"supportsCredit"`

        - `"supportsDebit"`

        - `"supportsEMV"`

      - `supported_networks: array of "amex" or "discover" or "jcb" or 3 more`

        - `"amex"`

        - `"discover"`

        - `"jcb"`

        - `"masterCard"`

        - `"privateLabel"`

        - `"visa"`

      - `total: object { amount, label, type }`

        The grand total.

        - `amount: string`

        - `label: string`

        - `type: optional "final" or "pending" or ""`

          Empty means unset.

          - `"final"`

          - `"pending"`

          - `""`

      - `required_billing_contact_fields: optional array of "email" or "name" or "phone" or 2 more`

        - `"email"`

        - `"name"`

        - `"phone"`

        - `"phoneticName"`

        - `"post"`

      - `required_shipping_contact_fields: optional array of "email" or "name" or "phone" or 2 more`

        - `"email"`

        - `"name"`

        - `"phone"`

        - `"phoneticName"`

        - `"post"`

      - `shipping_methods: optional array of object { amount, detail, identifier, label }`

        - `amount: string`

        - `detail: string`

        - `identifier: string`

        - `label: string`

      - `supported_countries: optional array of string`

    - `received_message: object { title, image_att_id, subtitle }`

      The bubble the customer sees before authorizing.

      - `title: string`

      - `image_att_id: optional string`

      - `subtitle: optional string`

    - `type: "apple_pay"`

      - `"apple_pay"`

  - `AuthenticatePart object { received_title, reply_title, scope, 3 more }`

    Opens Apple's sign-in sheet (New Authentication Message). You supply the OAuth scope and the two bubble titles. We fill in state and redirect_uri when the customer accepts.

    - `received_title: string`

      The bubble the customer sees.

    - `reply_title: string`

      The bubble after completion.

    - `scope: array of string`

    - `type: "authenticate"`

      - `"authenticate"`

    - `redirect_uri: optional string`

    - `state: optional string`

  - `AppExtensionPart object { app, received_message, reply_message, 4 more }`

    A third-party App Extension bubble. We fill in the app identity (bid, appId, appName, appIcon) from your brand's registered apps, so those are rejected as request fields. We never read url; it is handed to the extension exactly as you sent it.

    - `app: string`

      Which of the brand's registered apps to send under.

    - `received_message: object { title, subtitle }`

      Shown on a device without the extension installed.

      - `title: string`

      - `subtitle: optional string`

    - `reply_message: object { title, subtitle }`

      Shown on a device without the extension installed.

      - `title: string`

      - `subtitle: optional string`

    - `type: "app_extension"`

      - `"app_extension"`

    - `url: string`

      The payload the Messages app hands to the extension. Opaque: never parsed or rewritten.

    - `session_id: optional string`

      Reuse to address a card already sent; omit and Apple generates one.

    - `use_live_layout: optional boolean`

      Defaults to true.

  - `SourceAttachmentPart object { name, source_url, type }`

    A partner-hosted attachment imported synchronously before the send is
    accepted. Linq stores a frozen copy; later source changes cannot alter
    delivery. The URL must be public HTTPS on port 443 and require no headers,
    cookies or authentication. Redirects are accepted only when every hop
    passes the same secure URL policy.

    - `name: string`

      Filename delivered to the customer.

    - `source_url: string`

      Public HTTPS URL fetched before acceptance.

    - `type: "attachment"`

      - `"attachment"`

- `category: optional "transactional" or "account_notification" or "marketing"`

  Apple notification category. OPTIONAL by contract: absent means
  `transactional` (an in-chat reply): a bare
  `{"parts":[…]}` must keep working bit-for-bit. Unknown value →
  422 `code` 1010. A non-transactional send with no valid opt-in on
  file returns HTTP 403 `code` 2015 (`force=true` does NOT override consent).

  - `"transactional"`

  - `"account_notification"`

  - `"marketing"`

- `degrade: optional "reject" or "auto" or "acknowledged"`

  Capability fallback opt-in. Absent or `reject` (the default): an
  unsupported part type is refused 409 `code` 4005. `auto`:
  the part is rewritten to the best representation the device renders, and
  the response reports each swap it made in `fallbacks`. `acknowledged`: `auto`, plus the statement that a HUMAN has
  seen what the downgrade produces and accepted it; identical to `auto`
  for an automated caller, and the ONLY value that lets a human-attributed
  send be rewritten (a send attributed to a named operator that `auto`
  would rewrite is refused 409 `code` 4005, outright rather
  than warned about). Any other value →
  422 `code` 1091.

  - `"reject"`

  - `"auto"`

  - `"acknowledged"`

- `typing_lead_ms: optional number`

  How long the typing indicator runs before this message, in MILLISECONDS.

  THE INDICATOR IS YOURS TO ASK FOR. We show one for exactly as long as you
  request and never on our own, so this field is the whole control: absent
  or `0` shows no indicator at all, and a positive value shows one for
  precisely that long before the message reaches the customer.

  OPTIONAL by contract, and absent means the same as `0`: no indicator.
  We recommend asking for about 1000 ms before each message you send, and
  longer when the customer is about to wait on a lookup or a handoff, so
  the pause reads as thinking rather than as a stall.

  Bounded at 15000 (422 `code` 1096, which is also what a negative
  value gets). The cap is derived from the budget one send attempt gets: a
  longer indicator eats the time the message send itself needs, so the send
  would be cut short and retried and the customer would watch a long
  indicator followed by a late message or none.

### Returns

- `id: string`

  The FIRST message's id: the send's primary id.

- `chat_id: string`

- `delivery: "queued"`

  - `"queued"`

- `messages: array of SentMessage`

  Every message this request was accepted as, in delivery order; ALWAYS
  present, one element for an ordinary send. Longer when an `https` URL in
  your text was promoted to its own rich-link message:
  each element then has its own `message.sent`/`message.failed` lifecycle,
  correlated by `group_id`. More than 10 messages returns HTTP 422 `code` 1075.

  - `id: string`

    Message id (`msg_…`).

  - `type: string`

    The part type of that message's FIRST part.

  - `parts: optional array of unknown`

    The message's canonical parts AS STORED at accept: post-degrade, post-promotion, so you see what the customer will actually receive without a second read. Typed opaque for the same one-decode-door reason `TranscriptRow.parts` is.

- `capture: optional object { collection, expects, page_id, 4 more }`

  The reply capture this send opened; see `CaptureObject`. Absent when no capture opened.

  - `collection: string`

    `single_shot`: the whole prompt sequence goes at once. `progressive` is reserved.

  - `expects: string`

    The reply shape we will match: `choice`, `multi_choice`, `text`, `phone`, `email`, or `date_text` (a date question at the text floor records the customer's words verbatim; `date_choice` is reserved for a future variant that offers concrete slots).

  - `page_id: string`

    Your form page's own id: the key the eventual `reply.values` uses.

  - `reasks: number`

    How many re-asks a mismatched reply earns before the collection ends `abandoned`. Fixed at 1.

  - `timeout_s: number`

    How long the collection stays open, in seconds. Fixed at 1800.

  - `group_id: optional string`

    ABSENT on the preview (nothing was minted); on the send `200`, the fan's correlation id: the primary message's own id, the value `form.response` reports back.

  - `options: optional array of object { index, item_id, label }`

    Present for the choice shapes only: what the customer can answer with, by number or label.

    - `index: number`

    - `item_id: string`

    - `label: string`

- `fallbacks: optional array of Fallback`

  Present ONLY when `degrade` (`"auto"` or `"acknowledged"`) actually
  rewrote a part: its presence is the signal that the customer saw
  something other than what you composed, and an acknowledgement does not
  suppress it. Absent otherwise (never an empty array).

  - `token: string`

    The missing capability token that forced the rewrite.

  - `from: string`

    Canonical part type in.

  - `rule: string`

    The fallback-tree rule applied (e.g. `guide-17.5-select-le5`).

  - `to: string`

    Canonical part type out. For a rewritten form this is `text_sequence`: the whole degraded fan is one rewrite outcome, stable across how many messages it produced; which types those messages carry is on the response's own `messages[]`.

  - `options: optional array of FallbackOption`

    Present only when the rewrite flattened a menu.

    - `index: number`

      1-based, matching the numbering the degraded copy emits.

    - `item_id: string`

      The original item id, so a customer's "2" maps back to your routing.

    - `label: string`

### Example

```http
curl https://messages.api.linqapp.com/v1/chats/$CHAT/messages \
    -H 'Content-Type: application/json' \
    -H "Authorization: Bearer $LINQ_AMB_API_KEY" \
    -d '{
          "parts": [
            {
              "body": "Your driver Marta is two minutes away in a blue Prius.",
              "type": "text"
            }
          ]
        }'
```

#### Response

```json
{
  "id": "msg_2c7d90",
  "chat_id": "chat_4f81b2",
  "delivery": "queued",
  "messages": [
    {
      "id": "msg_2c7d90",
      "type": "text"
    }
  ]
}
```

## Preview a message

**post** `/v1/chats/{chat}/messages/preview`

Previews a send without sending it. Use it to surface field-level errors
and capability rewrites in a composer before anything reaches the
customer.

**Behavior**

- Runs the identical accept pipeline as the send: the same validation,
  chat gates, `degrade` decision, and consent policy, but records
  nothing: no message id is minted, no idempotency claim is made, and no
  `Idempotency-Key` is required.
- A body this endpoint refuses, the send refuses with the same `code`
  and `param`, except that a `source_url` attachment is not fetched during
  preview. Its reachability, MIME, byte size and storage readiness are
  therefore send-time facts, and the preview returns the unresolved source
  form rather than an `att_id`. Otherwise a body preview accepts is
  accepted by send, chat state permitting, since a preview cannot freeze
  the chat between the two calls.

**Limits**

- The per-message `wire` field is representative, never exact;
  `wire_fidelity` states this in the response. Values resolved only at
  delivery are substituted or omitted: payment sessions and app
  identities appear as placeholders, image bytes are omitted, and a
  plain attachment message has no `wire` at all. Do not present it as
  the exact payload Apple will receive.

### Path Parameters

- `chat: string`

### Query Parameters

- `force: optional boolean`

  Same meaning as on the send: `true` previews the send as an override of the exclusive-owner gate.

### Body Parameters

- `parts: array of TextPart or ChoicesPart or ListPickerPart or 8 more`

  The message content, ≤20 parts. Structural rule (adapter L3): at most one
  interactive part per message, and `text` may not be combined with an
  interactive part; send two messages. An empty array returns HTTP 422 `code` 1052.

  - `TextPart object { body, type, subject }`

    A plain text message. subject renders bold on AMB.

    - `body: string`

    - `type: "text"`

      - `"text"`

    - `subject: optional string`

  - `ChoicesPart object { items, summary_text, type }`

    AMB Quick Reply: single-select items. summary_text is the transcript label, not the prompt; send the prompt as a preceding text message.

    - `items: array of object { id, label }`

      - `id: string`

      - `label: string`

    - `summary_text: string`

    - `type: "choices"`

      - `"choices"`

  - `ListPickerPart object { sections, summary_text, type, 4 more }`

    AMB List Picker: sections of items, per-section multi-select. Item image_id and bubble_image_id reference the shared images pool by id.

    - `sections: array of object { items, title, multi_select }`

      - `items: array of object { id, label, detail, image_id }`

        - `id: string`

        - `label: string`

        - `detail: optional string`

        - `image_id: optional string`

      - `title: string`

      - `multi_select: optional boolean`

    - `summary_text: string`

    - `type: "list_picker"`

      - `"list_picker"`

    - `bubble_image_id: optional string`

    - `bubble_style: optional "icon" or "small" or "large"`

      Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble.

      - `"icon"`

      - `"small"`

      - `"large"`

    - `images: optional array of object { id, att_id, description }`

      - `id: string`

      - `att_id: string`

        A stored attachment's att_id; never inline base64.

      - `description: optional string`

        Read aloud by VoiceOver.

    - `subtitle: optional string`

  - `TimeSlotsPart object { event, slots, type, 5 more }`

    AMB Time Picker: whole-minute, future-only slot starts; duration in seconds; timezone offset in minutes.

    - `event: object { title, image_id, location }`

      - `title: string`

      - `image_id: optional string`

      - `location: optional object { latitude, longitude, radius_m, title }`

        - `latitude: optional number`

        - `longitude: optional number`

        - `radius_m: optional number`

        - `title: optional string`

    - `slots: array of object { id, duration_s, start }`

      - `id: string`

      - `duration_s: number`

      - `start: string`

    - `type: "time_slots"`

      - `"time_slots"`

    - `bubble_image_id: optional string`

    - `bubble_style: optional "icon" or "small" or "large"`

      Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble.

      - `"icon"`

      - `"small"`

      - `"large"`

    - `images: optional array of object { id, att_id, description }`

      - `id: string`

      - `att_id: string`

        A stored attachment's att_id; never inline base64.

      - `description: optional string`

        Read aloud by VoiceOver.

    - `subtitle: optional string`

    - `timezone_offset_min: optional number`

  - `FormPart object { header, pages, type, 7 more }`

    AMB Message Form (dynamic v1.2). Page kinds: select, picker, datePicker, input. The last page is the submit page. Omitting show_summary means true.

    - `header: string`

    - `pages: array of object { id, kind, subtitle, 15 more }`

      - `id: string`

      - `kind: "select" or "picker" or "datePicker" or "input"`

        - `"select"`

        - `"picker"`

        - `"datePicker"`

        - `"input"`

      - `subtitle: string`

        The question shown to the user.

      - `date_format: optional string`

        datePicker only. ICU pattern, default MM/dd/yyyy. It governs how the device reads the three date strings.

      - `hint_text: optional string`

        datePicker only.

      - `items: optional array of object { id, label, image_id }`

        select and picker only.

        - `id: string`

        - `label: string`

        - `image_id: optional string`

          select items only.

      - `keyboard_type: optional "default" or "asciiCapable" or "numbersAndPunctuation" or 7 more`

        input only.

        - `"default"`

        - `"asciiCapable"`

        - `"numbersAndPunctuation"`

        - `"URL"`

        - `"numberPad"`

        - `"phonePad"`

        - `"namePhonePad"`

        - `"emailAddress"`

        - `"decimalPad"`

        - `"webSearch"`

      - `label_text: optional string`

        datePicker only; defaults to "Date".

      - `max_chars: optional number`

        input only.

      - `maximum_date: optional string`

        datePicker only, in date_format.

      - `minimum_date: optional string`

        datePicker only, in date_format.

      - `multiple: optional boolean`

        select only.

      - `next_page_id: optional string`

      - `picker_title: optional string`

        picker only; empty centers the field.

      - `required: optional boolean`

        input only.

      - `selected_item_index: optional number`

        picker only.

      - `start_date: optional string`

        datePicker only, in date_format.

      - `title: optional string`

    - `type: "form"`

      - `"form"`

    - `bubble_image_id: optional string`

      Icon on the received/reply message bubble.

    - `bubble_style: optional "icon" or "small" or "large"`

      Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble.

      - `"icon"`

      - `"small"`

      - `"large"`

    - `images: optional array of object { id, att_id, description }`

      - `id: string`

      - `att_id: string`

        A stored attachment's att_id; never inline base64.

      - `description: optional string`

        Read aloud by VoiceOver.

    - `show_summary: optional boolean`

    - `splash_button_title: optional string`

      Defaults to "Start". The splash view shows when any splash field is set.

    - `splash_image_id: optional string`

    - `splash_text: optional string`

  - `RichLinkPart object { title, type, url, 5 more }`

    A rich link card. Omit image_att_id to get the default card. The image must be a real PNG.

    - `title: string`

    - `type: "rich_link"`

      - `"rich_link"`

    - `url: string`

    - `app_clip: optional boolean`

      Set true when url is an App Clip page, so the App Clip payload is constructed server side (POST /v1/constructPayload). Apple Music and Apple Maps URLs need no flag; they are detected by host. The page must carry App Clip elements and its image must be under 4 MB; if construction fails, the send fails rather than delivering a plain card.

    - `image_att_id: optional string`

    - `store_region: optional string`

      The App Store region Apple resolves the App Clip in, as a 2-letter uppercase ISO-3166 code. Omit for the US, which is Apple's default. Requires app_clip: true; Apple scopes this to App Clips, so setting it on any other rich link is rejected rather than ignored.

    - `video_mime_type: optional string`

      MIME type of the video at video_url. video/mp4 is the recommended container.

    - `video_url: optional string`

      Public https URL of a video asset to play inline in the card. Apple's device fetches this URL client-side when the customer taps play; it must serve the video bytes from the open internet. We never fetch, validate, or re-host them. Requires video_mime_type and image_att_id.

  - `AttachmentPart object { att_id, type }`

    An attachment by reference: att_id names a file the platform holds for your brand, such as an inbound attachment we retained (the id on the message.received event). To send a file you host, use this part's source_url form instead; never inline base64.

    - `att_id: string`

    - `type: "attachment"`

      - `"attachment"`

  - `ApplePayPart object { payment_request, received_message, type }`

    AMB Apple Pay payment request. We fill in the merchant identity, gateway URL and merchant session from your brand's configuration, so merchant_identifier, endpoints and merchant_session are rejected as unknown fields. Amounts are decimal strings.

    - `payment_request: object { country_code, currency_code, line_items, 7 more }`

      - `country_code: string`

      - `currency_code: string`

      - `line_items: array of object { amount, label, type }`

        - `amount: string`

        - `label: string`

        - `type: optional "final" or "pending" or ""`

          Empty means unset.

          - `"final"`

          - `"pending"`

          - `""`

      - `merchant_capabilities: array of "supports3DS" or "supportsCredit" or "supportsDebit" or "supportsEMV"`

        - `"supports3DS"`

        - `"supportsCredit"`

        - `"supportsDebit"`

        - `"supportsEMV"`

      - `supported_networks: array of "amex" or "discover" or "jcb" or 3 more`

        - `"amex"`

        - `"discover"`

        - `"jcb"`

        - `"masterCard"`

        - `"privateLabel"`

        - `"visa"`

      - `total: object { amount, label, type }`

        The grand total.

        - `amount: string`

        - `label: string`

        - `type: optional "final" or "pending" or ""`

          Empty means unset.

          - `"final"`

          - `"pending"`

          - `""`

      - `required_billing_contact_fields: optional array of "email" or "name" or "phone" or 2 more`

        - `"email"`

        - `"name"`

        - `"phone"`

        - `"phoneticName"`

        - `"post"`

      - `required_shipping_contact_fields: optional array of "email" or "name" or "phone" or 2 more`

        - `"email"`

        - `"name"`

        - `"phone"`

        - `"phoneticName"`

        - `"post"`

      - `shipping_methods: optional array of object { amount, detail, identifier, label }`

        - `amount: string`

        - `detail: string`

        - `identifier: string`

        - `label: string`

      - `supported_countries: optional array of string`

    - `received_message: object { title, image_att_id, subtitle }`

      The bubble the customer sees before authorizing.

      - `title: string`

      - `image_att_id: optional string`

      - `subtitle: optional string`

    - `type: "apple_pay"`

      - `"apple_pay"`

  - `AuthenticatePart object { received_title, reply_title, scope, 3 more }`

    Opens Apple's sign-in sheet (New Authentication Message). You supply the OAuth scope and the two bubble titles. We fill in state and redirect_uri when the customer accepts.

    - `received_title: string`

      The bubble the customer sees.

    - `reply_title: string`

      The bubble after completion.

    - `scope: array of string`

    - `type: "authenticate"`

      - `"authenticate"`

    - `redirect_uri: optional string`

    - `state: optional string`

  - `AppExtensionPart object { app, received_message, reply_message, 4 more }`

    A third-party App Extension bubble. We fill in the app identity (bid, appId, appName, appIcon) from your brand's registered apps, so those are rejected as request fields. We never read url; it is handed to the extension exactly as you sent it.

    - `app: string`

      Which of the brand's registered apps to send under.

    - `received_message: object { title, subtitle }`

      Shown on a device without the extension installed.

      - `title: string`

      - `subtitle: optional string`

    - `reply_message: object { title, subtitle }`

      Shown on a device without the extension installed.

      - `title: string`

      - `subtitle: optional string`

    - `type: "app_extension"`

      - `"app_extension"`

    - `url: string`

      The payload the Messages app hands to the extension. Opaque: never parsed or rewritten.

    - `session_id: optional string`

      Reuse to address a card already sent; omit and Apple generates one.

    - `use_live_layout: optional boolean`

      Defaults to true.

  - `SourceAttachmentPart object { name, source_url, type }`

    A partner-hosted attachment imported synchronously before the send is
    accepted. Linq stores a frozen copy; later source changes cannot alter
    delivery. The URL must be public HTTPS on port 443 and require no headers,
    cookies or authentication. Redirects are accepted only when every hop
    passes the same secure URL policy.

    - `name: string`

      Filename delivered to the customer.

    - `source_url: string`

      Public HTTPS URL fetched before acceptance.

    - `type: "attachment"`

      - `"attachment"`

- `category: optional "transactional" or "account_notification" or "marketing"`

  Apple notification category. OPTIONAL by contract: absent means
  `transactional` (an in-chat reply): a bare
  `{"parts":[…]}` must keep working bit-for-bit. Unknown value →
  422 `code` 1010. A non-transactional send with no valid opt-in on
  file returns HTTP 403 `code` 2015 (`force=true` does NOT override consent).

  - `"transactional"`

  - `"account_notification"`

  - `"marketing"`

- `degrade: optional "reject" or "auto" or "acknowledged"`

  Capability fallback opt-in. Absent or `reject` (the default): an
  unsupported part type is refused 409 `code` 4005. `auto`:
  the part is rewritten to the best representation the device renders, and
  the response reports each swap it made in `fallbacks`. `acknowledged`: `auto`, plus the statement that a HUMAN has
  seen what the downgrade produces and accepted it; identical to `auto`
  for an automated caller, and the ONLY value that lets a human-attributed
  send be rewritten (a send attributed to a named operator that `auto`
  would rewrite is refused 409 `code` 4005, outright rather
  than warned about). Any other value →
  422 `code` 1091.

  - `"reject"`

  - `"auto"`

  - `"acknowledged"`

- `typing_lead_ms: optional number`

  How long the typing indicator runs before this message, in MILLISECONDS.

  THE INDICATOR IS YOURS TO ASK FOR. We show one for exactly as long as you
  request and never on our own, so this field is the whole control: absent
  or `0` shows no indicator at all, and a positive value shows one for
  precisely that long before the message reaches the customer.

  OPTIONAL by contract, and absent means the same as `0`: no indicator.
  We recommend asking for about 1000 ms before each message you send, and
  longer when the customer is about to wait on a lookup or a handoff, so
  the pause reads as thinking rather than as a stall.

  Bounded at 15000 (422 `code` 1096, which is also what a negative
  value gets). The cap is derived from the budget one send attempt gets: a
  longer indicator eats the time the message send itself needs, so the send
  would be cut short and retried and the customer would watch a long
  indicator followed by a late message or none.

### Returns

- `PreviewMessageResponse object { chat_id, messages, wire_fidelity, 2 more }`

  The previewed send. Nothing was written; nothing will be sent.

  - `chat_id: string`

  - `messages: array of PreviewedMessage`

    What the ONE request would become, message by message (URL promotion can make several).

    - `parts: array of unknown`

      The message's post-degrade, post-promotion parts. A URL attachment remains
      in its source form because preview does not fetch it or mint the real
      `att_id` that an accepted send returns. Typed as an opaque array rather
      than `Part[]` for the same one-decode-door reason `TranscriptRow.parts` is.

    - `type: string`

      The part type of this message's FIRST part: the same value the send's `200` reports per message.

    - `wire: optional unknown`

      The representative Apple wire fields for this message; see the operation description for exactly what is substituted. Absent when `wire_unavailable` says why.

    - `wire_unavailable: optional string`

      Present only when this message has no wire preview (a plain attachment message), and why.

  - `wire_fidelity: "representative"`

    Always `representative`: the wire bytes are an approximation of what delivery sends, never the exact payload.

    - `"representative"`

  - `capture: optional object { collection, expects, page_id, 4 more }`

    The reply capture this send WOULD open, identical to the send `200`'s object except `group_id`, which is ABSENT here (nothing was minted). Absent entirely when no capture would open.

    - `collection: string`

      `single_shot`: the whole prompt sequence goes at once. `progressive` is reserved.

    - `expects: string`

      The reply shape we will match: `choice`, `multi_choice`, `text`, `phone`, `email`, or `date_text` (a date question at the text floor records the customer's words verbatim; `date_choice` is reserved for a future variant that offers concrete slots).

    - `page_id: string`

      Your form page's own id: the key the eventual `reply.values` uses.

    - `reasks: number`

      How many re-asks a mismatched reply earns before the collection ends `abandoned`. Fixed at 1.

    - `timeout_s: number`

      How long the collection stays open, in seconds. Fixed at 1800.

    - `group_id: optional string`

      ABSENT on the preview (nothing was minted); on the send `200`, the fan's correlation id: the primary message's own id, the value `form.response` reports back.

    - `options: optional array of object { index, item_id, label }`

      Present for the choice shapes only: what the customer can answer with, by number or label.

      - `index: number`

      - `item_id: string`

      - `label: string`

  - `fallbacks: optional array of Fallback`

    The capability rewrites `degrade` would apply: the same array the send's `200` would carry. Present only when something would be rewritten.

    - `token: string`

      The missing capability token that forced the rewrite.

    - `from: string`

      Canonical part type in.

    - `rule: string`

      The fallback-tree rule applied (e.g. `guide-17.5-select-le5`).

    - `to: string`

      Canonical part type out. For a rewritten form this is `text_sequence`: the whole degraded fan is one rewrite outcome, stable across how many messages it produced; which types those messages carry is on the response's own `messages[]`.

    - `options: optional array of FallbackOption`

      Present only when the rewrite flattened a menu.

      - `index: number`

        1-based, matching the numbering the degraded copy emits.

      - `item_id: string`

        The original item id, so a customer's "2" maps back to your routing.

      - `label: string`

### Example

```http
curl https://messages.api.linqapp.com/v1/chats/$CHAT/messages/preview \
    -H 'Content-Type: application/json' \
    -H "Authorization: Bearer $LINQ_AMB_API_KEY" \
    -d '{
          "parts": [
            {
              "items": [
                {
                  "id": "curb",
                  "label": "Curbside"
                },
                {
                  "id": "garage",
                  "label": "Garage level 2"
                }
              ],
              "summary_text": "Pickup options",
              "type": "choices"
            }
          ]
        }'
```

#### Response

```json
{
  "chat_id": "chat_4f81b2",
  "wire_fidelity": "representative",
  "messages": [
    {
      "type": "choices",
      "parts": [
        {
          "type": "choices",
          "summary_text": "Pickup options",
          "items": [
            {
              "id": "curb",
              "label": "Curbside"
            },
            {
              "id": "garage",
              "label": "Garage level 2"
            }
          ]
        }
      ]
    }
  ]
}
```

## Domain Types

### App Extension Part

- `AppExtensionPart object { app, received_message, reply_message, 4 more }`

  A third-party App Extension bubble. We fill in the app identity (bid, appId, appName, appIcon) from your brand's registered apps, so those are rejected as request fields. We never read url; it is handed to the extension exactly as you sent it.

  - `app: string`

    Which of the brand's registered apps to send under.

  - `received_message: object { title, subtitle }`

    Shown on a device without the extension installed.

    - `title: string`

    - `subtitle: optional string`

  - `reply_message: object { title, subtitle }`

    Shown on a device without the extension installed.

    - `title: string`

    - `subtitle: optional string`

  - `type: "app_extension"`

    - `"app_extension"`

  - `url: string`

    The payload the Messages app hands to the extension. Opaque: never parsed or rewritten.

  - `session_id: optional string`

    Reuse to address a card already sent; omit and Apple generates one.

  - `use_live_layout: optional boolean`

    Defaults to true.

### Apple Pay Part

- `ApplePayPart object { payment_request, received_message, type }`

  AMB Apple Pay payment request. We fill in the merchant identity, gateway URL and merchant session from your brand's configuration, so merchant_identifier, endpoints and merchant_session are rejected as unknown fields. Amounts are decimal strings.

  - `payment_request: object { country_code, currency_code, line_items, 7 more }`

    - `country_code: string`

    - `currency_code: string`

    - `line_items: array of object { amount, label, type }`

      - `amount: string`

      - `label: string`

      - `type: optional "final" or "pending" or ""`

        Empty means unset.

        - `"final"`

        - `"pending"`

        - `""`

    - `merchant_capabilities: array of "supports3DS" or "supportsCredit" or "supportsDebit" or "supportsEMV"`

      - `"supports3DS"`

      - `"supportsCredit"`

      - `"supportsDebit"`

      - `"supportsEMV"`

    - `supported_networks: array of "amex" or "discover" or "jcb" or 3 more`

      - `"amex"`

      - `"discover"`

      - `"jcb"`

      - `"masterCard"`

      - `"privateLabel"`

      - `"visa"`

    - `total: object { amount, label, type }`

      The grand total.

      - `amount: string`

      - `label: string`

      - `type: optional "final" or "pending" or ""`

        Empty means unset.

        - `"final"`

        - `"pending"`

        - `""`

    - `required_billing_contact_fields: optional array of "email" or "name" or "phone" or 2 more`

      - `"email"`

      - `"name"`

      - `"phone"`

      - `"phoneticName"`

      - `"post"`

    - `required_shipping_contact_fields: optional array of "email" or "name" or "phone" or 2 more`

      - `"email"`

      - `"name"`

      - `"phone"`

      - `"phoneticName"`

      - `"post"`

    - `shipping_methods: optional array of object { amount, detail, identifier, label }`

      - `amount: string`

      - `detail: string`

      - `identifier: string`

      - `label: string`

    - `supported_countries: optional array of string`

  - `received_message: object { title, image_att_id, subtitle }`

    The bubble the customer sees before authorizing.

    - `title: string`

    - `image_att_id: optional string`

    - `subtitle: optional string`

  - `type: "apple_pay"`

    - `"apple_pay"`

### Attachment Part

- `AttachmentPart object { att_id, type }`

  An attachment by reference: att_id names a file the platform holds for your brand, such as an inbound attachment we retained (the id on the message.received event). To send a file you host, use this part's source_url form instead; never inline base64.

  - `att_id: string`

  - `type: "attachment"`

    - `"attachment"`

### Authenticate Part

- `AuthenticatePart object { received_title, reply_title, scope, 3 more }`

  Opens Apple's sign-in sheet (New Authentication Message). You supply the OAuth scope and the two bubble titles. We fill in state and redirect_uri when the customer accepts.

  - `received_title: string`

    The bubble the customer sees.

  - `reply_title: string`

    The bubble after completion.

  - `scope: array of string`

  - `type: "authenticate"`

    - `"authenticate"`

  - `redirect_uri: optional string`

  - `state: optional string`

### Choices Part

- `ChoicesPart object { items, summary_text, type }`

  AMB Quick Reply: single-select items. summary_text is the transcript label, not the prompt; send the prompt as a preceding text message.

  - `items: array of object { id, label }`

    - `id: string`

    - `label: string`

  - `summary_text: string`

  - `type: "choices"`

    - `"choices"`

### Fallback

- `Fallback object { token, from, rule, 2 more }`

  One capability rewrite performed under `degrade: "auto"`, or
  `"acknowledged"`, which rewrites on exactly the same rules and records the
  swap the same way.

  - `token: string`

    The missing capability token that forced the rewrite.

  - `from: string`

    Canonical part type in.

  - `rule: string`

    The fallback-tree rule applied (e.g. `guide-17.5-select-le5`).

  - `to: string`

    Canonical part type out. For a rewritten form this is `text_sequence`: the whole degraded fan is one rewrite outcome, stable across how many messages it produced; which types those messages carry is on the response's own `messages[]`.

  - `options: optional array of FallbackOption`

    Present only when the rewrite flattened a menu.

    - `index: number`

      1-based, matching the numbering the degraded copy emits.

    - `item_id: string`

      The original item id, so a customer's "2" maps back to your routing.

    - `label: string`

### Fallback Option

- `FallbackOption object { index, item_id, label }`

  One entry of a menu a degrade rewrite flattened into words.

  - `index: number`

    1-based, matching the numbering the degraded copy emits.

  - `item_id: string`

    The original item id, so a customer's "2" maps back to your routing.

  - `label: string`

### Form Part

- `FormPart object { header, pages, type, 7 more }`

  AMB Message Form (dynamic v1.2). Page kinds: select, picker, datePicker, input. The last page is the submit page. Omitting show_summary means true.

  - `header: string`

  - `pages: array of object { id, kind, subtitle, 15 more }`

    - `id: string`

    - `kind: "select" or "picker" or "datePicker" or "input"`

      - `"select"`

      - `"picker"`

      - `"datePicker"`

      - `"input"`

    - `subtitle: string`

      The question shown to the user.

    - `date_format: optional string`

      datePicker only. ICU pattern, default MM/dd/yyyy. It governs how the device reads the three date strings.

    - `hint_text: optional string`

      datePicker only.

    - `items: optional array of object { id, label, image_id }`

      select and picker only.

      - `id: string`

      - `label: string`

      - `image_id: optional string`

        select items only.

    - `keyboard_type: optional "default" or "asciiCapable" or "numbersAndPunctuation" or 7 more`

      input only.

      - `"default"`

      - `"asciiCapable"`

      - `"numbersAndPunctuation"`

      - `"URL"`

      - `"numberPad"`

      - `"phonePad"`

      - `"namePhonePad"`

      - `"emailAddress"`

      - `"decimalPad"`

      - `"webSearch"`

    - `label_text: optional string`

      datePicker only; defaults to "Date".

    - `max_chars: optional number`

      input only.

    - `maximum_date: optional string`

      datePicker only, in date_format.

    - `minimum_date: optional string`

      datePicker only, in date_format.

    - `multiple: optional boolean`

      select only.

    - `next_page_id: optional string`

    - `picker_title: optional string`

      picker only; empty centers the field.

    - `required: optional boolean`

      input only.

    - `selected_item_index: optional number`

      picker only.

    - `start_date: optional string`

      datePicker only, in date_format.

    - `title: optional string`

  - `type: "form"`

    - `"form"`

  - `bubble_image_id: optional string`

    Icon on the received/reply message bubble.

  - `bubble_style: optional "icon" or "small" or "large"`

    Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble.

    - `"icon"`

    - `"small"`

    - `"large"`

  - `images: optional array of object { id, att_id, description }`

    - `id: string`

    - `att_id: string`

      A stored attachment's att_id; never inline base64.

    - `description: optional string`

      Read aloud by VoiceOver.

  - `show_summary: optional boolean`

  - `splash_button_title: optional string`

    Defaults to "Start". The splash view shows when any splash field is set.

  - `splash_image_id: optional string`

  - `splash_text: optional string`

### List Picker Part

- `ListPickerPart object { sections, summary_text, type, 4 more }`

  AMB List Picker: sections of items, per-section multi-select. Item image_id and bubble_image_id reference the shared images pool by id.

  - `sections: array of object { items, title, multi_select }`

    - `items: array of object { id, label, detail, image_id }`

      - `id: string`

      - `label: string`

      - `detail: optional string`

      - `image_id: optional string`

    - `title: string`

    - `multi_select: optional boolean`

  - `summary_text: string`

  - `type: "list_picker"`

    - `"list_picker"`

  - `bubble_image_id: optional string`

  - `bubble_style: optional "icon" or "small" or "large"`

    Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble.

    - `"icon"`

    - `"small"`

    - `"large"`

  - `images: optional array of object { id, att_id, description }`

    - `id: string`

    - `att_id: string`

      A stored attachment's att_id; never inline base64.

    - `description: optional string`

      Read aloud by VoiceOver.

  - `subtitle: optional string`

### Part

- `Part = TextPart or ChoicesPart or ListPickerPart or 7 more`

  The sealed part union: one variant per member of the canonical content
  model (10 types). Discriminated on the wire
  by each part's `type` const.

  - `TextPart object { body, type, subject }`

    A plain text message. subject renders bold on AMB.

    - `body: string`

    - `type: "text"`

      - `"text"`

    - `subject: optional string`

  - `ChoicesPart object { items, summary_text, type }`

    AMB Quick Reply: single-select items. summary_text is the transcript label, not the prompt; send the prompt as a preceding text message.

    - `items: array of object { id, label }`

      - `id: string`

      - `label: string`

    - `summary_text: string`

    - `type: "choices"`

      - `"choices"`

  - `ListPickerPart object { sections, summary_text, type, 4 more }`

    AMB List Picker: sections of items, per-section multi-select. Item image_id and bubble_image_id reference the shared images pool by id.

    - `sections: array of object { items, title, multi_select }`

      - `items: array of object { id, label, detail, image_id }`

        - `id: string`

        - `label: string`

        - `detail: optional string`

        - `image_id: optional string`

      - `title: string`

      - `multi_select: optional boolean`

    - `summary_text: string`

    - `type: "list_picker"`

      - `"list_picker"`

    - `bubble_image_id: optional string`

    - `bubble_style: optional "icon" or "small" or "large"`

      Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble.

      - `"icon"`

      - `"small"`

      - `"large"`

    - `images: optional array of object { id, att_id, description }`

      - `id: string`

      - `att_id: string`

        A stored attachment's att_id; never inline base64.

      - `description: optional string`

        Read aloud by VoiceOver.

    - `subtitle: optional string`

  - `TimeSlotsPart object { event, slots, type, 5 more }`

    AMB Time Picker: whole-minute, future-only slot starts; duration in seconds; timezone offset in minutes.

    - `event: object { title, image_id, location }`

      - `title: string`

      - `image_id: optional string`

      - `location: optional object { latitude, longitude, radius_m, title }`

        - `latitude: optional number`

        - `longitude: optional number`

        - `radius_m: optional number`

        - `title: optional string`

    - `slots: array of object { id, duration_s, start }`

      - `id: string`

      - `duration_s: number`

      - `start: string`

    - `type: "time_slots"`

      - `"time_slots"`

    - `bubble_image_id: optional string`

    - `bubble_style: optional "icon" or "small" or "large"`

      Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble.

      - `"icon"`

      - `"small"`

      - `"large"`

    - `images: optional array of object { id, att_id, description }`

      - `id: string`

      - `att_id: string`

        A stored attachment's att_id; never inline base64.

      - `description: optional string`

        Read aloud by VoiceOver.

    - `subtitle: optional string`

    - `timezone_offset_min: optional number`

  - `FormPart object { header, pages, type, 7 more }`

    AMB Message Form (dynamic v1.2). Page kinds: select, picker, datePicker, input. The last page is the submit page. Omitting show_summary means true.

    - `header: string`

    - `pages: array of object { id, kind, subtitle, 15 more }`

      - `id: string`

      - `kind: "select" or "picker" or "datePicker" or "input"`

        - `"select"`

        - `"picker"`

        - `"datePicker"`

        - `"input"`

      - `subtitle: string`

        The question shown to the user.

      - `date_format: optional string`

        datePicker only. ICU pattern, default MM/dd/yyyy. It governs how the device reads the three date strings.

      - `hint_text: optional string`

        datePicker only.

      - `items: optional array of object { id, label, image_id }`

        select and picker only.

        - `id: string`

        - `label: string`

        - `image_id: optional string`

          select items only.

      - `keyboard_type: optional "default" or "asciiCapable" or "numbersAndPunctuation" or 7 more`

        input only.

        - `"default"`

        - `"asciiCapable"`

        - `"numbersAndPunctuation"`

        - `"URL"`

        - `"numberPad"`

        - `"phonePad"`

        - `"namePhonePad"`

        - `"emailAddress"`

        - `"decimalPad"`

        - `"webSearch"`

      - `label_text: optional string`

        datePicker only; defaults to "Date".

      - `max_chars: optional number`

        input only.

      - `maximum_date: optional string`

        datePicker only, in date_format.

      - `minimum_date: optional string`

        datePicker only, in date_format.

      - `multiple: optional boolean`

        select only.

      - `next_page_id: optional string`

      - `picker_title: optional string`

        picker only; empty centers the field.

      - `required: optional boolean`

        input only.

      - `selected_item_index: optional number`

        picker only.

      - `start_date: optional string`

        datePicker only, in date_format.

      - `title: optional string`

    - `type: "form"`

      - `"form"`

    - `bubble_image_id: optional string`

      Icon on the received/reply message bubble.

    - `bubble_style: optional "icon" or "small" or "large"`

      Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble.

      - `"icon"`

      - `"small"`

      - `"large"`

    - `images: optional array of object { id, att_id, description }`

      - `id: string`

      - `att_id: string`

        A stored attachment's att_id; never inline base64.

      - `description: optional string`

        Read aloud by VoiceOver.

    - `show_summary: optional boolean`

    - `splash_button_title: optional string`

      Defaults to "Start". The splash view shows when any splash field is set.

    - `splash_image_id: optional string`

    - `splash_text: optional string`

  - `RichLinkPart object { title, type, url, 5 more }`

    A rich link card. Omit image_att_id to get the default card. The image must be a real PNG.

    - `title: string`

    - `type: "rich_link"`

      - `"rich_link"`

    - `url: string`

    - `app_clip: optional boolean`

      Set true when url is an App Clip page, so the App Clip payload is constructed server side (POST /v1/constructPayload). Apple Music and Apple Maps URLs need no flag; they are detected by host. The page must carry App Clip elements and its image must be under 4 MB; if construction fails, the send fails rather than delivering a plain card.

    - `image_att_id: optional string`

    - `store_region: optional string`

      The App Store region Apple resolves the App Clip in, as a 2-letter uppercase ISO-3166 code. Omit for the US, which is Apple's default. Requires app_clip: true; Apple scopes this to App Clips, so setting it on any other rich link is rejected rather than ignored.

    - `video_mime_type: optional string`

      MIME type of the video at video_url. video/mp4 is the recommended container.

    - `video_url: optional string`

      Public https URL of a video asset to play inline in the card. Apple's device fetches this URL client-side when the customer taps play; it must serve the video bytes from the open internet. We never fetch, validate, or re-host them. Requires video_mime_type and image_att_id.

  - `AttachmentPart object { att_id, type }`

    An attachment by reference: att_id names a file the platform holds for your brand, such as an inbound attachment we retained (the id on the message.received event). To send a file you host, use this part's source_url form instead; never inline base64.

    - `att_id: string`

    - `type: "attachment"`

      - `"attachment"`

  - `ApplePayPart object { payment_request, received_message, type }`

    AMB Apple Pay payment request. We fill in the merchant identity, gateway URL and merchant session from your brand's configuration, so merchant_identifier, endpoints and merchant_session are rejected as unknown fields. Amounts are decimal strings.

    - `payment_request: object { country_code, currency_code, line_items, 7 more }`

      - `country_code: string`

      - `currency_code: string`

      - `line_items: array of object { amount, label, type }`

        - `amount: string`

        - `label: string`

        - `type: optional "final" or "pending" or ""`

          Empty means unset.

          - `"final"`

          - `"pending"`

          - `""`

      - `merchant_capabilities: array of "supports3DS" or "supportsCredit" or "supportsDebit" or "supportsEMV"`

        - `"supports3DS"`

        - `"supportsCredit"`

        - `"supportsDebit"`

        - `"supportsEMV"`

      - `supported_networks: array of "amex" or "discover" or "jcb" or 3 more`

        - `"amex"`

        - `"discover"`

        - `"jcb"`

        - `"masterCard"`

        - `"privateLabel"`

        - `"visa"`

      - `total: object { amount, label, type }`

        The grand total.

        - `amount: string`

        - `label: string`

        - `type: optional "final" or "pending" or ""`

          Empty means unset.

          - `"final"`

          - `"pending"`

          - `""`

      - `required_billing_contact_fields: optional array of "email" or "name" or "phone" or 2 more`

        - `"email"`

        - `"name"`

        - `"phone"`

        - `"phoneticName"`

        - `"post"`

      - `required_shipping_contact_fields: optional array of "email" or "name" or "phone" or 2 more`

        - `"email"`

        - `"name"`

        - `"phone"`

        - `"phoneticName"`

        - `"post"`

      - `shipping_methods: optional array of object { amount, detail, identifier, label }`

        - `amount: string`

        - `detail: string`

        - `identifier: string`

        - `label: string`

      - `supported_countries: optional array of string`

    - `received_message: object { title, image_att_id, subtitle }`

      The bubble the customer sees before authorizing.

      - `title: string`

      - `image_att_id: optional string`

      - `subtitle: optional string`

    - `type: "apple_pay"`

      - `"apple_pay"`

  - `AuthenticatePart object { received_title, reply_title, scope, 3 more }`

    Opens Apple's sign-in sheet (New Authentication Message). You supply the OAuth scope and the two bubble titles. We fill in state and redirect_uri when the customer accepts.

    - `received_title: string`

      The bubble the customer sees.

    - `reply_title: string`

      The bubble after completion.

    - `scope: array of string`

    - `type: "authenticate"`

      - `"authenticate"`

    - `redirect_uri: optional string`

    - `state: optional string`

  - `AppExtensionPart object { app, received_message, reply_message, 4 more }`

    A third-party App Extension bubble. We fill in the app identity (bid, appId, appName, appIcon) from your brand's registered apps, so those are rejected as request fields. We never read url; it is handed to the extension exactly as you sent it.

    - `app: string`

      Which of the brand's registered apps to send under.

    - `received_message: object { title, subtitle }`

      Shown on a device without the extension installed.

      - `title: string`

      - `subtitle: optional string`

    - `reply_message: object { title, subtitle }`

      Shown on a device without the extension installed.

      - `title: string`

      - `subtitle: optional string`

    - `type: "app_extension"`

      - `"app_extension"`

    - `url: string`

      The payload the Messages app hands to the extension. Opaque: never parsed or rewritten.

    - `session_id: optional string`

      Reuse to address a card already sent; omit and Apple generates one.

    - `use_live_layout: optional boolean`

      Defaults to true.

### Preview Message Response

- `PreviewMessageResponse object { chat_id, messages, wire_fidelity, 2 more }`

  The previewed send. Nothing was written; nothing will be sent.

  - `chat_id: string`

  - `messages: array of PreviewedMessage`

    What the ONE request would become, message by message (URL promotion can make several).

    - `parts: array of unknown`

      The message's post-degrade, post-promotion parts. A URL attachment remains
      in its source form because preview does not fetch it or mint the real
      `att_id` that an accepted send returns. Typed as an opaque array rather
      than `Part[]` for the same one-decode-door reason `TranscriptRow.parts` is.

    - `type: string`

      The part type of this message's FIRST part: the same value the send's `200` reports per message.

    - `wire: optional unknown`

      The representative Apple wire fields for this message; see the operation description for exactly what is substituted. Absent when `wire_unavailable` says why.

    - `wire_unavailable: optional string`

      Present only when this message has no wire preview (a plain attachment message), and why.

  - `wire_fidelity: "representative"`

    Always `representative`: the wire bytes are an approximation of what delivery sends, never the exact payload.

    - `"representative"`

  - `capture: optional object { collection, expects, page_id, 4 more }`

    The reply capture this send WOULD open, identical to the send `200`'s object except `group_id`, which is ABSENT here (nothing was minted). Absent entirely when no capture would open.

    - `collection: string`

      `single_shot`: the whole prompt sequence goes at once. `progressive` is reserved.

    - `expects: string`

      The reply shape we will match: `choice`, `multi_choice`, `text`, `phone`, `email`, or `date_text` (a date question at the text floor records the customer's words verbatim; `date_choice` is reserved for a future variant that offers concrete slots).

    - `page_id: string`

      Your form page's own id: the key the eventual `reply.values` uses.

    - `reasks: number`

      How many re-asks a mismatched reply earns before the collection ends `abandoned`. Fixed at 1.

    - `timeout_s: number`

      How long the collection stays open, in seconds. Fixed at 1800.

    - `group_id: optional string`

      ABSENT on the preview (nothing was minted); on the send `200`, the fan's correlation id: the primary message's own id, the value `form.response` reports back.

    - `options: optional array of object { index, item_id, label }`

      Present for the choice shapes only: what the customer can answer with, by number or label.

      - `index: number`

      - `item_id: string`

      - `label: string`

  - `fallbacks: optional array of Fallback`

    The capability rewrites `degrade` would apply: the same array the send's `200` would carry. Present only when something would be rewritten.

    - `token: string`

      The missing capability token that forced the rewrite.

    - `from: string`

      Canonical part type in.

    - `rule: string`

      The fallback-tree rule applied (e.g. `guide-17.5-select-le5`).

    - `to: string`

      Canonical part type out. For a rewritten form this is `text_sequence`: the whole degraded fan is one rewrite outcome, stable across how many messages it produced; which types those messages carry is on the response's own `messages[]`.

    - `options: optional array of FallbackOption`

      Present only when the rewrite flattened a menu.

      - `index: number`

        1-based, matching the numbering the degraded copy emits.

      - `item_id: string`

        The original item id, so a customer's "2" maps back to your routing.

      - `label: string`

### Previewed Message

- `PreviewedMessage object { parts, type, wire, wire_unavailable }`

  One message of a previewed send, in send order (URL promotion can make several).

  - `parts: array of unknown`

    The message's post-degrade, post-promotion parts. A URL attachment remains
    in its source form because preview does not fetch it or mint the real
    `att_id` that an accepted send returns. Typed as an opaque array rather
    than `Part[]` for the same one-decode-door reason `TranscriptRow.parts` is.

  - `type: string`

    The part type of this message's FIRST part: the same value the send's `200` reports per message.

  - `wire: optional unknown`

    The representative Apple wire fields for this message; see the operation description for exactly what is substituted. Absent when `wire_unavailable` says why.

  - `wire_unavailable: optional string`

    Present only when this message has no wire preview (a plain attachment message), and why.

### Rich Link Part

- `RichLinkPart object { title, type, url, 5 more }`

  A rich link card. Omit image_att_id to get the default card. The image must be a real PNG.

  - `title: string`

  - `type: "rich_link"`

    - `"rich_link"`

  - `url: string`

  - `app_clip: optional boolean`

    Set true when url is an App Clip page, so the App Clip payload is constructed server side (POST /v1/constructPayload). Apple Music and Apple Maps URLs need no flag; they are detected by host. The page must carry App Clip elements and its image must be under 4 MB; if construction fails, the send fails rather than delivering a plain card.

  - `image_att_id: optional string`

  - `store_region: optional string`

    The App Store region Apple resolves the App Clip in, as a 2-letter uppercase ISO-3166 code. Omit for the US, which is Apple's default. Requires app_clip: true; Apple scopes this to App Clips, so setting it on any other rich link is rejected rather than ignored.

  - `video_mime_type: optional string`

    MIME type of the video at video_url. video/mp4 is the recommended container.

  - `video_url: optional string`

    Public https URL of a video asset to play inline in the card. Apple's device fetches this URL client-side when the customer taps play; it must serve the video bytes from the open internet. We never fetch, validate, or re-host them. Requires video_mime_type and image_att_id.

### Sent Message

- `SentMessage object { id, type, parts }`

  One accepted message of a send (URL promotion can make several).

  - `id: string`

    Message id (`msg_…`).

  - `type: string`

    The part type of that message's FIRST part.

  - `parts: optional array of unknown`

    The message's canonical parts AS STORED at accept: post-degrade, post-promotion, so you see what the customer will actually receive without a second read. Typed opaque for the same one-decode-door reason `TranscriptRow.parts` is.

### Text Part

- `TextPart object { body, type, subject }`

  A plain text message. subject renders bold on AMB.

  - `body: string`

  - `type: "text"`

    - `"text"`

  - `subject: optional string`

### Time Slots Part

- `TimeSlotsPart object { event, slots, type, 5 more }`

  AMB Time Picker: whole-minute, future-only slot starts; duration in seconds; timezone offset in minutes.

  - `event: object { title, image_id, location }`

    - `title: string`

    - `image_id: optional string`

    - `location: optional object { latitude, longitude, radius_m, title }`

      - `latitude: optional number`

      - `longitude: optional number`

      - `radius_m: optional number`

      - `title: optional string`

  - `slots: array of object { id, duration_s, start }`

    - `id: string`

    - `duration_s: number`

    - `start: string`

  - `type: "time_slots"`

    - `"time_slots"`

  - `bubble_image_id: optional string`

  - `bubble_style: optional "icon" or "small" or "large"`

    Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble.

    - `"icon"`

    - `"small"`

    - `"large"`

  - `images: optional array of object { id, att_id, description }`

    - `id: string`

    - `att_id: string`

      A stored attachment's att_id; never inline base64.

    - `description: optional string`

      Read aloud by VoiceOver.

  - `subtitle: optional string`

  - `timezone_offset_min: optional number`

### Message Send Response

- `MessageSendResponse object { id, chat_id, delivery, 3 more }`

  The accepted send. A replay of the same Idempotency-Key within 24h returns these exact bytes; past that window the key is forgotten and the request executes again.

  - `id: string`

    The FIRST message's id: the send's primary id.

  - `chat_id: string`

  - `delivery: "queued"`

    - `"queued"`

  - `messages: array of SentMessage`

    Every message this request was accepted as, in delivery order; ALWAYS
    present, one element for an ordinary send. Longer when an `https` URL in
    your text was promoted to its own rich-link message:
    each element then has its own `message.sent`/`message.failed` lifecycle,
    correlated by `group_id`. More than 10 messages returns HTTP 422 `code` 1075.

    - `id: string`

      Message id (`msg_…`).

    - `type: string`

      The part type of that message's FIRST part.

    - `parts: optional array of unknown`

      The message's canonical parts AS STORED at accept: post-degrade, post-promotion, so you see what the customer will actually receive without a second read. Typed opaque for the same one-decode-door reason `TranscriptRow.parts` is.

  - `capture: optional object { collection, expects, page_id, 4 more }`

    The reply capture this send opened; see `CaptureObject`. Absent when no capture opened.

    - `collection: string`

      `single_shot`: the whole prompt sequence goes at once. `progressive` is reserved.

    - `expects: string`

      The reply shape we will match: `choice`, `multi_choice`, `text`, `phone`, `email`, or `date_text` (a date question at the text floor records the customer's words verbatim; `date_choice` is reserved for a future variant that offers concrete slots).

    - `page_id: string`

      Your form page's own id: the key the eventual `reply.values` uses.

    - `reasks: number`

      How many re-asks a mismatched reply earns before the collection ends `abandoned`. Fixed at 1.

    - `timeout_s: number`

      How long the collection stays open, in seconds. Fixed at 1800.

    - `group_id: optional string`

      ABSENT on the preview (nothing was minted); on the send `200`, the fan's correlation id: the primary message's own id, the value `form.response` reports back.

    - `options: optional array of object { index, item_id, label }`

      Present for the choice shapes only: what the customer can answer with, by number or label.

      - `index: number`

      - `item_id: string`

      - `label: string`

  - `fallbacks: optional array of Fallback`

    Present ONLY when `degrade` (`"auto"` or `"acknowledged"`) actually
    rewrote a part: its presence is the signal that the customer saw
    something other than what you composed, and an acknowledgement does not
    suppress it. Absent otherwise (never an empty array).

    - `token: string`

      The missing capability token that forced the rewrite.

    - `from: string`

      Canonical part type in.

    - `rule: string`

      The fallback-tree rule applied (e.g. `guide-17.5-select-le5`).

    - `to: string`

      Canonical part type out. For a rewritten form this is `text_sequence`: the whole degraded fan is one rewrite outcome, stable across how many messages it produced; which types those messages carry is on the response's own `messages[]`.

    - `options: optional array of FallbackOption`

      Present only when the rewrite flattened a menu.

      - `index: number`

        1-based, matching the numbering the degraded copy emits.

      - `item_id: string`

        The original item id, so a customer's "2" maps back to your routing.

      - `label: string`

# Typing

## Set the typing indicator

**post** `/v1/chats/{chat}/typing`

Sets or clears the chat's typing indicator.

**Behavior**

- Live only: the customer sees it as it happens; nothing is recorded.
- Not needed around your own sends: put `typing_lead_ms` on the send
  instead, which shows the indicator for exactly as long as you ask. This
  endpoint is for showing activity while you are still working on a reply.
- No `Idempotency-Key`: the server neither requires nor consults one
  here.

### Path Parameters

- `chat: string`

### Body Parameters

- `state: "start" or "end"`

  `start` shows the customer the typing indicator; `end` clears it. Any other value is a 422 `code` 1066.

  - `"start"`

  - `"end"`

### Example

```http
curl https://messages.api.linqapp.com/v1/chats/$CHAT/typing \
    -H 'Content-Type: application/json' \
    -H "Authorization: Bearer $LINQ_AMB_API_KEY" \
    -d '{
          "state": "start"
        }'
```

#### Response

```json
{}
```

## Domain Types

### Typing Set Response

- `TypingSetResponse = unknown`

  Typing succeeds with an empty JSON object; we do not wait for Apple to confirm it.

# Events

## List chat events

**get** `/v1/chats/{chat}/events`

Lists the chat's event log: the ordered record you rebuild your own
state from. Each entry carries a `seq` that only ever counts up, with no
gaps; page with `after_seq`.

### Path Parameters

- `chat: string`

### Query Parameters

- `after_seq: optional number`

  Return entries with `seq` greater than this; omitted (or non-numeric) reads from the start.

- `limit: optional number`

  Page size; absent or outside 1–100 reads as 100.

### Returns

- `data: array of JournalEntry`

  - `actor: string`

    Who wrote it: `customer`, `partner`, `human`, `system` or `brand`.

  - `created_at: string`

  - `payload: unknown`

    The event's own JSON payload; shape depends on `type`.

  - `seq: number`

    Gapless per-chat sequence number: the events cursor.

  - `type: string`

    Event type (`message_received`, `message_sent`, `owner_changed`, …).

  - `operator: optional string`

    WHICH human, for the events that have one: the operator name the acting
    request carried (attributed through our agent console). Present ONLY beside `actor: "human"`, and only
    when the caller named an operator; absent everywhere else, including on
    every event recorded before an operator id was ever sent. So its presence
    is the signal that this row can be rendered as "Dana accepted this" rather
    than "someone accepted this"; its absence is never "no human", only
    "unattributed".

### Example

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

#### Response

```json
{
  "data": [
    {
      "seq": 13,
      "type": "message_received",
      "actor": "customer",
      "payload": {
        "body": "where is my driver?"
      },
      "created_at": "2026-08-06T14:01:58Z"
    }
  ]
}
```

## Domain Types

### Journal Entry

- `JournalEntry object { actor, created_at, payload, 3 more }`

  One entry in the chat's event log.

  - `actor: string`

    Who wrote it: `customer`, `partner`, `human`, `system` or `brand`.

  - `created_at: string`

  - `payload: unknown`

    The event's own JSON payload; shape depends on `type`.

  - `seq: number`

    Gapless per-chat sequence number: the events cursor.

  - `type: string`

    Event type (`message_received`, `message_sent`, `owner_changed`, …).

  - `operator: optional string`

    WHICH human, for the events that have one: the operator name the acting
    request carried (attributed through our agent console). Present ONLY beside `actor: "human"`, and only
    when the caller named an operator; absent everywhere else, including on
    every event recorded before an operator id was ever sent. So its presence
    is the signal that this row can be rendered as "Dana accepted this" rather
    than "someone accepted this"; its absence is never "no human", only
    "unattributed".

# Transcript

## List chat transcript

**get** `/v1/chats/{chat}/transcript`

Lists the chat as a conversation to display: what the customer saw, in
order.

**Behavior**

- The same rows `/events` pages, rebuilt for rendering: each carries a
  `kind` to switch on, the message's canonical `parts`, and the delivery
  state of each outbound message.
- Same paging (`after_seq`, `limit`) and same bounds as `/events`. Use
  this to draw a chat; use `/events` to rebuild state.

### Path Parameters

- `chat: string`

### Query Parameters

- `after_seq: optional number`

  Return rows with `seq` greater than this; omitted (or non-numeric) reads from the start.

- `limit: optional number`

  Page size; absent or outside 1–100 reads as 100.

### Returns

- `data: array of TranscriptRow`

  - `actor: string`

    Who wrote it: `customer`, `partner`, `human`, `flow`, `system` or `brand`.
    On a row carrying a `message_id` this is who COMPOSED that message: a
    human agent replying from a console reads `human`, a deterministic flow
    reads `flow`, and NOT the actor of the underlying event, which is always
    `partner` on a send because that event means we delivered it. Everywhere
    else it is the underlying event's own actor.

  - `created_at: string`

  - `event_type: string`

    The underlying event type (`message_received`, `owner_changed`, `consent_send_refused`, …); render detail from it, but branch on `kind`.

  - `kind: "customer_message" or "business_message" or "system"`

    What KIND of thing happened; switch on this. The set is CLOSED: an event type added later renders as `system`, never as a fourth value.

    - `"customer_message"`

    - `"business_message"`

    - `"system"`

  - `seq: number`

    The `seq` this row is built from; same numbering as the events endpoint, and the transcript's own cursor.

  - `attachments: optional array of object { att_id, mime_type, name, 2 more }`

    Files the CUSTOMER sent with this message; absent when they sent none.

    - `att_id: optional string`

      The retained plaintext's id (`att_…`); absent when nothing was retained.

    - `mime_type: optional string`

    - `name: optional string`

      The file's name as the customer's device sent it.

    - `reason: optional string`

      Present exactly when `att_id` is absent, and a CLOSED set: `too_large` | `brand_unresolved` | `store_unavailable` | `disabled` | `unavailable` (any failure earlier than the retention decision). Never the underlying error text, that is free-form and can carry the channel's own temporary download URL, so it stays on `/events`.

    - `size: optional number`

      Size in bytes.

  - `delivery: optional string`

    That message's delivery state, read from the message row: `queued` | `sending` | `sent` | `retry` | `failed` | `undelivered` | `suppressed`. Two of those are terminal failures, not one: `undelivered` is the "resubmitting later may land" outcome and `failed` the one that will not. There is NO delivered or read receipt here or anywhere on this API; AMB produces neither, and `sent` means only that Apple accepted the send.

  - `form_response: optional unknown`

    A `form_response` row's FULL record, first-class: `values` keyed by your original page ids, `item_ids`, `status`, `sources`, `opened_at`, `closed_at`. Present only on `event_type: "form_response"` rows.

  - `group_id: optional string`

    Ties a fan-out member to its group, and a `form_response` row to the degraded prompt messages that asked, so grouping needs no payload spelunking. Present on rows that belong to a group.

  - `message_id: optional string`

    The outbound message this row is about; pass it to `GET /v1/messages/{msg}`.

  - `operator: optional string`

    WHICH human. On a row carrying a `message_id` this is the operator who
    composed that send, where one did and where the send named them. On every
    other row it is the events endpoint's `operator`: the human we recorded,
    with the same rules.

    **On message rows this deliberately does NOT match `/events`**, which
    shows no `operator` for the same `seq`: a send is recorded by the worker
    that delivered it, with actor `partner` and no operator, so the composer
    is only knowable from the message row. Absence never means "no human";
    it means this row cannot say which.

  - `parts: optional array of unknown`

    The message's canonical parts (the same array a send takes), absent on
    system rows and on a message that carried none: an invitation delivery
    records its invitation identity rather than parts. Typed as an opaque
    array rather than `Part[]` on purpose: the generated Go transport must
    never contain a second decoder for the part union (`content.ParseParts`
    is the one decode door), and a response `$ref` to `Part` would put one
    there.

  - `reaction: optional object { action, correlation, kind, 7 more }`

    The reaction this customer text row IS, when the text is a tapback. The row keeps its text verbatim and `kind` stays `customer_message`, so a client ignoring this key sees exactly the transcript it saw before.

    - `action: "added" or "removed"`

      Whether the customer APPLIED this reaction or TOOK IT AWAY. CLOSED: `added` or `removed`, and always present. Apple sends a removal as its own message, so a removal is its own transcript fact rather than the absence of one. Note what this does NOT tell you: replacing one reaction with another sends the new `added` and no `removed` beside it, so state is last-one-wins per (message, customer) and a client that appends will accumulate reactions the customer no longer has.

      - `"added"`

      - `"removed"`

    - `correlation: "inferred" or "ambiguous" or "unmatched" or "unavailable"`

      HOW the target was reached. CLOSED: `inferred` (exactly one message in the recent window carried that text, and both target keys name it), `ambiguous` (several did; `candidate_count` says how many, and no winner is picked for you), `unmatched` (none did), `unavailable` (Apple named no target at all, which is what a reaction to a non-text message looks like). There is no `exact`, because no reaction on this wire carries an id.

      - `"inferred"`

      - `"ambiguous"`

      - `"unmatched"`

      - `"unavailable"`

    - `kind: "like" or "love" or "dislike" or 5 more`

      What the customer did. CLOSED: `like`, `love`, `dislike`, `laugh`, `emphasize`, `question`, `sticker` (a sticker or Memoji), or `emoji` (any other character, which rides `emoji`).

      - `"like"`

      - `"love"`

      - `"dislike"`

      - `"laugh"`

      - `"emphasize"`

      - `"question"`

      - `"sticker"`

      - `"emoji"`

    - `attachment_id: optional string`

      The sticker's image, on a sticker whose image we retained: pass it to `GET /v1/attachments/{att}/content`. Present ONLY when `kind` is `sticker` and `action` is `added`. Absent when the image was not retained, or when more than one file arrived and the wire does not say which is the sticker: no handle is better than the wrong one. Do not assume a format. Captures show both `image/png` and `image/heic`; read the response's own content type. A PRESENT ID IS NOT A PROMISE OF BYTES, exactly as for `att_id`: retained attachments are swept after their published TTL and the event is immutable, so an old reaction still names the id it was minted with and the content read answers `404`. Handle that; it is the ordinary outcome for anything old enough.

    - `candidate_count: optional number`

      How many messages carried that exact text, on `ambiguous`.

    - `emoji: optional string`

      The character the customer picked, present only when `kind` is `emoji`, where the character IS the reaction and no named one exists for it.

    - `quoted_text: optional string`

      The original message's text as Apple quoted it back: the only handle this wire offers, and what the correlation is made against. Absent when Apple named no target.

    - `target_msg_id: optional string`

      The message reacted to, on `inferred`. Pass it to `GET /v1/messages/{msg}`.

    - `target_seq: optional number`

      That message's `seq`, on `inferred`: the transcript row to scroll to.

    - `target_side: optional "business" or "customer"`

      WHOSE message was reacted to, on `inferred`: `business` or `customer`. A customer can react to their own message, so this is not derivable from the reaction being a customer event. Unlike the correlation it is not an inference; it is the matched row's own type.

      - `"business"`

      - `"customer"`

  - `reason: optional string`

    The reason the event states, where it states one: the failure discriminator on a failed or suppressed send, the handoff reason on an ownership change, the refusal reason on a consent row.

  - `reply: optional unknown`

    A customer's normalized interactive selection (quick reply, list pick, time slot); absent when they sent plain text. A customer who taps a Quick Reply sends no text, so this is the row's only content.

  - `request_id: optional string`

    The `requestIdentifier` this message was delivered under: the exact value an interactive reply names in its own `request_id`, so an answer can be matched to the message it answers instead of inferred from item ids (the same card sent twice defines the same item ids, while each interactive send is delivered under a fresh identifier). Present only on a row naming a message that recorded one: interactive sends mint one, plain sends do not, and rows from before this field was served carry none.

### Example

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

#### Response

```json
{
  "data": [
    {
      "seq": 13,
      "kind": "customer_message",
      "event_type": "message_received",
      "actor": "customer",
      "created_at": "2026-08-06T14:01:58Z",
      "parts": [
        {
          "type": "text",
          "body": "where is my driver?"
        }
      ]
    }
  ]
}
```

## Domain Types

### Transcript Row

- `TranscriptRow object { actor, created_at, event_type, 13 more }`

  One transcript row, ready to render: an event-log entry rebuilt for
  display. It is not a `JournalEntry`: that shape has an open set of
  `type` values and a `payload` whose shape depends on it, so a client has
  nothing to switch on. This row leads with a closed `kind` and carries the
  message's canonical parts and delivery state.

  - `actor: string`

    Who wrote it: `customer`, `partner`, `human`, `flow`, `system` or `brand`.
    On a row carrying a `message_id` this is who COMPOSED that message: a
    human agent replying from a console reads `human`, a deterministic flow
    reads `flow`, and NOT the actor of the underlying event, which is always
    `partner` on a send because that event means we delivered it. Everywhere
    else it is the underlying event's own actor.

  - `created_at: string`

  - `event_type: string`

    The underlying event type (`message_received`, `owner_changed`, `consent_send_refused`, …); render detail from it, but branch on `kind`.

  - `kind: "customer_message" or "business_message" or "system"`

    What KIND of thing happened; switch on this. The set is CLOSED: an event type added later renders as `system`, never as a fourth value.

    - `"customer_message"`

    - `"business_message"`

    - `"system"`

  - `seq: number`

    The `seq` this row is built from; same numbering as the events endpoint, and the transcript's own cursor.

  - `attachments: optional array of object { att_id, mime_type, name, 2 more }`

    Files the CUSTOMER sent with this message; absent when they sent none.

    - `att_id: optional string`

      The retained plaintext's id (`att_…`); absent when nothing was retained.

    - `mime_type: optional string`

    - `name: optional string`

      The file's name as the customer's device sent it.

    - `reason: optional string`

      Present exactly when `att_id` is absent, and a CLOSED set: `too_large` | `brand_unresolved` | `store_unavailable` | `disabled` | `unavailable` (any failure earlier than the retention decision). Never the underlying error text, that is free-form and can carry the channel's own temporary download URL, so it stays on `/events`.

    - `size: optional number`

      Size in bytes.

  - `delivery: optional string`

    That message's delivery state, read from the message row: `queued` | `sending` | `sent` | `retry` | `failed` | `undelivered` | `suppressed`. Two of those are terminal failures, not one: `undelivered` is the "resubmitting later may land" outcome and `failed` the one that will not. There is NO delivered or read receipt here or anywhere on this API; AMB produces neither, and `sent` means only that Apple accepted the send.

  - `form_response: optional unknown`

    A `form_response` row's FULL record, first-class: `values` keyed by your original page ids, `item_ids`, `status`, `sources`, `opened_at`, `closed_at`. Present only on `event_type: "form_response"` rows.

  - `group_id: optional string`

    Ties a fan-out member to its group, and a `form_response` row to the degraded prompt messages that asked, so grouping needs no payload spelunking. Present on rows that belong to a group.

  - `message_id: optional string`

    The outbound message this row is about; pass it to `GET /v1/messages/{msg}`.

  - `operator: optional string`

    WHICH human. On a row carrying a `message_id` this is the operator who
    composed that send, where one did and where the send named them. On every
    other row it is the events endpoint's `operator`: the human we recorded,
    with the same rules.

    **On message rows this deliberately does NOT match `/events`**, which
    shows no `operator` for the same `seq`: a send is recorded by the worker
    that delivered it, with actor `partner` and no operator, so the composer
    is only knowable from the message row. Absence never means "no human";
    it means this row cannot say which.

  - `parts: optional array of unknown`

    The message's canonical parts (the same array a send takes), absent on
    system rows and on a message that carried none: an invitation delivery
    records its invitation identity rather than parts. Typed as an opaque
    array rather than `Part[]` on purpose: the generated Go transport must
    never contain a second decoder for the part union (`content.ParseParts`
    is the one decode door), and a response `$ref` to `Part` would put one
    there.

  - `reaction: optional object { action, correlation, kind, 7 more }`

    The reaction this customer text row IS, when the text is a tapback. The row keeps its text verbatim and `kind` stays `customer_message`, so a client ignoring this key sees exactly the transcript it saw before.

    - `action: "added" or "removed"`

      Whether the customer APPLIED this reaction or TOOK IT AWAY. CLOSED: `added` or `removed`, and always present. Apple sends a removal as its own message, so a removal is its own transcript fact rather than the absence of one. Note what this does NOT tell you: replacing one reaction with another sends the new `added` and no `removed` beside it, so state is last-one-wins per (message, customer) and a client that appends will accumulate reactions the customer no longer has.

      - `"added"`

      - `"removed"`

    - `correlation: "inferred" or "ambiguous" or "unmatched" or "unavailable"`

      HOW the target was reached. CLOSED: `inferred` (exactly one message in the recent window carried that text, and both target keys name it), `ambiguous` (several did; `candidate_count` says how many, and no winner is picked for you), `unmatched` (none did), `unavailable` (Apple named no target at all, which is what a reaction to a non-text message looks like). There is no `exact`, because no reaction on this wire carries an id.

      - `"inferred"`

      - `"ambiguous"`

      - `"unmatched"`

      - `"unavailable"`

    - `kind: "like" or "love" or "dislike" or 5 more`

      What the customer did. CLOSED: `like`, `love`, `dislike`, `laugh`, `emphasize`, `question`, `sticker` (a sticker or Memoji), or `emoji` (any other character, which rides `emoji`).

      - `"like"`

      - `"love"`

      - `"dislike"`

      - `"laugh"`

      - `"emphasize"`

      - `"question"`

      - `"sticker"`

      - `"emoji"`

    - `attachment_id: optional string`

      The sticker's image, on a sticker whose image we retained: pass it to `GET /v1/attachments/{att}/content`. Present ONLY when `kind` is `sticker` and `action` is `added`. Absent when the image was not retained, or when more than one file arrived and the wire does not say which is the sticker: no handle is better than the wrong one. Do not assume a format. Captures show both `image/png` and `image/heic`; read the response's own content type. A PRESENT ID IS NOT A PROMISE OF BYTES, exactly as for `att_id`: retained attachments are swept after their published TTL and the event is immutable, so an old reaction still names the id it was minted with and the content read answers `404`. Handle that; it is the ordinary outcome for anything old enough.

    - `candidate_count: optional number`

      How many messages carried that exact text, on `ambiguous`.

    - `emoji: optional string`

      The character the customer picked, present only when `kind` is `emoji`, where the character IS the reaction and no named one exists for it.

    - `quoted_text: optional string`

      The original message's text as Apple quoted it back: the only handle this wire offers, and what the correlation is made against. Absent when Apple named no target.

    - `target_msg_id: optional string`

      The message reacted to, on `inferred`. Pass it to `GET /v1/messages/{msg}`.

    - `target_seq: optional number`

      That message's `seq`, on `inferred`: the transcript row to scroll to.

    - `target_side: optional "business" or "customer"`

      WHOSE message was reacted to, on `inferred`: `business` or `customer`. A customer can react to their own message, so this is not derivable from the reaction being a customer event. Unlike the correlation it is not an inference; it is the matched row's own type.

      - `"business"`

      - `"customer"`

  - `reason: optional string`

    The reason the event states, where it states one: the failure discriminator on a failed or suppressed send, the handoff reason on an ownership change, the refusal reason on a consent row.

  - `reply: optional unknown`

    A customer's normalized interactive selection (quick reply, list pick, time slot); absent when they sent plain text. A customer who taps a Quick Reply sends no text, so this is the row's only content.

  - `request_id: optional string`

    The `requestIdentifier` this message was delivered under: the exact value an interactive reply names in its own `request_id`, so an answer can be matched to the message it answers instead of inferred from item ids (the same card sent twice defines the same item ids, while each interactive send is delivered under a fresh identifier). Present only on a row naming a message that recorded one: interactive sends mint one, plain sends do not, and rows from before this field was served carry none.

# Activity

## Get a chat's activity timeline

**get** `/v1/chats/{chat}/activity`

The chat's activity timeline: the governance story, distinct from the
transcript: how contact began, what consent stands, who controlled the
conversation when. Read-only, composed at read time.

**Behavior**

- `activity[]` carries the same event-log rows `/events`
  serves, narrowed to governance events (ownership, handoffs, flows,
  entry-point routing decisions, close/reopen, invitation responses,
  enforcement audit rows), never
  messages, typing, or reactions. Each item's `seq` is the event log's
  own, so an activity item and its `/events` row are the same fact
  under the same id. `class` is a closed enum the server promises:
  `contact | consent | control`. `actor` is one of the closed set
  `customer|partner|human|flow|system|brand`; an operator's identity,
  where one was recorded, is `detail.operator`.
- `entry_routed` (class `control`) is one entry-point routing decision,
  recorded for every inbound your routing document routed, whatever it
  decided. Its `detail` carries `intent` and `group` (the
  `biz-intent-id` and `biz-group-id` your entry-point link delivered),
  `matched` (the 0-based index of the rule that fired in your routing
  document, or `default`), `action`
  (`flow` | `human` | `none` | `entry_flow`), `flow` (the flow that was
  started, empty when none was), `outcome`
  (`applied` | `already_applied` | `entry_flow_unset`), and
  `routed_seq`, the `seq` of the inbound the decision was about. A
  customer can open a second entry point part way through a
  conversation, so a chat can carry several, each at its own `seq`.
- `origin`, `consent`, and `consent_events` are returned on the FIRST
  page only (a request with no `after_seq`); a resumed request answers
  `activity` alone. Page `activity` with `after_seq` exactly as
  `/events`.
- `consent` is a FOUR-STATE headline per category
  (`granted | revoked | expired | none`) over the categories that apply
  to the chat's handle kind: `expired` is a grant whose `expires_at`
  has passed, reported the way the send gate already treats it, and
  `none` means no decision on record. `consent_events` is the append-only consent
  history (`granted | revoked | expired`), complete for the subject;
  each category's `history_recorded_since` is its earliest recorded
  event.

**Errors**

- HTTP 404: unknown chat, or one belonging to another brand.

### Path Parameters

- `chat: string`

### Query Parameters

- `after_seq: optional number`

  Resume the `activity` page after this `seq`; omitted (or non-numeric) reads from the start and includes the `origin`/`consent`/`consent_events` blocks.

- `limit: optional number`

  Page size for `activity`; absent or outside 1-100 reads as 100.

### Returns

- `ActivityResponse object { activity, consent, consent_events, origin }`

  The activity timeline: the chat's governance story as a read-time
  composition. `origin`, `consent`, and `consent_events` appear on the first
  page only; a resumed page (`after_seq` present) carries `activity` alone.

  - `activity: array of object { actor, at, class, 3 more }`

    The governance events, `seq`-paged exactly as `/events`.

    - `actor: string`

      Who did this: `customer`, `partner`, `human`, `flow`, `system`, or `brand`.

    - `at: string`

    - `class: "contact" or "consent" or "control"`

      Server-promised closed class; render by class for any type you do not map.

      - `"contact"`

      - `"consent"`

      - `"control"`

    - `seq: number`

      The event log's own `seq`: the same id the `/events` row carries.

    - `type: string`

      The event type (`owner_changed`, `handoff_accepted`, `flow_entered`, …).

    - `detail: optional unknown`

      Per-type detail from the event's payload. OPEN AND ADDITIVE by contract: keys may be added over time, never renamed or removed; `operator`, `reason` and the per-type ids are present whenever their event recorded them.

  - `consent: optional unknown`

    Four-state consent snapshot per category applying to this chat's handle kind: `granted`, `revoked`, `expired`, or `none`. First page only.

  - `consent_events: optional array of object { action, at, category, 2 more }`

    The subject's append-only consent history, time-ordered and complete. First page only.

    - `action: "granted" or "revoked" or "expired"`

      `granted`, `revoked`, or `expired`, an expiry lapse recorded by the sweep.

      - `"granted"`

      - `"revoked"`

      - `"expired"`

    - `at: string`

    - `category: string`

    - `capture_mode: optional string`

    - `source: optional string`

  - `origin: optional object { kind, at, invitation }`

    Contact provenance. First page only.

    - `kind: "customer_initiated" or "invitation"`

      `customer_initiated` (the customer's first message created the chat) or `invitation` (business-initiated).

      - `"customer_initiated"`

      - `"invitation"`

    - `at: optional string`

      First-contact instant for a customer-initiated chat: the chat's own creation stamp.

    - `invitation: optional object { id, delivery, sent_at, 3 more }`

      The originating invitation, for `kind: invitation`.

      - `id: string`

      - `delivery: string`

        Its delivery state.

      - `sent_at: string`

        When the invitation was sent.

      - `response: optional string`

        The customer's response, when one was recorded.

      - `response_source: optional string`

        How the response was resolved: `tap`, or the literal-text fallback.

      - `superseded_by_chat_id: optional string`

        Set when a later invitation superseded this conversation.

### Example

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

#### Response

```json
{
  "origin": {
    "kind": "invitation",
    "invitation": {
      "id": "inv_7f81c2",
      "sent_at": "2026-08-14T17:55:34Z",
      "delivery": "sent",
      "response": "accepted",
      "response_source": "tap"
    }
  },
  "consent": {
    "marketing": {
      "state": "none",
      "history_recorded_since": null
    },
    "account_notification": {
      "state": "granted",
      "source": "inbound_keyword",
      "capture_mode": "linq_managed",
      "granted_at": "2026-08-14T18:01:02Z",
      "history_recorded_since": "2026-08-14T18:01:02Z"
    }
  },
  "consent_events": [
    {
      "category": "account_notification",
      "action": "granted",
      "at": "2026-08-14T18:01:02Z",
      "source": "inbound_keyword",
      "capture_mode": "linq_managed"
    }
  ],
  "activity": [
    {
      "seq": 9,
      "type": "handoff_accepted",
      "class": "control",
      "at": "2026-08-14T18:02:40Z",
      "actor": "human",
      "detail": {
        "operator": "op_becca",
        "reason": "customer asked"
      }
    }
  ]
}
```

## Domain Types

### Activity Response

- `ActivityResponse object { activity, consent, consent_events, origin }`

  The activity timeline: the chat's governance story as a read-time
  composition. `origin`, `consent`, and `consent_events` appear on the first
  page only; a resumed page (`after_seq` present) carries `activity` alone.

  - `activity: array of object { actor, at, class, 3 more }`

    The governance events, `seq`-paged exactly as `/events`.

    - `actor: string`

      Who did this: `customer`, `partner`, `human`, `flow`, `system`, or `brand`.

    - `at: string`

    - `class: "contact" or "consent" or "control"`

      Server-promised closed class; render by class for any type you do not map.

      - `"contact"`

      - `"consent"`

      - `"control"`

    - `seq: number`

      The event log's own `seq`: the same id the `/events` row carries.

    - `type: string`

      The event type (`owner_changed`, `handoff_accepted`, `flow_entered`, …).

    - `detail: optional unknown`

      Per-type detail from the event's payload. OPEN AND ADDITIVE by contract: keys may be added over time, never renamed or removed; `operator`, `reason` and the per-type ids are present whenever their event recorded them.

  - `consent: optional unknown`

    Four-state consent snapshot per category applying to this chat's handle kind: `granted`, `revoked`, `expired`, or `none`. First page only.

  - `consent_events: optional array of object { action, at, category, 2 more }`

    The subject's append-only consent history, time-ordered and complete. First page only.

    - `action: "granted" or "revoked" or "expired"`

      `granted`, `revoked`, or `expired`, an expiry lapse recorded by the sweep.

      - `"granted"`

      - `"revoked"`

      - `"expired"`

    - `at: string`

    - `category: string`

    - `capture_mode: optional string`

    - `source: optional string`

  - `origin: optional object { kind, at, invitation }`

    Contact provenance. First page only.

    - `kind: "customer_initiated" or "invitation"`

      `customer_initiated` (the customer's first message created the chat) or `invitation` (business-initiated).

      - `"customer_initiated"`

      - `"invitation"`

    - `at: optional string`

      First-contact instant for a customer-initiated chat: the chat's own creation stamp.

    - `invitation: optional object { id, delivery, sent_at, 3 more }`

      The originating invitation, for `kind: invitation`.

      - `id: string`

      - `delivery: string`

        Its delivery state.

      - `sent_at: string`

        When the invitation was sent.

      - `response: optional string`

        The customer's response, when one was recorded.

      - `response_source: optional string`

        How the response was resolved: `tap`, or the literal-text fallback.

      - `superseded_by_chat_id: optional string`

        Set when a later invitation superseded this conversation.

# Handoff

## Request or return a handoff

**post** `/v1/chats/{chat}/handoff`

Requests a human takeover, or returns control to your system.

**Behavior**

- `to: "human"` moves the chat to `human_pending`; closed to your sends
  until a human accepts or control returns.
- `to: "partner"` returns control to your system.
- The event log records who asked: a request carries actor `partner`, a
  return carries actor `human`, and an operator name appears only beside
  a `human` actor.

### Path Parameters

- `chat: string`

### Body Parameters

- `to: "human" or "partner"`

  `human` requests a human (owner → `human_pending`); `partner` returns control. Anything else returns HTTP 422 `code` 1071.

  - `"human"`

  - `"partner"`

- `reason: optional string`

  Free-text reason, recorded on the ownership event (absent → empty).

### Returns

- `ok: true`

  - `true`

### Example

```http
curl https://messages.api.linqapp.com/v1/chats/$CHAT/handoff \
    -H 'Content-Type: application/json' \
    -H "Authorization: Bearer $LINQ_AMB_API_KEY" \
    -d '{
          "to": "human"
        }'
```

#### Response

```json
{
  "ok": true
}
```

## Accept a pending handoff

**post** `/v1/chats/{chat}/handoff/accept`

Take a pending handoff live (`human_pending` → `human`). Auth stays the
partner's API key: the `human` actor is honest about the role, not an
authenticated person. When the accept is made through our agent console
with a named operator, the event log records WHICH human accepted it
(`operator` on the events read).

**Behavior**

- Legal only from `human_pending`. Once applied, the chat is `human`,
  and a second accept returns HTTP 409 `code` 2025.
- Two accepts arriving together resolve to one; first writer wins, and
  the loser records nothing, so the transcript never shows an accept
  that did not take effect.
- When our console attributes the accept to a named operator, that
  operator claims the chat (`assigned_operator`); a different operator
  accepting the same entry returns HTTP 409 `code` 2009,
  naming the holder. The claim is released when the chat returns to
  your system (`POST …/handoff` with `to: "partner"`), which is also
  how a claim held by someone unreachable is cleared: return it, then
  accept it again.
- A claim does not restrict sending; the desk is shared at the brand
  level.

### Path Parameters

- `chat: string`

### Body Parameters

- `reason: optional string`

  Free-text reason, recorded on the ownership event.

### Returns

- `ok: true`

  - `true`

### Example

```http
curl https://messages.api.linqapp.com/v1/chats/$CHAT/handoff/accept \
    -X POST \
    -H "Authorization: Bearer $LINQ_AMB_API_KEY"
```

#### Response

```json
{
  "ok": true
}
```

## Domain Types

### Handoff Request Response

- `HandoffRequestResponse object { ok }`

  The bare `{"ok":true}` acknowledgement; failures ride the error envelope instead.

  - `ok: true`

    - `true`

### Handoff Accept Response

- `HandoffAcceptResponse object { ok }`

  The bare `{"ok":true}` acknowledgement; failures ride the error envelope instead.

  - `ok: true`

    - `true`
