---
title: "List recent webhook delivery attempts for the authenticated account"
method: GET
path: "/api/v1/webhook-deliveries"
---

# List recent webhook delivery attempts for the authenticated account

`GET /api/v1/webhook-deliveries`

## Request

- Base URL: `https://api.uptimemonitoring.com`
- URL: `https://api.uptimemonitoring.com/api/v1/webhook-deliveries`
- Auth: HTTP bearer

Two independent response shapes, selected by `?order=`:

- Default (`order` key absent entirely): offset mode. `created_at`
  descending, `?limit=`/`?offset=`, `WebhookDeliveryListResponse`.
  Unchanged since before issue #396 apart from the added
  `enqueued_at` field on each delivery. A present `order` key —
  even an empty `?order=` or a bare `?order` — never falls back
  to this mode; see below.
- `order=id`: cursor mode (issue #396). `id` ascending,
  keyset-paginated via `?cursor=`, `WebhookDeliveryCursorListResponse`.
  Any `?offset=` (any value, including `0`) is rejected with 400.
  A malformed or empty `?cursor=` is rejected with 400 — presence
  of the parameter is checked independently of its value, so a
  present-but-empty `?order=id&cursor=` is rejected the same as a
  malformed one, not treated as "no cursor". `?cursor=` without
  `order=id` is also rejected with 400, empty value included — a
  cursor only has meaning in keyset order. `order` itself must
  be present exactly once with the value `id`; a present but
  empty `?order=`, a bare `?order`, a repeated `order=` key, or
  any other value is rejected with 400 rather than silently
  running offset mode. A malformed query string itself (e.g. an
  unescaped `;`) is rejected with 400 before any parameter is
  inspected.

`?recipient_id=` filters to one recipient's own deliveries
(`webhook_deliveries.webhook_id`), still scoped to the caller's
account. `?status=` is repeatable (OR-union) — a single
`status=x` behaves exactly as before issue #396.

**Recipient-queue drain recipe.** To prove a recipient's
pre-attachment backlog is drained before deleting it (e.g.
rotating to a replacement recipient via
`DELETE /api/v1/recipients/{id}?replaced_by=`): scan
`?recipient_id={id}&status=pending&status=retrying&order=id`,
following `next_cursor` until it is `null`. Only an empty scan
result proves the queue is literally empty; any row the scan
returns proves the opposite, since the status filter selects
live rows by construction. The backlog is considered drained
once every returned row's `enqueued_at` is non-null and greater
than the replacement's `attached_at` on every monitor
(`GET /api/v1/monitors/{id}/recipients`'s `attached_at` field) —
but that timestamp comparison alone does not prove sibling
coverage (that each row has a counterpart delivery enqueued for
the replacement); it only establishes that a live row postdates
the replacement's attachment. `DELETE /api/v1/recipients/{recipientID}`
is the authoritative check for a safe rotation — it verifies
sibling coverage server-side, in one transaction, rather than
trusting a client-computed timestamp comparison. See that
endpoint for the full recipe and its guarantees/limits.

## Query parameters

- `monitor_id` integer
- `recipient_id` integer
- `status` string[]
- `since` string, date-time
- `limit` integer
- `offset` integer
- `order` 'id'
- `cursor` string

## Response `200`

Deliveries

- union
  - WebhookDeliveryListResponse
    - `deliveries` WebhookDelivery[]
      - `id` integer
      - `webhook_id` integer: A recipient id (issue #212) — the field name is kept for wire compatibility; it has not named a row in the legacy `webhooks` table since the recipients migration. A row can represent an email delivery (issue #321), a Slack post (issue #359), or a PagerDuty Events API v2 event (issue #363) as well as a webhook POST — check the referenced recipient's `type` to tell which; `last_response_code` is SendGrid's status for email (202 on success, 0 on transport error), the destination's HTTP status for webhook, Slack's own HTTP status for slack (`response_body` carries Slack's error string, e.g. `"no_service"`), and PagerDuty's own HTTP status for pagerduty (`response_body` carries FIXED outcome text, e.g. `"PagerDuty accepted the event (HTTP 202)"` or `"PagerDuty rate-limited the event (HTTP 429)"` — never PagerDuty's actual response body, which could echo the routing key) — for both slack and pagerduty, only 429 and 5xx retry; every other non-2xx, 3xx included, is terminal on the first attempt. A pagerduty row with no `outage_id` — enqueued before issue #363, or an "up" recovery whose onset transition left no open incident to inherit an outage id from — fails immediately with `response_body` `"no outage key"` and no request ever sent; such a recovery is never reported to PagerDuty.
      - `monitor_id` integer
      - `account_id` integer
      - `event_type` string
      - `payload_hash` string, nullable
      - `status` 'pending' | 'retrying' | 'delivered' | 'failed' | 'superseded' | 'cancelled': `superseded` (added for issue #165): an older `pending`/`retrying` delivery for the same webhook+monitor pair was replaced by a newer one, bounding a swarm of stale notifications after an outage recovers to at most one live delivery per pair at a time. Not immutably terminal: the replacement is not atomic with dispatch, so a delivery already in flight when it was superseded can still complete and transition to `delivered` for that one attempt — a subscriber can still receive a delivery for a transition that was superseded before it arrived. `cancelled` (added for issue #338): a delayed `down` delivery whose outage recovered before its `down_delay_s` elapsed, or whose recipient's events narrowed to drop `down` while the row was still pending. Never reused for `superseded` — nothing replaced the row, it was simply never sent. Also not immutably terminal: a row already claimed for dispatch when an events-narrowing edit cancels it can still complete and transition to `delivered`.
      - `attempt_count` integer
      - `collapsed_count` integer: The number of older live (`pending`/`retrying`) delivery rows this row superseded, plus their own accumulated collapsed counts. Zero for a delivery that superseded nothing. This is NOT a count of POSTs guaranteed suppressed, and NOT a count of transitions the subscriber missed: a superseded row can still be in flight and reach `delivered` after being counted here. Mirrors the outbound `collapsed_transitions` payload field.
      - `next_attempt_at` string, date-time
      - `last_attempt_at` string, date-time, nullable
      - `last_response_code` integer, nullable
      - `response_body` string, nullable: Truncated copy (≤4096 bytes, NUL-stripped, valid UTF-8) of the subscriber's HTTP response body from the last attempt, for `type=webhook`/`type=email`/`type=slack`. Present to help debug rejected deliveries; null for rows recorded before this was captured or when the subscriber returned no body. For `type=pagerduty` (issue #363), this is NEVER the actual response body — it is always one of a small set of fixed outcome strings (e.g. `"PagerDuty accepted the event (HTTP 202)"`, `"PagerDuty rejected the event (HTTP 400)"`, `"PagerDuty request failed (timeout)"`, or `"no outage key"`), because a real PagerDuty response body could echo the request back, including the routing key.
      - `created_at` string, date-time
      - `cancelled_at` string, date-time, nullable: Set only when status is `cancelled` (issue
      - `enqueued_at` string, date-time, nullable: The Postgres server clock's own stamp of this row's insert moment (issue #396) — distinct from `created_at`, which is the dispatcher's own Go-clock transition time and so cannot be compared against anything the database generates. Null for a row written before this column existed; every reader treats null as "before any boundary". Used by the `?recipient_id=&order=id` drain scan (see `/api/v1/webhook-deliveries`'s description) and the guarded `DELETE /api/v1/recipients/{recipientID}?replaced_by=` endpoint to decide whether a row predates a replacement recipient's attachment.
    - `total` integer
    - `limit` integer
    - `offset` integer
  - WebhookDeliveryCursorListResponse: The response shape for `?order=id` (issue #396) — keyset-paginated, ascending by `id`. No `total`/`offset`: cursor mode never runs the COUNT(*) that would produce one. `next_cursor` is always present, even on the last page, where its value is `null`.
    - `deliveries` WebhookDelivery[], required
      - `id` integer
      - `webhook_id` integer: A recipient id (issue #212) — the field name is kept for wire compatibility; it has not named a row in the legacy `webhooks` table since the recipients migration. A row can represent an email delivery (issue #321), a Slack post (issue #359), or a PagerDuty Events API v2 event (issue #363) as well as a webhook POST — check the referenced recipient's `type` to tell which; `last_response_code` is SendGrid's status for email (202 on success, 0 on transport error), the destination's HTTP status for webhook, Slack's own HTTP status for slack (`response_body` carries Slack's error string, e.g. `"no_service"`), and PagerDuty's own HTTP status for pagerduty (`response_body` carries FIXED outcome text, e.g. `"PagerDuty accepted the event (HTTP 202)"` or `"PagerDuty rate-limited the event (HTTP 429)"` — never PagerDuty's actual response body, which could echo the routing key) — for both slack and pagerduty, only 429 and 5xx retry; every other non-2xx, 3xx included, is terminal on the first attempt. A pagerduty row with no `outage_id` — enqueued before issue #363, or an "up" recovery whose onset transition left no open incident to inherit an outage id from — fails immediately with `response_body` `"no outage key"` and no request ever sent; such a recovery is never reported to PagerDuty.
      - `monitor_id` integer
      - `account_id` integer
      - `event_type` string
      - `payload_hash` string, nullable
      - `status` 'pending' | 'retrying' | 'delivered' | 'failed' | 'superseded' | 'cancelled': `superseded` (added for issue #165): an older `pending`/`retrying` delivery for the same webhook+monitor pair was replaced by a newer one, bounding a swarm of stale notifications after an outage recovers to at most one live delivery per pair at a time. Not immutably terminal: the replacement is not atomic with dispatch, so a delivery already in flight when it was superseded can still complete and transition to `delivered` for that one attempt — a subscriber can still receive a delivery for a transition that was superseded before it arrived. `cancelled` (added for issue #338): a delayed `down` delivery whose outage recovered before its `down_delay_s` elapsed, or whose recipient's events narrowed to drop `down` while the row was still pending. Never reused for `superseded` — nothing replaced the row, it was simply never sent. Also not immutably terminal: a row already claimed for dispatch when an events-narrowing edit cancels it can still complete and transition to `delivered`.
      - `attempt_count` integer
      - `collapsed_count` integer: The number of older live (`pending`/`retrying`) delivery rows this row superseded, plus their own accumulated collapsed counts. Zero for a delivery that superseded nothing. This is NOT a count of POSTs guaranteed suppressed, and NOT a count of transitions the subscriber missed: a superseded row can still be in flight and reach `delivered` after being counted here. Mirrors the outbound `collapsed_transitions` payload field.
      - `next_attempt_at` string, date-time
      - `last_attempt_at` string, date-time, nullable
      - `last_response_code` integer, nullable
      - `response_body` string, nullable: Truncated copy (≤4096 bytes, NUL-stripped, valid UTF-8) of the subscriber's HTTP response body from the last attempt, for `type=webhook`/`type=email`/`type=slack`. Present to help debug rejected deliveries; null for rows recorded before this was captured or when the subscriber returned no body. For `type=pagerduty` (issue #363), this is NEVER the actual response body — it is always one of a small set of fixed outcome strings (e.g. `"PagerDuty accepted the event (HTTP 202)"`, `"PagerDuty rejected the event (HTTP 400)"`, `"PagerDuty request failed (timeout)"`, or `"no outage key"`), because a real PagerDuty response body could echo the request back, including the routing key.
      - `created_at` string, date-time
      - `cancelled_at` string, date-time, nullable: Set only when status is `cancelled` (issue
      - `enqueued_at` string, date-time, nullable: The Postgres server clock's own stamp of this row's insert moment (issue #396) — distinct from `created_at`, which is the dispatcher's own Go-clock transition time and so cannot be compared against anything the database generates. Null for a row written before this column existed; every reader treats null as "before any boundary". Used by the `?recipient_id=&order=id` drain scan (see `/api/v1/webhook-deliveries`'s description) and the guarded `DELETE /api/v1/recipients/{recipientID}?replaced_by=` endpoint to decide whether a row predates a replacement recipient's attachment.
    - `limit` integer, required
    - `next_cursor` string, nullable, required: Opaque base64url token. Pass back verbatim as `?cursor=` to fetch the next page. `null` means no further page exists.

## Other responses

- `400`: Bad request — an unknown `status` value; an `order` that is empty, repeated, or anything other than `id` (including a bare `?order`); a malformed `cursor`; `cursor` supplied without `order=id`; or `offset` supplied together with `order=id`.
- `429`: Per-account dispatcher rate limit exceeded (200 requests/minute). The `Retry-After` header gives the integer seconds until at least one token has refilled. The `X-RateLimit-*` trio reflects the bucket whose rejection produced this response — usually the global 200/min cap; on `POST /api/v1/monitors` a 429 may instead reflect the per-route 20/min creation limit.

---

[API](https://skmtc.dev/uptimemonitoring/apis/uptimemonitoring-com-api.md) · [All operations](https://skmtc.dev/uptimemonitoring/apis/uptimemonitoring-com-api/llms.txt) · [OpenAPI document](https://skmtc.dev/uptimemonitoring/apis/uptimemonitoring-com-api/revisions/ddcb2a8bc70f?raw)
