# Messages

## Get a message's status

**get** `/v1/messages/{msg}`

Retrieves a sent message's delivery outcome. Use it when you missed, or do not subscribe to: the `message.sent` and `message.failed` webhooks.

### Path Parameters

- `msg: string`

### Returns

- `id: string`

  Message id (`msg_…`).

- `chat_id: string`

- `created_at: string`

- `state: string`

  Delivery state: `queued` until a terminal outcome (`sent`, `failed`, `suppressed`, …).

- `updated_at: string`

- `fallbacks: optional array of Fallback`

  Capability rewrites applied at accept under `degrade` (`"auto"` or `"acknowledged"`); present only when something was rewritten (the same array the send's 200 carried).

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

- `group_id: optional string`

  Correlation id present ONLY when this message was one of several a single
  request fanned out into (URL promotion): it is the first message's id,
  carried on every member, the same value the send response and the
  `message.sent`/`message.failed` webhooks report. Use it to identify which
  fan-out group a message belongs to; it is a correlation key, not a member
  list. Absent on an ordinary single-message send.

- `last_error: optional string`

  Gateway error of the last attempt; rides only on a gateway-verdict failure.

- `last_status: optional number`

  Gateway HTTP status of the last attempt; rides only on a gateway-verdict failure.

- `parts: optional array of unknown`

  The message's canonical parts AS STORED at accept; post-degrade,
  post-promotion, i.e. what the customer's device was (or will be) sent,
  not necessarily what you composed (`fallbacks` says when they differ).
  Typed as an opaque array rather than `Part[]` for the same
  one-decode-door reason `TranscriptRow.parts` is. Absent only when the
  stored content cannot be rendered canonically.

- `reason: optional string`

  Failure-only: whether to resubmit (`permanent`, `undelivered`, `auth_error`, `suppressed`, `consent_revoked`, …): the same value the `message.failed` webhook carried.

### Example

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

#### Response

```json
{
  "id": "msg_2c7d90",
  "chat_id": "chat_4f81b2",
  "state": "sent",
  "created_at": "2026-08-06T14:02:12Z",
  "updated_at": "2026-08-06T14:02:13Z"
}
```

## Get a message's timeline

**get** `/v1/messages/{msg}/timeline`

Retrieves the full story of one outbound send: when it was accepted,
its current delivery state, the terminal events it produced, and the
webhook deliveries that carried those events to your endpoints.
`GET /v1/messages/{msg}` answers "what happened"; this answers "why
does it say that".

**Requirements**

- `msg` is an outbound send id: the ids `POST /v1/chats/{chat}/messages`
  returns. Inbound customer messages have no message id and return
  HTTP 404, as does another brand's id or an unknown one.

**Limits**

- No per-attempt history: entries carry counters and a current state.
- No intermediate delivery states: you get the current state plus the
  immutable terminal events.
- Entries past the retention horizons are marked by `retention` and
  `truncated` rather than silently absent.

### Path Parameters

- `msg: string`

### Returns

- `chat_id: string`

- `entries: array of object { at, kind, attempt, 13 more }`

  Every entry we hold, oldest first.

  - `at: string`

    When the fact was recorded: accept time on `accepted`, when the delivery row last moved on `delivery` (it is mutable), the journal row's time on a terminal event, and the outbox row's creation on `webhook`.

  - `kind: string`

    What this entry is. Documented-open; skip values you do not know.

  - `attempt: optional number`

    The row's attempt counter. Present-and-zero is a real answer ("accepted, never attempted"), so read absence and zero differently.

  - `attempt_404: optional number`

    `delivery` only: attempts that answered `404`.

  - `attempt_auth: optional number`

    `delivery` only: attempts that failed authentication.

  - `endpoint_id: optional string`

    `webhook` only: YOUR endpoint id (`wh_…`) the row is bound for.

  - `event_type: optional string`

    `webhook` only: the partner event kind the row carries (`message.sent` | `message.failed`).

  - `last_error: optional string`

    Failure-only: the gateway's (or your endpoint's) error text on a FAILED row.

  - `last_status: optional number`

    Failure-only, exactly as on `GET /v1/messages/{msg}`: the gateway's (or your endpoint's) HTTP status on a FAILED row, and absent otherwise: a success does not publish its status here.

  - `message_id: optional string`

    `webhook` only: the message the row's own payload names: the finer attribution key on the window-spanning row above.

  - `next_attempt_at: optional string`

    When the next attempt is due. Rides only while the row is NON-terminal, so a settled entry never advertises a retry that will not happen.

  - `reason: optional string`

    `message_failed` only: the failure discriminator (`permanent`, `undelivered`, `auth_error`, `suppressed`, …), the same value that webhook carried.

  - `seq: optional number`

    Journal entries only: the event's `seq`: the same watermark `GET /v1/chats/{chat}/events` pages on.

  - `seq_from: optional number`

    `webhook` only: the row's journal-seq window (inclusive). Every per-message emit produces a single-seq window today, so a webhook entry names exactly one message; the schema permits a wider one, and such a row appears on EVERY covered message's timeline.

  - `seq_to: optional number`

  - `state: optional string`

    Current state of the mutable row behind this entry: the delivery state (`queued` | `sending` | `retry` | `sent` | `failed` | `undelivered` | `suppressed` | `cancelled`) on `delivery`, the outbox state (`pending` | `sending` | `delivered` | `failed`) on `webhook`.

