> For the complete documentation index, see [llms.txt](https://docs.shortcutco.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.shortcutco.com/developers/webhooks/retries-and-monitoring.md).

# Retries and monitoring

What Shortcut counts as delivered, when it retries, when it turns a webhook off, and how to see and resend every delivery.

After this page you can build a receiver that never loses an event, read the delivery history when something goes wrong, and resend what your endpoint missed.

## What counts as delivered

Shortcut sends each event as one `POST` and waits for the answer:

* **Any `2xx` status is success.** The body of your answer is not read for meaning.
* **Everything else is a failed attempt**: any other status, a redirect (`3xx` is never followed), a refused or dropped connection, a host name that does not resolve or resolves to a private address, no connection within 5 seconds, or no complete answer within 15 seconds of starting.
* **Shortcut reads at most 64 KiB of your answer** and keeps its first 500 characters, with secrets and contact details redacted, for the deliveries list. Answer with an empty body or a short `ok`.

At most 2 requests are in flight to one webhook at a time, and at most 10 across the workspace.

## The retry schedule

A delivery is one event for one webhook. It gets up to 6 attempts:

| Attempt | When                                   |
| ------- | -------------------------------------- |
| 1       | Within about a minute of the change.   |
| 2       | About 1 minute after attempt 1 failed. |
| 3       | About 5 minutes after attempt 2.       |
| 4       | About 30 minutes after attempt 3.      |
| 5       | About 2 hours after attempt 4.         |
| 6       | About 12 hours after attempt 5.        |

If the sixth attempt fails, the delivery is `exhausted` and Shortcut stops trying, about 15 hours after the first attempt. You can still [retry it yourself](#retry-a-delivery).

Every attempt carries the same `Shortcut-Event-Id` and `Shortcut-Delivery-Id`, a fresh signature with a new `t`, and the record as it is at that attempt. A retry can therefore carry newer data than the first attempt did.

## Duplicates and order

Delivery is at least once. The same event can reach you twice, for example when your endpoint did the work but its `2xx` was lost or came after the 15 seconds. Keep the envelope `id` (also in the `Shortcut-Event-Id` header) of the events you have handled, for a few days at least, and skip one you have already seen. The `id` is the same on every retry and for every webhook that receives the event, so two webhooks pointed at the same endpoint deliver one event twice under one `id`.

Order is not guaranteed. Retries, parallel requests, and changes made close together can arrive in any order: a `visit.completed` can arrive before the `visit.rescheduled` that preceded it. Treat each event as "this record changed": compare the object's `updatedAt` with your copy and keep the newer one, or fetch the record with the API when the order matters.

## Build a receiver that keeps up

1. **Verify the signature** over the raw body. See [Verify signatures](/developers/webhooks/verify-signatures.md).
2. **Skip duplicates** by the envelope `id`.
3. **Store the event** in a durable queue or table.
4. **Answer `200` straight away**, well inside 15 seconds.
5. **Do the work afterwards**, from the queue: updating your records, calling the API, sending messages.

A receiver that does slow work before it answers times out, gets the same event again, and counts failures against the webhook.

## When Shortcut turns a webhook off

A webhook whose attempts fail 50 times in a row, across all its deliveries, is turned off: its `status` becomes `disabled` and `disabledAt` says when. A successful attempt sets the count, `consecutiveFailures`, back to zero. Test events do not count.

When a webhook is turned off:

* its deliveries still waiting end as `exhausted`;
* events that happen while it is off are not kept and are never sent;
* the workspace's company admins get a notification and an email that it was turned off, naming the endpoint's host.

Before you turn it back on, note its `disabledAt`: turning it back on clears it, and you need it to catch up. Then fix the endpoint, check it with a [test event](/developers/webhooks/overview.md#manage-webhooks-with-the-api), and set the webhook to `active`:

```bash
curl https://api.shortcutco.com/v1/webhooks/7d2f4b8e-1a3c-4e5f-9b6d-0c8a2e4f6b1d \
  -X PATCH \
  -H "Authorization: Bearer $SHORTCUT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "status": "active" }'
```

Its failure count starts over at zero. A company admin can do the same in **Settings → API & integrations**. To catch up on changes made while it was off, read the records that changed since the `disabledAt` you noted with `updatedSince`; see [Sync](/developers/concepts/sync.md).

## Events that wait

Some deliveries are `held`: kept, not attempted, and sent later.

* **A paused webhook** holds everything still waiting to be sent, from the moment it is paused, and every new event. Setting it back to `active` starts sending what it held within about a minute, two at a time: about two deliveries a minute per webhook, in no particular order, so a long backlog takes a while to drain. A send already under way when you pause finishes; if it fails, its retry is held too.
* **A workspace whose Shortcut subscription needs attention**, so the API is paused for it, has its deliveries held as well. Shortcut checks again every hour and sends them once the subscription is in good standing.

A held delivery is kept for 30 days from when it was created. After that it ends as `exhausted` with `lastError` held\_expired. Time spent held does not count toward the 3 days after which a waiting delivery expires; that clock starts again when the webhook resumes.

## See what was sent

`GET /v1/webhooks/{id}/deliveries` lists a webhook's deliveries, newest first, test events included. It takes `limit` and `cursor` like every list (see [Pagination](/developers/concepts/conventions.md#pagination)) and an optional `status`:

```bash
curl "https://api.shortcutco.com/v1/webhooks/7d2f4b8e-1a3c-4e5f-9b6d-0c8a2e4f6b1d/deliveries?status=failed&limit=20" \
  -H "Authorization: Bearer $SHORTCUT_TOKEN"
```

```json
{
  "data": [
    {
      "id": "0f3a5c7e-9b1d-4f2a-8c6e-1d3b5f7a9c2e",
      "object": "webhook_delivery",
      "webhookId": "7d2f4b8e-1a3c-4e5f-9b6d-0c8a2e4f6b1d",
      "eventId": "evt_9b2e4c6a8d0f1e3b5a7c9d2f4e6b8a0c",
      "eventType": "invoice.paid",
      "status": "failed",
      "attempts": 2,
      "nextAttemptAt": "2026-10-17T15:10:02.000Z",
      "deliveredAt": null,
      "lastResponseStatus": 503,
      "lastError": "http_503",
      "lastResponseExcerpt": "Service Unavailable",
      "createdAt": "2026-10-17T15:04:00.000Z"
    }
  ],
  "meta": { "nextCursor": null, "hasMore": false }
}
```

`id` is the `Shortcut-Delivery-Id` your endpoint received, and `eventId` is the envelope `id`, so you can match a delivery to your own logs either way.

| `status`    | Meaning                                                                                                |
| ----------- | ------------------------------------------------------------------------------------------------------ |
| `pending`   | Waiting for its first attempt, or for a retry you asked for.                                           |
| `held`      | Waiting for a paused webhook to resume, or for the workspace's subscription.                           |
| `succeeded` | Your endpoint answered `2xx`; `deliveredAt` says when.                                                 |
| `failed`    | The last attempt failed; the next is due at `nextAttemptAt`.                                           |
| `exhausted` | Will not be attempted again: all 6 attempts failed, or the delivery ended for a reason in `lastError`. |

`lastError` says why the last attempt failed or why the delivery ended:

| `lastError`                            | Meaning                                                                                                                                                   |
| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| http\_ and a status, such as http\_500 | Your endpoint answered with that status, which is not `2xx`. Redirects show here as http\_301, http\_302, and so on.                                      |
| timeout                                | No complete answer within 15 seconds.                                                                                                                     |
| connect\_timeout                       | No connection within 5 seconds.                                                                                                                           |
| unresolvable                           | The URL's host name did not resolve in DNS within 2 seconds.                                                                                              |
| private\_address                       | The host name resolved to a private, local, or otherwise non-public address.                                                                              |
| response\_truncated                    | Your endpoint answered with a status that is not `2xx` and a body over 64 KiB.                                                                            |
| object\_not\_found                     | The record could no longer be read with `GET` when the event was sent, for example because it was deleted. The delivery ended.                            |
| event\_type\_unsubscribed              | The event type was removed from the webhook before the event was sent. The delivery ended.                                                                |
| subscription\_disabled                 | The webhook was turned off. The delivery ended.                                                                                                           |
| scope\_missing                         | The webhook's `requiredScopes` no longer cover the record's family, so it could not be serialized. The delivery ended.                                    |
| expired                                | The delivery was still not sent 3 days after its attempt was due (resuming a webhook or retrying a delivery starts that clock again). The delivery ended. |
| held\_expired                          | The delivery was held for 30 days. The delivery ended.                                                                                                    |
| secret\_unavailable                    | Shortcut could not sign the request. The delivery ended, and it does not count against your endpoint.                                                     |

Other values are short, lower-case network error codes, such as econnrefused.

Deliveries and their history are kept for 30 days, then deleted. Deleting a webhook deletes its deliveries with it. Company admins can see a webhook's recent deliveries, with the same status, response code, and response excerpt, and retry them, in **Settings → API & integrations**. A Shortcut support session can view them but cannot retry them or send test events.

## Retry a delivery

Once you have fixed whatever made a delivery fail, send it again:

```bash
curl https://api.shortcutco.com/v1/webhooks/7d2f4b8e-1a3c-4e5f-9b6d-0c8a2e4f6b1d/deliveries/0f3a5c7e-9b1d-4f2a-8c6e-1d3b5f7a9c2e/retry \
  -X POST \
  -H "Authorization: Bearer $SHORTCUT_TOKEN"
```

```json
{
  "data": {
    "id": "0f3a5c7e-9b1d-4f2a-8c6e-1d3b5f7a9c2e",
    "object": "webhook_delivery",
    "webhookId": "7d2f4b8e-1a3c-4e5f-9b6d-0c8a2e4f6b1d",
    "eventId": "evt_9b2e4c6a8d0f1e3b5a7c9d2f4e6b8a0c",
    "eventType": "invoice.paid",
    "status": "pending",
    "attempts": 2,
    "nextAttemptAt": "2026-10-17T15:08:41.000Z",
    "deliveredAt": null,
    "lastResponseStatus": 503,
    "lastError": null,
    "lastResponseExcerpt": "Service Unavailable",
    "createdAt": "2026-10-17T15:04:00.000Z"
  }
}
```

* Only `failed` and `exhausted` deliveries can be retried, only while the webhook is `active`, and not while an attempt of that delivery is being sent. Anything else is refused with [`409 delivery_not_retryable`](/developers/concepts/errors.md#delivery_not_retryable) or [`409 webhook_not_active`](/developers/concepts/errors.md#webhook_not_active).
* Test events cannot be retried; send another test event instead.
* The token needs the webhook's `requiredScopes` as well as `webhooks:manage`, because the record is serialized again.
* The delivery is sent within about a minute, with the record as it is then, the same event `id`, and a fresh signature.
* A retry is one more attempt. If it fails, the schedule carries on from where the delivery was; a delivery that had used all 6 attempts ends as `exhausted` again.

To resend everything a webhook missed, list its `exhausted` deliveries and retry each one, or read the changed records with [Sync](/developers/concepts/sync.md).
