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