- `message_id: string`

  The message this timeline is about (`msg_…`).

- `retention: object { delivery_from, webhook_from }`

  The two retention horizons this response was computed against: the database
  clock's `now()` minus each sweep's own window. They differ, which is the
  whole reason `truncated` exists.

  - `delivery_from: string`

    Delivery rows that settled before this may have been swept (90 days).

  - `webhook_from: string`

    Webhook outbox rows created before this may have been swept (30 days).

- `truncated: array of string`

  Each section whose retention horizon this message has outlived;
  `"delivery"`, `"webhook"`, or both. Always present; an EMPTY array means an
  empty section is a fact about the world rather than a sweep.

  It exists because the horizons differ: a 45-day-old send that delivered and
  whose webhook fired shows a delivery entry and zero webhook entries, which
  would otherwise be byte-identical to "the webhook was never created" on the
  one endpoint built to answer that question.

  "MAY have been swept", never "was": the 30-day webhook sweep takes
  `delivered` rows ONLY, so a pending or failed outbox row survives past its
  horizon and truncation can hide only a SUCCESS.

### Example

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

#### Response

```json
{
  "message_id": "msg_2c7d90",
  "chat_id": "chat_4f81b2",
  "entries": [
    {
      "kind": "accepted",
      "at": "2026-08-06T14:02:12Z"
    },
    {
      "kind": "delivery",
      "at": "2026-08-06T14:02:13Z",
      "state": "sent",
      "attempt": 1
    },
    {
      "kind": "message_sent",
      "at": "2026-08-06T14:02:13Z",
      "seq": 14
    },
    {
      "kind": "webhook",
      "at": "2026-08-06T14:02:13Z",
      "state": "delivered",
      "attempt": 1,
      "endpoint_id": "wh_7c31a8",
      "event_type": "message.sent",
      "seq_from": 14,
      "seq_to": 14,
      "message_id": "msg_2c7d90"
    }
  ],
  "retention": {
    "delivery_from": "2026-05-08T14:02:20Z",
    "webhook_from": "2026-07-07T14:02:20Z"
  },
  "truncated": []
}
```

## Domain Types

### Message Retrieve Response

