> 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/overview.md).

# Webhooks

Get a signed HTTPS request the moment a customer, lead, estimate, job, visit, invoice, or payment changes in Shortcut, instead of polling.

After this page you can subscribe an endpoint of your own to changes in a Shortcut workspace, read what Shortcut sends, and manage the subscription from code.

A webhook is a subscription: an HTTPS URL and the event types it wants, such as `invoice.paid` or `visit.completed`. When one of those changes happens, Shortcut sends a signed `POST` to the URL with the record as `GET` would return it. Your integration hears about the change within about a minute, without polling.

## How it works

1. You create a webhook with a URL and its event types. Shortcut answers with a signing secret, once.
2. Shortcut collects changes every minute. For each change a webhook subscribes to, it sends one `POST` with a JSON event.
3. Your endpoint checks the signature, stores the event, and answers with any `2xx` status.
4. Anything else is a failed attempt, and Shortcut tries again on a schedule for about 15 hours. See [Retries and monitoring](/developers/webhooks/retries-and-monitoring.md).

The event types and their payloads are on [Events](/developers/webhooks/events.md). Checking the signature is on [Verify signatures](/developers/webhooks/verify-signatures.md).

## Create a webhook

### With the API

Creating a webhook needs a token with `webhooks:manage` and, for each event family you subscribe to, that family's read scope: `invoices:read` for `invoice.paid`, `payments:read` for `payment.recorded`. A `write` scope includes its `read` scope. The **Standard integration** preset has both. See [Scopes](/developers/concepts/scopes.md).

```bash
curl https://api.shortcutco.com/v1/webhooks \
  -X POST \
  -H "Authorization: Bearer $SHORTCUT_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: accounting-sync-webhook-1" \
  -d '{
    "url": "https://hooks.example.com/shortcut",
    "description": "Accounting sync: invoices and payments",
    "eventTypes": ["invoice.paid", "invoice.voided", "payment.recorded", "payment.reversed"]
  }'
```

```json
{
  "data": {
    "id": "7d2f4b8e-1a3c-4e5f-9b6d-0c8a2e4f6b1d",
    "object": "webhook",
    "url": "https://hooks.example.com/shortcut",
    "description": "Accounting sync: invoices and payments",
    "eventTypes": ["invoice.paid", "invoice.voided", "payment.recorded", "payment.reversed"],
    "requiredScopes": ["invoices:read", "payments:read"],
    "status": "active",
    "consecutiveFailures": 0,
    "disabledAt": null,
    "previousSecretExpiresAt": null,
    "createdVia": "api_token",
    "createdAt": "2026-10-17T15:00:00.000Z",
    "updatedAt": "2026-10-17T15:00:00.000Z",
    "secret": "whsec_docs-example-secret-000"
  }
}
```

