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

# Events

Every Shortcut webhook event type, the scope it needs, exactly when it fires, and example payloads for each family.

After this page you can choose the event types your integration needs, know exactly which change in Shortcut sends each one, and parse every payload.

Every event has the same envelope, described on [Webhooks](/developers/webhooks/overview.md#what-shortcut-sends). Its `data.object` is the record exactly as the resource's `GET` returns it, with the same fields as the [API reference](https://api.shortcutco.com/v1/openapi.json). Two events are about records that no longer exist, and carry a tombstone instead: [`visit.deleted`](#visitdeleted) and [`payment.reversed`](#paymentreversed).

## Families and scopes

There are 28 event types in 7 families. Subscribing to any event of a family needs the family's read scope.

| Family    | Read scope       | Events                                                                                                         |
| --------- | ---------------- | -------------------------------------------------------------------------------------------------------------- |
| Customers | `customers:read` | `customer.created`, `customer.updated`, `customer.deactivated`                                                 |
| Leads     | `leads:read`     | `lead.created`, `lead.updated`, `lead.dismissed`                                                               |
| Estimates | `estimates:read` | `estimate.sent`, `estimate.approved`, `estimate.declined`, `estimate.voided`                                   |
| Jobs      | `jobs:read`      | `job.created`, `job.updated`, `job.paused`, `job.resumed`, `job.canceled`                                      |
| Visits    | `visits:read`    | `visit.scheduled`, `visit.rescheduled`, `visit.assigned`, `visit.completed`, `visit.canceled`, `visit.deleted` |
| Invoices  | `invoices:read`  | `invoice.created`, `invoice.sent`, `invoice.paid`, `invoice.overdue`, `invoice.voided`                         |
| Payments  | `payments:read`  | `payment.recorded`, `payment.reversed`                                                                         |

A change made in the app, the crew app, the customer portal, an automation, or the API sends its events. An import (from a spreadsheet or another system) sends none, however many records it creates or changes: after an import, read what changed with `updatedSince`; see [Sync](/developers/concepts/sync.md).

## Customers

| Event                  | Fires when                                                                                                                                                                                                                                                                                                                                                                                          |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `customer.created`     | A customer is created.                                                                                                                                                                                                                                                                                                                                                                              |
| `customer.updated`     | The customer's name, company, email, phone, or status changes (reactivating included); or one of their properties is added, changed, or removed; or a tag is added to or removed from them; or one of their custom-field values changes. It also fires, with an empty `changedFields`, when a setting that is not part of the customer object changes, such as their autopay or invoicing settings. |
| `customer.deactivated` | The customer is deactivated.                                                                                                                                                                                                                                                                                                                                                                        |

A person becomes a customer in the API once they have a job, or a lead of theirs reaches the `customer` stage: the same rule as `GET /v1/customers/{id}`. An event is sent only when its record can be read that way at the moment it is sent, so `customer.created` for someone who is still only a lead is not sent. To hear when a lead becomes a customer, subscribe to `lead.updated` (its `lifecycleStage` changes to `customer`) or `job.created`.

## Leads

| Event            | Fires when                                                                                                                                                                                              |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `lead.created`   | A lead is created, from your website form, the app, or `POST /v1/leads`. Imported leads send no event.                                                                                                  |
| `lead.updated`   | Any of the lead's name, company, email, phone, address, square footage, services requested, special requests, lifecycle stage, or linked customer or property changes, or a dismissed lead is restored. |
| `lead.dismissed` | The lead is dismissed.                                                                                                                                                                                  |

## Estimates

| Event               | Fires when                                                                                                |
| ------------------- | --------------------------------------------------------------------------------------------------------- |
| `estimate.sent`     | The estimate is sent to the customer for the first time. Sending it again, or a reminder, sends no event. |
| `estimate.approved` | The estimate is approved, by the customer or on their behalf.                                             |
| `estimate.declined` | The estimate is declined.                                                                                 |
| `estimate.voided`   | The estimate is voided.                                                                                   |

## Jobs

| Event          | Fires when                                                                                                                                                                                                                                                          |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `job.created`  | A job is created.                                                                                                                                                                                                                                                   |
| `job.updated`  | A job is edited from a date on, such as a price change. Shortcut keeps the job's history by ending the old version on that date and starting a new version from it. The event is about the new version, whose `previousVersionJobId` names the version it replaces. |
| `job.paused`   | The job is paused.                                                                                                                                                                                                                                                  |
| `job.resumed`  | A paused job is resumed.                                                                                                                                                                                                                                            |
| `job.canceled` | The job is canceled.                                                                                                                                                                                                                                                |

When a job's crew changes, the visits that follow the job's crew get a new crew, so each of its open visits that does sends `visit.assigned`. The job itself sends no event for it.

## Visits

| Event               | Fires when                                                                                                                                                                                                                                                                                        |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `visit.scheduled`   | A visit is put on the schedule: created as `scheduled` or `rescheduled` (a visit created as `rescheduled` sends `visit.scheduled`, not `visit.rescheduled`), or its status becomes `scheduled` (other than from `rescheduled`). A visit created as `pending` sends nothing until it is scheduled. |
| `visit.rescheduled` | An open visit (not completed, canceled, or skipped) moves to another date, or its status becomes `rescheduled`.                                                                                                                                                                                   |
| `visit.assigned`    | The crew that will do the visit changes: the visit is assigned to another crew, its assignment is cleared, or the job's crew changes while the visit follows it. Assigning a visit to the crew it already had sends nothing.                                                                      |
| `visit.completed`   | The visit is completed.                                                                                                                                                                                                                                                                           |
| `visit.canceled`    | The visit is canceled.                                                                                                                                                                                                                                                                            |
| `visit.deleted`     | An open visit (not completed or canceled) is removed from the schedule, as when a recurring series is re-planned. The payload is a tombstone.                                                                                                                                                     |

A visit whose status becomes `skipped` or `needs_reschedule` sends no event. The crew fields are explained in [Visits and crews](/developers/concepts/glossary.md#visits-and-crews).

## Invoices

| Event             | Fires when                                                                                                                                                      |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `invoice.created` | An invoice is created, as a draft or already sent.                                                                                                              |
| `invoice.sent`    | The invoice is sent to the customer for the first time. Reminders, and a reversed payment that makes a paid invoice unpaid again, send no event.                |
| `invoice.paid`    | The invoice is paid in full.                                                                                                                                    |
| `invoice.overdue` | A sent, unpaid invoice's `dueAt` passes. `dueAt` is midnight UTC at the start of the printed `dueDate`, which in US time zones is the evening before that date. |
| `invoice.voided`  | The invoice is voided.                                                                                                                                          |

`invoice.overdue` is not the result of an edit: Shortcut checks for invoices that have just become overdue every hour, at 25 minutes past the hour UTC, so the event can arrive up to about an hour after `dueAt`. Invoices still in import review are not checked. It is sent once for each invoice and due date; if the due date moves and passes again, it is sent again. Its `createdAt` is when Shortcut found the invoice overdue. Subscribing does not replay old ones: only due dates that pass on or after the day the oldest of the workspace's active or paused webhooks subscribed to `invoice.overdue` was created, and within the last three days, are sent. List older overdue invoices with `GET /v1/invoices?status=overdue`.

## Payments

| Event              | Fires when                                                                                            |
| ------------------ | ----------------------------------------------------------------------------------------------------- |
| `payment.recorded` | A payment is recorded against an invoice: paid online, or recorded as cash, check, or another method. |
| `payment.reversed` | A recorded payment is reversed (undone) and no longer exists. The payload is a tombstone.             |

## Changed fields

On an event caused by an update, `changedFields` lists the public fields that changed, sorted. Events caused by a record being created or deleted, and `invoice.overdue`, have an empty list. The exception is `customer.updated` for an added or removed property, tag, or custom-field value, which lists `"properties"`, `"tags"`, or `"customFields"`. Two events from one change, such as `customer.deactivated` and `customer.updated` when an edit deactivates a customer and changes their email, share one list.

| Family    | Names that can appear                                                                                                                                                                                                            |
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Customers | `firstName`, `lastName`, `companyName`, `email`, `phone`, `status`; `"properties"` when a property was added, changed, or removed; `"tags"` when a tag was added or removed; `"customFields"` when a custom-field value changed. |
| Leads     | `firstName`, `lastName`, `companyName`, `email`, `phone`, `address`, `sqFootage`, `servicesRequested`, `specialRequests`, `status`, `lifecycleStage`, `customerId`, `propertyId`.                                                |
| Estimates | `status`.                                                                                                                                                                                                                        |
| Jobs      | `status`, `effectiveUntilDate`, `teamId`.                                                                                                                                                                                        |
| Visits    | `status`, `date`, `assignedTeamId`, `effectiveTeamId`. A change of `assignedTeamId` always lists `effectiveTeamId` too.                                                                                                          |
| Invoices  | `status`.                                                                                                                                                                                                                        |
| Payments  | Always empty.                                                                                                                                                                                                                    |

`changedFields` says what changed, not what it was before. Keep your own copy if you need the old value.

## Example payloads

Ids, names, and amounts below are examples.

### customer.updated

The customer's lawn was measured, so a property changed:

```json
{
  "id": "evt_4a6c8e0b2d4f6a8c0e1b3d5f7a9c1e3b",
  "type": "customer.updated",
  "createdAt": "2026-10-17T14:22:31.507Z",
  "apiVersion": "v1",
  "data": {
    "object": {
      "id": "2b4d6f8a-0c1e-4a3b-9d5f-7e9a1c3b5d70",
      "object": "customer",
      "firstName": "Robin",
      "lastName": "Alvarez",
      "companyName": null,
      "email": "robin.alvarez@example.com",
      "phone": "(406) 555-0142",
      "status": "active",
      "tags": [{ "id": "9a1c3e5b-7d9f-4e2a-b4c6-8d0e2f4a6b83", "name": "Weekly" }],
      "externalReferences": [{ "source": "crm", "externalId": "C-10442" }],
      "createdAt": "2026-03-02T16:10:00.000Z",
      "updatedAt": "2026-09-30T18:45:12.000Z",
      "properties": [
        {
          "id": "6e8a0c2b-4d6f-4b1a-8c3e-5f7a9b1d3e52",
          "object": "property",
          "label": "Home",
          "address": "412 Elm St, Bozeman, MT 59715",
          "latitude": 45.6797,
          "longitude": -111.0386,
          "squareFeet": 8200,
          "status": "active",
          "createdAt": "2026-03-02T16:10:00.000Z",
          "updatedAt": "2026-10-17T14:22:31.000Z"
        }
      ]
    }
  },
  "changedFields": ["properties"]
}
```

A tag or custom-field change moves the customer's own `updatedAt`. A property change does not: the property's `updatedAt` shows when it changed.

### lead.created

```json
{
  "id": "evt_7c9e1b3d5f7a9c2e4b6d8f0a1c3e5b7d",
  "type": "lead.created",
  "createdAt": "2026-10-17T13:05:44.218Z",
  "apiVersion": "v1",
  "data": {
    "object": {
      "id": "4f6a8c0e-2b4d-4f6a-9c1e-3b5d7f9a1c24",
      "object": "lead",
      "firstName": "Jamie",
      "lastName": "Chen",
      "companyName": null,
      "email": "jamie.chen@example.com",
      "phone": "+14065550198",
      "address": "88 Willow Way, Bozeman, MT 59718",
      "servicesRequested": ["Aeration"],
      "specialRequests": "Gate code is 1234.",
      "estimatedHours": null,
      "sqFootage": null,
      "status": "pending",
      "lifecycleStage": "lead",
      "source": "intake_form",
      "customerId": null,
      "propertyId": null,
      "externalReferences": [],
      "createdAt": "2026-10-17T13:05:44.000Z",
      "updatedAt": "2026-10-17T13:05:44.000Z"
    }
  },
  "changedFields": []
}
```

### estimate.approved

```json
{
  "id": "evt_1d3f5b7a9c0e2a4c6e8b0d2f4a6c8e1b",
  "type": "estimate.approved",
  "createdAt": "2026-10-17T16:41:09.733Z",
  "apiVersion": "v1",
  "data": {
    "object": {
      "id": "8c0e2a4b-6d8f-4a1c-9e3b-5d7f9a1c3e46",
      "object": "estimate",
      "status": "approved",
      "customerId": "2b4d6f8a-0c1e-4a3b-9d5f-7e9a1c3b5d70",
      "leadId": null,
      "propertyId": "6e8a0c2b-4d6f-4b1a-8c3e-5f7a9b1d3e52",
      "propertyLabel": "Home",
      "propertyAddress": "412 Elm St, Bozeman, MT 59715",
      "message": "Thanks for choosing us for the season.",
      "total": 45,
      "sentAt": "2026-10-15T17:00:00.000Z",
      "expiresAt": "2026-11-14T17:00:00.000Z",
      "expiredAt": null,
      "approvedAt": "2026-10-17T16:41:09.000Z",
      "declinedAt": null,
      "declineReason": null,
      "createdAt": "2026-10-15T16:52:00.000Z",
      "updatedAt": "2026-10-17T16:41:09.000Z",
      "lines": [
        {
          "id": "1e3a5c7b-9d0f-4b2a-8c4e-6f8a0b2c4d68",
          "serviceId": "5b7d9f1a-3c5e-4d7f-8a0b-2c4e6a8b0d13",
          "name": "Weekly mowing",
          "description": null,
          "priceStructure": "flat",
          "rate": 45,
          "quantity": null,
          "subtotal": 45,
          "cadence": "recurring",
          "cadenceFrequency": 1,
          "cadencePeriod": "per_week",
          "choiceGroupId": null,
          "isOptional": false,
          "isRemovedByClient": false,
          "selectedOptionId": null,
          "sortOrder": 0,
          "options": [],
          "products": []
        }
      ]
    }
  },
  "changedFields": ["status"]
}
```

### job.updated

The weekly mowing price changed from November 2. The object is the new version; `previousVersionJobId` is the version that ended:

```json
{
  "id": "evt_8e0a2c4e6b8d0f1a3c5e7b9d1f3a5c7e",
  "type": "job.updated",
  "createdAt": "2026-10-17T17:12:45.090Z",
  "apiVersion": "v1",
  "data": {
    "object": {
      "id": "0d2f4a6c-8e1b-4c3d-9f5a-7b9c1d3e5f80",
      "object": "job",
      "name": "Weekly mowing",
      "description": null,
      "status": "scheduled",
      "customerId": "2b4d6f8a-0c1e-4a3b-9d5f-7e9a1c3b5d70",
      "propertyId": "6e8a0c2b-4d6f-4b1a-8c3e-5f7a9b1d3e52",
      "address": "412 Elm St, Bozeman, MT 59715",
      "serviceId": "5b7d9f1a-3c5e-4d7f-8a0b-2c4e6a8b0d13",
      "estimateId": "8c0e2a4b-6d8f-4a1c-9e3b-5d7f9a1c3e46",
      "cadence": "recurring",
      "cadenceFrequency": 1,
      "cadencePeriod": "per_week",
      "priceStructure": "flat",
      "rate": 50,
      "quantity": null,
      "subtotal": 50,
      "teamId": "a7c9e1b3-5d7f-4a9b-8c1d-3e5f7a9c1b24",
      "effectiveTeamId": "a7c9e1b3-5d7f-4a9b-8c1d-3e5f7a9c1b24",
      "nextVisitId": "b2d4f6a8-0c2e-4b4d-9f6a-8c0e2b4d6f19",
      "nextVisitDate": "2026-11-02",
      "pausedUntil": null,
      "pauseReason": null,
      "previousVersionJobId": "3e5a7c9b-1d3f-4e5a-8b7c-9d1f3a5c7e91",
      "effectiveUntilDate": null,
      "externalReferences": [],
      "createdAt": "2026-10-17T17:12:45.000Z",
      "updatedAt": "2026-10-17T17:12:45.000Z"
    }
  },
  "changedFields": []
}
```

A new version is a new record, so `changedFields` is empty. Compare it with the version it replaces, which you can still read with `GET /v1/jobs/{id}`: its `effectiveUntilDate` is the last day it covers. If you keep one row per job, move your row to the new id.

### visit.rescheduled

```json
{
  "id": "evt_2f4b6d8a0c1e3a5c7e9b1d3f5a7c9e2b",
  "type": "visit.rescheduled",
  "createdAt": "2026-10-17T07:48:02.311Z",
  "apiVersion": "v1",
  "data": {
    "object": {
      "id": "5e0c8a1f-2b7d-4c3e-9f6a-8d1b2c3e4f50",
      "object": "visit",
      "date": "2026-10-20",
      "status": "rescheduled",
      "closeReason": null,
      "jobId": "0d2f4a6c-8e1b-4c3d-9f5a-7b9c1d3e5f80",
      "jobName": "Weekly mowing",
      "customerId": "2b4d6f8a-0c1e-4a3b-9d5f-7e9a1c3b5d70",
      "propertyId": "6e8a0c2b-4d6f-4b1a-8c3e-5f7a9b1d3e52",
      "address": "412 Elm St, Bozeman, MT 59715",
      "assignedTeamId": null,
      "effectiveTeamId": "a7c9e1b3-5d7f-4a9b-8c1d-3e5f7a9c1b24",
      "routePosition": null,
      "plannedStartAt": null,
      "plannedEndAt": null,
      "estimatedDurationMinutes": 30,
      "arrivedAt": null,
      "completedAt": null,
      "invoiceId": null,
      "createdAt": "2026-09-01T12:00:00.000Z",
      "updatedAt": "2026-10-17T07:48:02.000Z"
    }
  },
  "changedFields": ["date", "status"]
}
```

### visit.deleted

The visit no longer exists, so the object is a tombstone of what it was:

```json
{
  "id": "evt_6b8d0f2a4c6e8a1c3e5b7d9f2a4c6e8b",
  "type": "visit.deleted",
  "createdAt": "2026-10-17T18:03:27.645Z",
  "apiVersion": "v1",
  "data": {
    "object": {
      "id": "9f1b3d5e-7a9c-4b1d-8e3f-5a7c9e1b3d68",
      "object": "visit",
      "deleted": true,
      "date": "2026-11-09",
      "status": "scheduled",
      "jobId": "3e5a7c9b-1d3f-4e5a-8b7c-9d1f3a5c7e91",
      "customerId": "2b4d6f8a-0c1e-4a3b-9d5f-7e9a1c3b5d70",
      "assignedTeamId": null,
      "effectiveTeamId": "a7c9e1b3-5d7f-4a9b-8c1d-3e5f7a9c1b24"
    }
  },
  "changedFields": []
}
```

| Field                               | Meaning                                                                                                                                      |
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`, `object`                      | The deleted visit.                                                                                                                           |
| `deleted`                           | Always `true`. Only tombstones have it.                                                                                                      |
| `date`, `status`                    | The visit's date and status when it was deleted.                                                                                             |
| `jobId`, `customerId`               | The job and customer it belonged to. `customerId` is `null` when the visit was deleted together with its job.                                |
| `assignedTeamId`, `effectiveTeamId` | Its crew assignment, as on a visit. `effectiveTeamId` is `null` when the visit was deleted together with its job and had no crew of its own. |

`GET /v1/visits/{id}` answers `404` for it. Remove it from your copy.

### invoice.paid

```json
{
  "id": "evt_9b2e4c6a8d0f1e3b5a7c9d2f4e6b8a0c",
  "type": "invoice.paid",
  "createdAt": "2026-10-17T15:03:58.112Z",
  "apiVersion": "v1",
  "data": {
    "object": {
      "id": "1c3e5a7b-9d2f-4b6a-8e0c-2f4a6c8e0b1d",
      "object": "invoice",
      "number": 1042,
      "kind": "standard",
      "status": "paid",
      "paymentActivity": null,
      "customerId": "2b4d6f8a-0c1e-4a3b-9d5f-7e9a1c3b5d70",
      "propertyId": "6e8a0c2b-4d6f-4b1a-8c3e-5f7a9b1d3e52",
      "propertyAddress": "412 Elm St, Bozeman, MT 59715",
      "propertyLabel": "Home",
      "subtotal": 45,
      "salesTax": 0,
      "processingFee": 0,
      "processingFeeScope": "all_payments",
      "cardProcessingFee": 0,
      "lateFees": 0,
      "total": 45,
      "dueDate": "2026-10-31",
      "dueAt": "2026-10-31T00:00:00.000Z",
      "coverageStartDate": null,
      "coverageEndDate": null,
      "sentAt": "2026-10-16T22:00:00.000Z",
      "paidAt": "2026-10-17T15:03:58.000Z",
      "createdAt": "2026-10-16T21:58:00.000Z",
      "updatedAt": "2026-10-17T15:03:58.000Z",
      "lines": [
        {
          "id": "2d4f6b8a-0c1e-4d3f-9a5b-7c9e1f3a5b70",
          "type": "service",
          "name": "Weekly mowing",
          "description": null,
          "date": "2026-10-16",
          "amount": 45,
          "taxable": false,
          "quantity": null,
          "unitPrice": null,
          "unitLabel": null,
          "visitId": "c3e5a7b9-1d3f-4c5e-8a7b-9d1f3c5e7a02",
          "serviceId": "5b7d9f1a-3c5e-4d7f-8a0b-2c4e6a8b0d13"
        }
      ]
    }
  },
  "changedFields": ["status"]
}
```

`invoice.overdue` has the same object, with `status` `overdue`, and an empty `changedFields`. Because the overdue status is worked out from `dueAt` rather than stored by an edit, it does not move the invoice's `updatedAt`.

### payment.recorded

```json
{
  "id": "evt_0c2e4a6c8e1b3d5f7a9c2e4b6d8f1a3c",
  "type": "payment.recorded",
  "createdAt": "2026-10-17T15:03:58.112Z",
  "apiVersion": "v1",
  "data": {
    "object": {
      "id": "6a8c0e2d-4f6b-4a8c-9e0d-2b4f6a8c0e13",
      "object": "payment",
      "invoiceId": "1c3e5a7b-9d2f-4b6a-8e0c-2f4a6c8e0b1d",
      "customerId": "2b4d6f8a-0c1e-4a3b-9d5f-7e9a1c3b5d70",
      "amount": 45,
      "method": "online",
      "reference": null,
      "refundedAmount": 0,
      "paidAt": "2026-10-17T15:03:58.000Z",
      "createdAt": "2026-10-17T15:03:58.000Z"
    }
  },
  "changedFields": []
}
```

### payment.reversed

A recorded check was reversed, so the payment no longer exists. The object is a tombstone of what it was:

```json
{
  "id": "evt_3d5f7b9a1c2e4a6c8e0b2d4f6a8c1e3d",
  "type": "payment.reversed",
  "createdAt": "2026-10-17T19:20:11.402Z",
  "apiVersion": "v1",
  "data": {
    "object": {
      "id": "7b9d1f3a-5c7e-4b9d-8f1a-3c5e7b9d1f24",
      "object": "payment",
      "deleted": true,
      "invoiceId": "1c3e5a7b-9d2f-4b6a-8e0c-2f4a6c8e0b1d",
      "customerId": "2b4d6f8a-0c1e-4a3b-9d5f-7e9a1c3b5d70",
      "amount": 45,
      "method": "check",
      "paidAt": "2026-10-14T00:00:00.000Z"
    }
  },
  "changedFields": []
}
```

| Field                        | Meaning                                          |
| ---------------------------- | ------------------------------------------------ |
| `id`, `object`               | The reversed payment.                            |
| `deleted`                    | Always `true`.                                   |
| `invoiceId`, `customerId`    | The invoice it paid and that invoice's customer. |
| `amount`, `method`, `paidAt` | As on the payment before it was reversed.        |

The invoice's balance changes with it; read the invoice again with `GET /v1/invoices/{id}`.

### webhook.test

`POST /v1/webhooks/{id}/test` sends this. It is signed like every event, carries no workspace data, and is never sent for anything else:

```json
{
  "id": "evt_3f9a1c7e5b2d4f6a8c0e1b3d5f7a9c2e",
  "type": "webhook.test",
  "createdAt": "2026-10-17T15:04:05.000Z",
  "apiVersion": "v1",
  "data": {
    "object": {
      "object": "webhook_test",
      "webhookId": "7d2f4b8e-1a3c-4e5f-9b6d-0c8a2e4f6b1d",
      "message": "A test event from Shortcut. If you can read this, your endpoint receives and can verify webhooks."
    }
  },
  "changedFields": []
}
```

Answer it with a `2xx` and ignore it otherwise.

## Handle events

Route each event by `type`, and treat the object as the latest you have seen only if it is newer than your copy:

```js
async function handleEvent(event) {
  if (event.type === "webhook.test") return;

  const object = event.data.object;
  if (object.deleted) {
    // visit.deleted and payment.reversed: the record is gone.
    await removeFromCopy(object.object, object.id);
    return;
  }

  if (event.type === "job.updated") {
    // The edited job continues under a new id.
    await replaceJob(object.previousVersionJobId, object);
    return;
  }

  const changedFields = new Set(event.changedFields);
  if (event.type === "customer.updated" && changedFields.has("customFields")) {
    await refreshCustomFields(object.id);
  }

  // Events can arrive out of order: keep the newer copy. Records without
  // updatedAt (payments) use createdAt; equal timestamps still apply, since
  // the copy in the event is at least as new as the one you hold.
  await upsertIfNotOlder(object.object, object, object.updatedAt ?? object.createdAt);
}
```

Receive, verify, and acknowledge first, then run this from a queue. See [Retries and monitoring](/developers/webhooks/retries-and-monitoring.md#build-a-receiver-that-keeps-up).