- `MessageRetrieveResponse object { id, chat_id, created_at, 8 more }`

  A send's delivery outcome.

  - `id: string`

    Message id (`msg_…`).

  - `chat_id: string`

  - `created_at: string`

  - `state: string`

    Delivery state: `queued` until a terminal outcome (`sent`, `failed`, `suppressed`, …).

  - `updated_at: string`

  - `fallbacks: optional array of Fallback`

    Capability rewrites applied at accept under `degrade` (`"auto"` or `"acknowledged"`); present only when something was rewritten (the same array the send's 200 carried).

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

  - `group_id: optional string`

    Correlation id present ONLY when this message was one of several a single
    request fanned out into (URL promotion): it is the first message's id,
    carried on every member, the same value the send response and the
    `message.sent`/`message.failed` webhooks report. Use it to identify which
    fan-out group a message belongs to; it is a correlation key, not a member
    list. Absent on an ordinary single-message send.

  - `last_error: optional string`

    Gateway error of the last attempt; rides only on a gateway-verdict failure.

  - `last_status: optional number`

    Gateway HTTP status of the last attempt; rides only on a gateway-verdict failure.

  - `parts: optional array of unknown`

    The message's canonical parts AS STORED at accept; post-degrade,
    post-promotion, i.e. what the customer's device was (or will be) sent,
    not necessarily what you composed (`fallbacks` says when they differ).
    Typed as an opaque array rather than `Part[]` for the same
    one-decode-door reason `TranscriptRow.parts` is. Absent only when the
    stored content cannot be rendered canonically.

  - `reason: optional string`

    Failure-only: whether to resubmit (`permanent`, `undelivered`, `auth_error`, `suppressed`, `consent_revoked`, …): the same value the `message.failed` webhook carried.

### Message Timeline Response

- `MessageTimelineResponse object { chat_id, entries, message_id, 2 more }`

  The row-level story of one outbound send.

  - `chat_id: string`

  - `entries: array of object { at, kind, attempt, 13 more }`

    Every entry we hold, oldest first.

    - `at: string`

      When the fact was recorded: accept time on `accepted`, when the delivery row last moved on `delivery` (it is mutable), the journal row's time on a terminal event, and the outbox row's creation on `webhook`.

    - `kind: string`

      What this entry is. Documented-open; skip values you do not know.

    - `attempt: optional number`

      The row's attempt counter. Present-and-zero is a real answer ("accepted, never attempted"), so read absence and zero differently.

    - `attempt_404: optional number`

      `delivery` only: attempts that answered `404`.

    - `attempt_auth: optional number`

      `delivery` only: attempts that failed authentication.

    - `endpoint_id: optional string`

      `webhook` only: YOUR endpoint id (`wh_…`) the row is bound for.

    - `event_type: optional string`

      `webhook` only: the partner event kind the row carries (`message.sent` | `message.failed`).

    - `last_error: optional string`

      Failure-only: the gateway's (or your endpoint's) error text on a FAILED row.

    - `last_status: optional number`

      Failure-only, exactly as on `GET /v1/messages/{msg}`: the gateway's (or your endpoint's) HTTP status on a FAILED row, and absent otherwise: a success does not publish its status here.

    - `message_id: optional string`

      `webhook` only: the message the row's own payload names: the finer attribution key on the window-spanning row above.

    - `next_attempt_at: optional string`

      When the next attempt is due. Rides only while the row is NON-terminal, so a settled entry never advertises a retry that will not happen.

    - `reason: optional string`

      `message_failed` only: the failure discriminator (`permanent`, `undelivered`, `auth_error`, `suppressed`, …), the same value that webhook carried.

    - `seq: optional number`

      Journal entries only: the event's `seq`: the same watermark `GET /v1/chats/{chat}/events` pages on.

    - `seq_from: optional number`

      `webhook` only: the row's journal-seq window (inclusive). Every per-message emit produces a single-seq window today, so a webhook entry names exactly one message; the schema permits a wider one, and such a row appears on EVERY covered message's timeline.

    - `seq_to: optional number`

    - `state: optional string`

      Current state of the mutable row behind this entry: the delivery state (`queued` | `sending` | `retry` | `sent` | `failed` | `undelivered` | `suppressed` | `cancelled`) on `delivery`, the outbox state (`pending` | `sending` | `delivered` | `failed`) on `webhook`.

  - `message_id: string`

    The message this timeline is about (`msg_…`).

  - `retention: object { delivery_from, webhook_from }`

    The two retention horizons this response was computed against: the database
    clock's `now()` minus each sweep's own window. They differ, which is the
    whole reason `truncated` exists.

    - `delivery_from: string`

      Delivery rows that settled before this may have been swept (90 days).

    - `webhook_from: string`

      Webhook outbox rows created before this may have been swept (30 days).

  - `truncated: array of string`

    Each section whose retention horizon this message has outlived;
    `"delivery"`, `"webhook"`, or both. Always present; an EMPTY array means an
    empty section is a fact about the world rather than a sweep.

    It exists because the horizons differ: a 45-day-old send that delivered and
    whose webhook fired shows a delivery entry and zero webhook entries, which
    would otherwise be byte-identical to "the webhook was never created" on the
    one endpoint built to answer that question.

    "MAY have been swept", never "was": the 30-day webhook sweep takes
    `delivered` rows ONLY, so a pending or failed outbox row survives past its
    horizon and truncation can hide only a SUCCESS.