The `secret` above is an example; yours is different. Store it where your receiver can read it, such as an environment variable. It appears in this response only: Shortcut never shows it again, and a replay of the request with the same `Idempotency-Key` returns the webhook without it. If you lose it, [rotate the secret](#rotate-the-signing-secret).

`description` is optional, up to 200 characters. `eventTypes` takes one or more of the 28 types on [Events](/developers/webhooks/events.md).

### In Settings

A company admin can also create and manage webhooks in **Settings → API & integrations**, on the web. A webhook created there can subscribe to any event type, because an admin can see everything; its read scopes are worked out the same way and stored on it. The Webhooks section lists every webhook in the workspace, whoever created it, and lets an admin create one (the signing secret is shown once), edit, pause, resume, or turn it back on, rotate its secret, send a test event, delete it, and see its latest 50 deliveries with their response codes and retry the ones that failed. When Shortcut support signs in to your workspace to help, it can view, pause, and delete webhooks, and nothing else: it cannot create or change one, see a secret, send a test event, or retry a delivery.

### Scopes decide what a webhook receives

Each event family has one read scope:

| Events       | Read scope       |
| ------------ | ---------------- |
| `customer.*` | `customers:read` |
| `lead.*`     | `leads:read`     |
| `estimate.*` | `estimates:read` |
| `job.*`      | `jobs:read`      |
| `visit.*`    | `visits:read`    |
| `invoice.*`  | `invoices:read`  |
| `payment.*`  | `payments:read`  |

When a webhook is created, or its event types change, Shortcut stores the read scopes of its families as `requiredScopes`. Every payload it sends is serialized under exactly those scopes, as a token with only them would see the record. A token without one of them is refused with `403 insufficient_scope`, and `requiredScope` names the one it lacks.

To change a webhook later, a token must hold the webhook's `requiredScopes` (after the change), so a token can never point events it could not read at a URL of its own. The exceptions are pausing and deleting, which need only `webhooks:manage`, so anyone who manages webhooks can always stop one.

### Webhooks belong to the workspace

A webhook is a workspace asset, like a token. It keeps working when the token that created it is revoked, and every token in the workspace with `webhooks:manage` can see, pause, and delete it. `createdVia` says whether it was created in Settings (`settings`) or with a token (`api_token`).

## What Shortcut sends

Each event is one `POST` with a JSON body:

```http
POST /shortcut HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: Shortcut-Webhooks/1.0
Shortcut-Event-Id: evt_9b2e4c6a8d0f1e3b5a7c9d2f4e6b8a0c
Shortcut-Event-Type: invoice.paid
Shortcut-Delivery-Id: 0f3a5c7e-9b1d-4f2a-8c6e-1d3b5f7a9c2e
Shortcut-Signature: t=1792249445,v1=5c0e8f2b6a4d1c9e7f3b5a2d8c6e4f1a3b9d7c5e2f8a6b4d1c3e9f7a5b2d8c6e
```

```jsonc
{
  "id": "evt_9b2e4c6a8d0f1e3b5a7c9d2f4e6b8a0c", // the event; the same on every retry and for every webhook
  "type": "invoice.paid",                     // the event type
  "createdAt": "2026-10-17T15:03:58.112Z",    // when Shortcut recorded the change, UTC
  "apiVersion": "v1",                         // the API version the object is serialized in
  "data": {
    "object": {                               // the record, exactly as GET /v1/invoices/{id} returns it
      "id": "1c3e5a7b-9d2f-4b6a-8e0c-2f4a6c8e0b1d",
      "object": "invoice",
      "status": "paid"
      // ...every other field of the invoice
    }
  },
  "changedFields": ["status"]                 // the public fields an update changed; [] otherwise
}
```

* **`data.object` is the record when the event is sent, not when it happened.** It is serialized by the same code as the resource's `GET`, so a parser for the API reads it unchanged. A visit that was deleted, or a payment that was reversed, no longer exists; for those two events the object is a short tombstone with `"deleted": true`. If the record cannot be read with `GET` when the event is sent, for example because it was deleted since, the event is not sent.
* **Events are signals, not a log.** Order is not guaranteed, and an object can be newer than its event. When the order of changes matters, compare `updatedAt`, or fetch the record.
* **Each event carries one record.** Fetch related records by id, such as the customer of a paid invoice, with the API.

Every type, when it fires, and a full example of each family is on [Events](/developers/webhooks/events.md).

### Headers

| Header                 | Value                                                                                                                                                                           |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`         | `application/json`                                                                                                                                                              |
| `User-Agent`           | `Shortcut-Webhooks/1.0`                                                                                                                                                         |
| `Shortcut-Event-Id`    | The envelope's `id`. The same for every retry of the event and for every webhook that receives it. Drop duplicates by it.                                                       |
| `Shortcut-Event-Type`  | The envelope's `type`, so you can route before parsing the body.                                                                                                                |
| `Shortcut-Delivery-Id` | This event's delivery to this webhook. The same on every retry; it matches the `id` in the [deliveries list](/developers/webhooks/retries-and-monitoring.md#see-what-was-sent). |
| `Shortcut-Signature`   | `t=<unix seconds>,v1=<hex HMAC-SHA256>`, with a second `v1` for 24 hours after a rotation. See [Verify signatures](/developers/webhooks/verify-signatures.md).                  |

## The signing secret

Every webhook has its own secret, `whsec_` followed by 43 characters. Shortcut signs each request with it, and your receiver checks the signature before it trusts the body. Treat the secret as a password: keep it out of source control, logs, and web pages.

### Rotate the signing secret

Rotate when the secret may have leaked, when someone who knew it leaves, or when you lost it:

```bash
curl https://api.shortcutco.com/v1/webhooks/7d2f4b8e-1a3c-4e5f-9b6d-0c8a2e4f6b1d/rotate-secret \
  -X POST \
  -H "Authorization: Bearer $SHORTCUT_TOKEN" \
  -H "Idempotency-Key: rotate-accounting-webhook-2026-10-17"
```

```json
{
  "data": {
    "id": "7d2f4b8e-1a3c-4e5f-9b6d-0c8a2e4f6b1d",
    "object": "webhook",
    "url": "https://hooks.example.com/shortcut",
    "description": "Accounting sync: invoices and payments",
    "eventTypes": ["invoice.paid", "invoice.voided", "payment.recorded", "payment.reversed"],
    "requiredScopes": ["invoices:read", "payments:read"],
    "status": "active",
    "consecutiveFailures": 0,
    "disabledAt": null,
    "previousSecretExpiresAt": "2026-10-18T16:30:00.000Z",
    "createdVia": "api_token",
    "createdAt": "2026-10-17T15:00:00.000Z",
    "updatedAt": "2026-10-17T16:30:00.000Z",
    "secret": "whsec_docs-example-secret-new"
  }
}
```

The new secret is returned once. For the next 24 hours, until `previousSecretExpiresAt`, every request carries two signatures, one per secret, new first, so a receiver that still has the old secret keeps verifying while you deploy the new one. After that only the new secret signs. Only the two most recent secrets ever sign: rotating again within the 24 hours retires the oldest one at once.

Rotating needs the webhook's `requiredScopes` as well as `webhooks:manage`.

## Statuses

| `status`   | What happens to new events                                                                                                           | How it gets there                                                                                                                                                                                                                       |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `active`   | Sent.                                                                                                                                | Created, resumed, or turned back on.                                                                                                                                                                                                    |
| `paused`   | Kept, not sent, for up to 30 days, together with anything still waiting when it was paused. Resuming sends them, about two a minute. | `PATCH` an `active` webhook with `"status": "paused"`, or in Settings.                                                                                                                                                                  |
| `disabled` | Dropped. Events that happen while a webhook is disabled are never sent.                                                              | Shortcut turns a webhook off after 50 failed attempts in a row, and emails and notifies the workspace's company admins. See [Retries and monitoring](/developers/webhooks/retries-and-monitoring.md#when-shortcut-turns-a-webhook-off). |

Set a `paused` or `disabled` webhook back to `active` with `PATCH`. Turning a disabled webhook back on starts its failure count over. A disabled webhook cannot be paused: `PATCH` with `"status": "paused"` is refused with `409 state_conflict`, so turn it back on first.

## Limits and URL rules

* **20 webhooks per workspace**, whatever their status. Creating a 21st is refused with `409 webhook_limit_reached`.
* **URLs are at most 2,048 characters**, and a `description` at most 200 after surrounding spaces are trimmed.
* **The URL must be `https` on the standard port, 443**, with no username or password in it.
* **The host name must resolve, and every address it resolves to must be public.** Private, loopback, link-local, carrier-grade NAT, multicast, documentation, reserved, and cloud-metadata ranges are refused, for IPv4 and IPv6. So `localhost`, `10.0.0.5`, and a name that points at either are refused.
* **The URL is checked again before every request**, and Shortcut connects to the exact address it checked. A URL that later starts resolving to a private address fails at delivery instead of reaching it.
* **Redirects are not followed.** Give the final URL.

A URL that breaks a rule is refused with `400 invalid_webhook_url` and `param` `body.url`; `message` says which rule. To receive events on your own computer while you build, see [Local development](/developers/webhooks/local-development.md).

## Manage webhooks with the API

Every operation needs `webhooks:manage`.

| Operation                                              | What it does                                                                                                                                                                                  |
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /v1/webhooks`                                     | Every webhook in the workspace, newest first. Never more than 20, so the list is not paged.                                                                                                   |
| `POST /v1/webhooks`                                    | Creates a webhook and returns its secret once. Needs the read scope of each family.                                                                                                           |
| `GET /v1/webhooks/{id}`                                | One webhook. The secret is never returned here.                                                                                                                                               |
| `PATCH /v1/webhooks/{id}`                              | Changes `url`, `description`, or `eventTypes`, or sets `status` to `paused` or `active`. Needs `requiredScopes` unless it only pauses.                                                        |
| `DELETE /v1/webhooks/{id}`                             | Deletes the webhook and its delivery history, and returns the deleted webhook. Nothing new is sent; a request already in progress at that moment (at most one per delivery) may still arrive. |
| `POST /v1/webhooks/{id}/rotate-secret`                 | Issues a new secret, returned once. Needs `requiredScopes`.                                                                                                                                   |
| `POST /v1/webhooks/{id}/test`                          | Sends a signed `webhook.test` event now and returns the delivery with your endpoint's answer.                                                                                                 |
| `GET /v1/webhooks/{id}/deliveries`                     | The webhook's deliveries, newest first, paged, with an optional `status` filter.                                                                                                              |
| `POST /v1/webhooks/{id}/deliveries/{deliveryId}/retry` | Sends a failed or exhausted delivery again. Needs `requiredScopes`.                                                                                                                           |

List the workspace's webhooks:

```bash
curl https://api.shortcutco.com/v1/webhooks \
  -H "Authorization: Bearer $SHORTCUT_TOKEN"
```

Pause one while you work on your endpoint, then resume it to send what it held:

```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": "paused" }'

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" }'
```

Change its event types. Send the whole new list; it replaces the old one, and `requiredScopes` follows it:

```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 '{ "eventTypes": ["invoice.paid", "invoice.overdue", "payment.recorded"] }'
```

Events already waiting for a type you removed are not sent.

Send a test event:

```bash
curl https://api.shortcutco.com/v1/webhooks/7d2f4b8e-1a3c-4e5f-9b6d-0c8a2e4f6b1d/test \
  -X POST \
  -H "Authorization: Bearer $SHORTCUT_TOKEN"
```

```json
{
  "data": {
    "id": "3a5c7e9b-1d2f-4a6c-8e0b-2d4f6a8c0e1b",
    "object": "webhook_delivery",
    "webhookId": "7d2f4b8e-1a3c-4e5f-9b6d-0c8a2e4f6b1d",
    "eventId": "evt_3f9a1c7e5b2d4f6a8c0e1b3d5f7a9c2e",
    "eventType": "webhook.test",
    "status": "succeeded",
    "attempts": 1,
    "nextAttemptAt": null,
    "deliveredAt": "2026-10-17T15:04:05.000Z",
    "lastResponseStatus": 200,
    "lastError": null,
    "lastResponseExcerpt": "ok",
    "createdAt": "2026-10-17T15:04:05.000Z"
  }
}
```

The test event carries no workspace data. It is sent once, whatever the webhook's status, is never retried, and does not count toward the failures that turn a webhook off, so you can check an endpoint before you resume or turn it back on.

Delete a webhook you no longer use:

```bash
curl https://api.shortcutco.com/v1/webhooks/7d2f4b8e-1a3c-4e5f-9b6d-0c8a2e4f6b1d \
  -X DELETE \
  -H "Authorization: Bearer $SHORTCUT_TOKEN"
```

Listing deliveries and retrying them is on [Retries and monitoring](/developers/webhooks/retries-and-monitoring.md#see-what-was-sent).

## Errors

The webhook operations answer with the API's usual [error body](/developers/concepts/errors.md#the-error-body). The codes you are most likely to meet:

| HTTP | `code`                                                                                      | When                                                                                                           |
| ---- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| 400  | [`invalid_webhook_url`](/developers/concepts/errors.md#invalid_webhook_url)                 | The URL is not `https` on port 443, has credentials in it, does not resolve, or resolves to a private address. |
| 400  | [`validation_failed`](/developers/concepts/errors.md#validation_failed)                     | An unknown event type, an empty `eventTypes`, or another field problem; `param` names it.                      |
| 403  | [`insufficient_scope`](/developers/concepts/errors.md#insufficient_scope)                   | The token lacks `webhooks:manage`, or a read scope the webhook's events need; `requiredScope` names it.        |
| 404  | [`resource_not_found`](/developers/concepts/errors.md#resource_not_found)                   | No webhook, or no delivery of that webhook, with that id in this workspace.                                    |
| 409  | [`webhook_limit_reached`](/developers/concepts/errors.md#webhook_limit_reached)             | The workspace already has 20 webhooks.                                                                         |
| 409  | [`delivery_not_retryable`](/developers/concepts/errors.md#delivery_not_retryable)           | The delivery is not failed or exhausted, is being sent right now, or is a test event.                          |
| 409  | [`webhook_not_active`](/developers/concepts/errors.md#webhook_not_active)                   | A retry on a paused or disabled webhook.                                                                       |
| 409  | [`state_conflict`](/developers/concepts/errors.md#state_conflict)                           | Pausing a disabled webhook; turn it back on first.                                                             |
| 503  | [`webhook_signing_unavailable`](/developers/concepts/errors.md#webhook_signing_unavailable) | Shortcut could not issue a signing secret. Nothing changed. Retry after `Retry-After`, which is an hour.       |

```json
{
  "error": {
    "type": "invalid_request",
    "code": "invalid_webhook_url",
    "message": "The URL must point to a public internet address, not a private, local, or cloud-internal one.",
    "param": "body.url",
    "requestId": "4b6d8f0a-2c4e-4a6b-8d0f-1a3c5e7b9d2f",
    "docs": "https://docs.shortcutco.com/developers/concepts/errors#invalid_webhook_url"
  }
}
```

## Next

* [Events](/developers/webhooks/events.md): every event type, when it fires, and example payloads.
* [Verify signatures](/developers/webhooks/verify-signatures.md): check `Shortcut-Signature` in Node, Python, Ruby, PHP, and Go.
* [Retries and monitoring](/developers/webhooks/retries-and-monitoring.md): what counts as delivered, the retry schedule, and the deliveries list.
* [Local development](/developers/webhooks/local-development.md): receive events on your own computer while you build.
