[← API v2 Overview](https://motordesk.com/api-docs/v2/)

# Webhooks

Subscribe to events and receive a signed HTTP POST the moment something changes, instead of polling. A subscription belongs to your business and is managed entirely through the API.

## How It Works

1. Create a subscription with `POST /webhooks`, giving an HTTPS endpoint and the events you want. The signing secret is returned once, in that response.
2. When a matching event occurs, MotorDesk queues a delivery and sends it to your endpoint as a JSON `POST`, signed with your secret.
3. Your endpoint verifies the signature and responds with any `2xx` status to acknowledge receipt. A non-2xx response, a timeout, or a connection error is treated as a failed delivery and retried.

Delivery is near-real-time (typically a few seconds behind the event) and runs out of band, so a slow or failing endpoint never affects the dashboard or the API write that produced the event.

## Events

Event names are `resource.action`, for example `vehicle.sold`, `lead.created`, `invoice.paid`. Subscribe to specific events, or to `"*"` for all current and future events. The live catalogue (with the read scope each event requires) is available at [GET /reference/webhook-events](https://motordesk.com/api-docs/v2/reference/).

| Group | Events | Scope |
| --- | --- | --- |
| Vehicles | `vehicle.created`, `vehicle.updated`, `vehicle.reserved`, `vehicle.sold`, `vehicle.deleted` | `vehicles:read` |
| Contacts | `contact.created`, `contact.updated`, `contact.deleted` | `contacts:read` |
| Leads | `lead.created`, `lead.updated`, `lead.deleted` | `leads:read` |
| Appointments | `appointment.created`, `appointment.updated`, `appointment.cancelled` | `appointments:read` |
| Invoices | `invoice.created`, `invoice.issued`, `invoice.paid`, `invoice.cancelled`, `invoice.credited` | `invoices:read` |
| Orders | `order.created`, `order.issued`, `order.cancelled`, `order.converted` | `orders:read` |
| Purchases | `purchase.created`, `purchase.issued`, `purchase.paid`, `purchase.cancelled`, `purchase.credited` | `purchases:read` |
| Deals | `deal.created`, `deal.updated`, `deal.stage_changed` | `deals:read` |
| Comms / Content | `call.created`, `review.created`, `review.updated`, `blog.published` | `calls:read`, `reviews:read`, `blogs:read` |

A subscription may only receive an event if its scope set includes that event's read scope (see [Scopes and Payloads](https://motordesk.com/api-docs/v2/webhooks/#scopes-and-payloads)).

## Payload

Each delivery is a JSON envelope. `data` is the same object you would get from that resource's `GET` endpoint, projected to the subscription's scopes. Deletion-style events (`*.deleted`, `appointment.cancelled`) carry only the id, because the record is gone or archived.

```
POST https://example.com/motordesk/webhook
Content-Type: application/json
X-MotorDesk-Signature: t=1781560330,v1=4f9c2b...e1
X-MotorDesk-Event: vehicle.sold
X-MotorDesk-Event-Id: evt_9b1d4c7a8f2e4a1b9c3d5e6f7a8b9c0d
X-MotorDesk-Delivery: 4812

{
  "id": "evt_9b1d4c7a8f2e4a1b9c3d5e6f7a8b9c0d",
  "event": "vehicle.sold",
  "api_version": "2.0",
  "created": 1781560330,
  "business": {
    "id": 576,
    "tag": "ABC123"
  },
  "data": {
    "id": 85574,
    "...": "the vehicle resource, as returned by GET /vehicles/{id}"
  }
}
```

The `business` object identifies which dealership the event belongs to. It is part of the signed body, so a single endpoint serving many dealers can route on it safely - no need for a per-business URL or to test each secret. The request headers carry the event name, the event id (also in the body), and the delivery id, so a receiver can route and log without parsing the body first.

## Verifying Signatures

Every delivery is signed with your subscription secret so you can confirm it genuinely came from MotorDesk and was not tampered with or replayed. The `X-MotorDesk-Signature` header is Stripe-style:

```
X-MotorDesk-Signature: t=1781560330,v1=4f9c2b...e1
```

`t` is the Unix timestamp when the delivery was signed, and `v1` is the HMAC-SHA256, as lowercase hex, of the string `"{t}.{raw_body}"` keyed with your secret. To verify:

1. Read the **raw request body** exactly as received, before any JSON parsing or re-encoding.
2. Split the header on commas to read `t` and `v1`.
3. Compute `HMAC-SHA256(secret, t + "." + raw_body)` and compare it to `v1` using a constant-time comparison.
4. Reject the request if `t` is outside a tolerance window (for example five minutes) of your current time, to prevent replay.

```
// PHP
$secret    = 'whsec_...';
$payload   = file_get_contents('php://input'); // the raw body
$header    = $_SERVER['HTTP_X_MOTORDESK_SIGNATURE'];
parse_str(strtr($header, ',', '&'), $parts);   // t=..., v1=...
$expected  = hash_hmac('sha256', $parts['t'].'.'.$payload, $secret);
$valid     = (abs(time() - (int) $parts['t']) <= 300) && hash_equals($expected, $parts['v1']);
http_response_code($valid ? 200 : 400);
```

Verify the signature against the raw bytes *before* parsing the JSON, and only then trust the body.

## Delivery, Retries and Failures

Deliveries are attempted with a 10-second timeout. Any `2xx` response marks the delivery delivered; anything else (including a timeout or connection error) is a failed attempt and is retried with exponential backoff, up to roughly nine attempts spread over about 24 hours:

Retry schedule:

*first attempt, then after about 1m, 5m, 15m, 1h, 3h, 6h, 12h and 24h. After the final attempt the delivery is marked failed.*

If a subscription accumulates sustained consecutive failures it is automatically disabled and the business is emailed; the failure counter resets to zero on any successful delivery. Re-enable a disabled subscription with `PATCH /webhooks/{id}` and `{"status":"active"}`, which clears the counter and resumes queued deliveries. Inspect recent attempts (status, response code, timing) with `GET /webhooks/{id}/deliveries`, and re-send a past delivery with `POST /webhooks/{id}/deliveries/{delivery_id}/redeliver`.

## Ordering and Idempotency

Delivery is **at-least-once**: an endpoint may occasionally receive the same event more than once (for example when it acknowledges late, or a delivery is re-sent). Order is **not guaranteed**; a retried event can arrive after a newer one.

- **Dedupe** on the event id (`id` in the body, or the `X-MotorDesk-Event-Id` header). Re-deliveries of the same event keep the same id.
- **Order** by the `created` timestamp, or re-fetch the resource by id to get its current state, rather than assuming the payload is the latest.
- **Respond quickly** (within the 10-second timeout). Acknowledge with a `2xx` and do slow work asynchronously.

**Coalescing.** A rapid burst of changes to the same record (for example uploading several photos in quick succession) is collapsed into a single `*.updated` delivery carrying the latest state, sent a few seconds after the activity settles - so you receive one event, not one per change. Discrete events (`*.created`, `*.sold`, payments, and similar) are never coalesced and are always delivered individually.

## Scopes and Payloads

A subscription stores its own scope set, chosen at creation and limited to the scopes held by the API key that created it. That scope set does two things: it gates which events the subscription may receive, and it projects every payload. A subscription with only `vehicles:read`, for instance, receives the base vehicle object but never the pricing, cost or funding fields that `vehicle-pricing:read` unlocks. When `scopes` is omitted on create, it defaults to the minimal read scopes the chosen events require.

## Security

- Endpoint URLs must use **HTTPS**.
- The signing **secret is shown once**, in the create (and rotate) response. Store it securely; it cannot be retrieved later. Rotate it with `POST /webhooks/{id}/secret/rotate`.
- Always **verify the signature** and enforce the timestamp tolerance before acting on a payload.
- Payloads are **scope-gated** per subscription, so a subscription never carries data beyond its own scopes.

## Managing Subscriptions

Subscriptions are managed entirely through the API. Managing them needs `webhooks:read` or `webhooks:write`. Send a test ping at any time with `POST /webhooks/{id}/ping` to exercise your endpoint and signature handling. The subscription object and the full endpoint list follow.

## The Webhook Object

Generated from the OpenAPI schema. Always matches the live API.

**Fields (13)**

| Attribute | Type | Required | Description | Example |
| --- | --- | --- | --- | --- |
| `id` | integer | - | Subscription id. |  |
| `url` | string | - | The HTTPS endpoint deliveries are POSTed to. |  |
| `description` | string | - | Optional label for the subscription. |  |
| `events` | string | - | The subscribed events: the string "*" (all) or an array of event names. |  |
| `scopes` | array of string | - | The scope snapshot used to project each delivery payload (a subset of the creating key's scopes). |  |
| `status` | enum | - | active subscriptions receive deliveries; auto-disabled after sustained failures. |  |
| `secret_hint` | string | - | The last 4 characters of the signing secret, for identification. The full secret is only returned once, on create. |  |
| `secret` | string | - | The signing secret, returned in cleartext ONLY in the create response. Store it securely; it cannot be retrieved again. |  |
| `failure_count` | integer | - | Consecutive terminal delivery failures; reset to 0 on any success. |  |
| `last_status` | enum or null | - | Outcome of the most recent delivery attempt, or null. |  |
| `last_delivery_at` | integer or null | - | Unix timestamp of the most recent delivery attempt, or null. |  |
| `created` | integer | - | Unix timestamp the subscription was created. |  |
| `updated` | integer | - | Unix timestamp the subscription was last updated. |  |

**Example object**

```json
{
    "id": 0,
    "url": "",
    "description": "",
    "events": "",
    "scopes": [
        ""
    ],
    "status": "active",
    "secret_hint": "",
    "secret": "",
    "failure_count": 0,
    "last_status": "delivered",
    "last_delivery_at": 0,
    "created": 0,
    "updated": 0
}
```

## Endpoints

### GET List Webhooks

`GET /2.0/webhooks` · scope `webhooks:read`

List the business's webhook subscriptions.

**Parameters (2)**

| Parameter | In | Type | Required | Description | Example |
| --- | --- | --- | --- | --- | --- |
| `cursor` | query | string | - | Keyset pagination cursor from a previous response's meta.pagination.next_cursor. When supplied, page/total are not returned. |  |
| `fields` | query | string | - | Comma-separated dot-paths to return only those fields, e.g. id,data.stock.price_channel. |  |

**Responses**

| Status | Description |
| --- | --- |
| `200` OK | List Webhooks |

Failures use the standard [error responses](https://motordesk.com/api-docs/v2/errors/) (4xx/5xx) with the shared error envelope.

```json
{
    "success": true,
    "data": [
        {
            "id": 0,
            "url": "",
            "description": "",
            "events": "",
            "scopes": [
                ""
            ],
            "status": "active",
            "secret_hint": "",
            "secret": "",
            "failure_count": 0,
            "last_status": "delivered",
            "last_delivery_at": 0,
            "created": 0,
            "updated": 0
        }
    ],
    "meta": {
        "pagination": {
            "page": 0,
            "per_page": 0,
            "total": 0,
            "total_pages": 0,
            "next_cursor": ""
        }
    }
}
```

### POST Create Webhook

`POST /2.0/webhooks` · scope `webhooks:write`

Create a webhook subscription. The signing secret is returned once in this response and cannot be retrieved again. Deliveries are signed (X-MotorDesk-Signature) and retried with exponential backoff.

**Request body**

| Attribute | Type | Required | Description | Example |
| --- | --- | --- | --- | --- |
| `url` | string | Yes | The HTTPS endpoint deliveries are POSTed to (max 512 chars). | `https://example.com/motordesk/webhook` |
| `events` | string | Yes | The events to subscribe to: the string "*" or an array of event names. | `["vehicle.created","vehicle.updated","vehicle.sold"]` |
| `scopes` | array of string | - | Optional scope snapshot (subset of the creating key's scopes) used to project payloads. Defaults to the minimal read scopes the events require. Add vehicle-pricing:read here to include acquisition/funding data in vehicle payloads. | `["vehicles:read"]` |
| `description` | string | - | Optional label (max 128 chars). | `Inventory sync` |

```json
{
    "url": "https://example.com/motordesk/webhook",
    "events": [
        "vehicle.created",
        "vehicle.updated",
        "vehicle.sold"
    ],
    "scopes": [
        "vehicles:read"
    ],
    "description": "Inventory sync"
}
```

**Responses**

| Status | Description |
| --- | --- |
| `201` Created | Create Webhook |

Failures use the standard [error responses](https://motordesk.com/api-docs/v2/errors/) (4xx/5xx) with the shared error envelope.

```json
{
    "success": true,
    "data": {
        "id": 0,
        "url": "",
        "description": "",
        "events": "",
        "scopes": [
            ""
        ],
        "status": "active",
        "secret_hint": "",
        "secret": "",
        "failure_count": 0,
        "last_status": "delivered",
        "last_delivery_at": 0,
        "created": 0,
        "updated": 0
    }
}
```

### GET Get Webhook

`GET /2.0/webhooks/{id}` · scope `webhooks:read`

Retrieve a single webhook subscription by id.

**Parameters (2)**

| Parameter | In | Type | Required | Description | Example |
| --- | --- | --- | --- | --- | --- |
| `id` | path | string | Yes | Resource identifier in the path. |  |
| `fields` | query | string | - | Comma-separated dot-paths to return only those fields, e.g. id,data.stock.price_channel. |  |

**Responses**

| Status | Description |
| --- | --- |
| `200` OK | Get Webhook |

Failures use the standard [error responses](https://motordesk.com/api-docs/v2/errors/) (4xx/5xx) with the shared error envelope.

```json
{
    "success": true,
    "data": {
        "id": 0,
        "url": "",
        "description": "",
        "events": "",
        "scopes": [
            ""
        ],
        "status": "active",
        "secret_hint": "",
        "secret": "",
        "failure_count": 0,
        "last_status": "delivered",
        "last_delivery_at": 0,
        "created": 0,
        "updated": 0
    }
}
```

### PATCH Update Webhook

`PATCH /2.0/webhooks/{id}` · scope `webhooks:write`

Update a webhook subscription (url, events, status, description). Setting status to active re-enables an auto-disabled subscription and resets its failure counter.

**Parameters (1)**

| Parameter | In | Type | Required | Description | Example |
| --- | --- | --- | --- | --- | --- |
| `id` | path | string | Yes | Resource identifier in the path. |  |

**Request body**

| Attribute | Type | Required | Description | Example |
| --- | --- | --- | --- | --- |
| `url` | string | - | The HTTPS endpoint deliveries are POSTed to (max 512 chars). |  |
| `events` | string | - | The events to subscribe to: the string "*" or an array of event names. |  |
| `status` | enum | - | Enable or disable delivery. |  |
| `description` | string | - | Optional label (max 128 chars). |  |

```json
{
    "url": "",
    "events": "",
    "status": "active",
    "description": ""
}
```

**Responses**

| Status | Description |
| --- | --- |
| `200` OK | Update Webhook |

Failures use the standard [error responses](https://motordesk.com/api-docs/v2/errors/) (4xx/5xx) with the shared error envelope.

```json
{
    "success": true,
    "data": {
        "id": 0,
        "url": "",
        "description": "",
        "events": "",
        "scopes": [
            ""
        ],
        "status": "active",
        "secret_hint": "",
        "secret": "",
        "failure_count": 0,
        "last_status": "delivered",
        "last_delivery_at": 0,
        "created": 0,
        "updated": 0
    }
}
```

### DELETE Delete Webhook

`DELETE /2.0/webhooks/{id}` · scope `webhooks:write`

Delete a webhook subscription. Its delivery history is retained.

**Parameters (1)**

| Parameter | In | Type | Required | Description | Example |
| --- | --- | --- | --- | --- | --- |
| `id` | path | string | Yes | Resource identifier in the path. |  |

**Responses**

| Status | Description |
| --- | --- |
| `200` OK | Delete Webhook |

Failures use the standard [error responses](https://motordesk.com/api-docs/v2/errors/) (4xx/5xx) with the shared error envelope.

```json
{
    "success": true,
    "data": {
        "id": 0,
        "deleted": false
    }
}
```

### Ping

### POST Ping a Webhook

`POST /2.0/webhooks/{id}/ping` · scope `webhooks:write`

Queue a test ping delivery to the subscription, for verifying the endpoint and signature handling.

**Parameters (1)**

| Parameter | In | Type | Required | Description | Example |
| --- | --- | --- | --- | --- | --- |
| `id` | path | string | Yes | Resource identifier in the path. |  |

**Responses**

| Status | Description |
| --- | --- |
| `200` OK | Ping a Webhook |

Failures use the standard [error responses](https://motordesk.com/api-docs/v2/errors/) (4xx/5xx) with the shared error envelope.

```json
{
    "success": true,
    "data": {
        "queued": false,
        "delivery_id": 0,
        "message": ""
    }
}
```

### Secret

### POST Rotate Webhook Secret

`POST /2.0/webhooks/{id}/secret/rotate` · scope `webhooks:write`

Generate a new signing secret for the subscription and return it once. The previous secret stops being used as soon as the new one is issued; update your verifier before or immediately after rotating.

**Parameters (1)**

| Parameter | In | Type | Required | Description | Example |
| --- | --- | --- | --- | --- | --- |
| `id` | path | string | Yes | Resource identifier in the path. |  |

**Responses**

| Status | Description |
| --- | --- |
| `200` OK | Rotate Webhook Secret |

Failures use the standard [error responses](https://motordesk.com/api-docs/v2/errors/) (4xx/5xx) with the shared error envelope.

```json
{
    "success": true,
    "data": {
        "id": 0,
        "url": "",
        "description": "",
        "events": "",
        "scopes": [
            ""
        ],
        "status": "active",
        "secret_hint": "",
        "secret": "",
        "failure_count": 0,
        "last_status": "delivered",
        "last_delivery_at": 0,
        "created": 0,
        "updated": 0
    }
}
```

### Deliveries

### GET List Webhook Deliveries

`GET /2.0/webhooks/{id}/deliveries` · scope `webhooks:read`

List the subscription's recent deliveries (status, response code, attempts), newest first. Optional ?status=pending|delivered|failed and ?event=<name> filters.

**Parameters (2)**

| Parameter | In | Type | Required | Description | Example |
| --- | --- | --- | --- | --- | --- |
| `id` | path | string | Yes | Resource identifier in the path. |  |
| `fields` | query | string | - | Comma-separated dot-paths to return only those fields, e.g. id,data.stock.price_channel. |  |

**Responses**

| Status | Description |
| --- | --- |
| `200` OK | List Webhook Deliveries |

Failures use the standard [error responses](https://motordesk.com/api-docs/v2/errors/) (4xx/5xx) with the shared error envelope.

```json
{
    "success": true,
    "data": [
        {
            "id": 0,
            "event": "",
            "event_id": "",
            "status": "pending",
            "attempts": 0,
            "response_code": 0,
            "response_ms": 0,
            "response_body": "",
            "next_attempt_at": 0,
            "created": 0,
            "delivered_at": 0
        }
    ]
}
```

### POST Redeliver a Webhook Delivery

`POST /2.0/webhooks/{id}/deliveries/{delivery_id}/redeliver` · scope `webhooks:write`

Re-queue a past delivery. A new delivery is created carrying the same event id and payload (so consumers dedupe it as the same event) and sent immediately.

**Parameters (2)**

| Parameter | In | Type | Required | Description | Example |
| --- | --- | --- | --- | --- | --- |
| `id` | path | string | Yes | Resource identifier in the path. |  |
| `delivery_id` | path | string | Yes | Resource identifier in the path. |  |

**Responses**

| Status | Description |
| --- | --- |
| `200` OK | Redeliver a Webhook Delivery |

Failures use the standard [error responses](https://motordesk.com/api-docs/v2/errors/) (4xx/5xx) with the shared error envelope.

```json
{
    "success": true,
    "data": {
        "id": 0,
        "event": "",
        "event_id": "",
        "status": "pending",
        "attempts": 0,
        "response_code": 0,
        "response_ms": 0,
        "response_body": "",
        "next_attempt_at": 0,
        "created": 0,
        "delivered_at": 0
    }
}
```
