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.
POST /webhooks, giving an HTTPS endpoint and the events you want. The signing secret is returned once, in that response.POST, signed with your secret.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.
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.
| 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).
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.
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:
t and v1.HMAC-SHA256(secret, t + "." + raw_body) and compare it to v1 using a constant-time comparison.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.
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:
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.
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.
id in the body, or the X-MotorDesk-Event-Id header). Re-deliveries of the same event keep the same id.created timestamp, or re-fetch the resource by id to get its current state, rather than assuming the payload is the latest.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.
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.
POST /webhooks/{id}/secret/rotate.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.
Generated from the OpenAPI schema. Always matches the live API.
| 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. |
{
"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 /2.0/webhooks · scope webhooks:read
List the business's webhook subscriptions.
| 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. |
| Status | Description |
|---|---|
200 OK | List Webhooks |
Failures use the standard error responses (4xx/5xx) with the shared error envelope.
{
"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 /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.
| 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 |
{
"url": "https://example.com/motordesk/webhook",
"events": [
"vehicle.created",
"vehicle.updated",
"vehicle.sold"
],
"scopes": [
"vehicles:read"
],
"description": "Inventory sync"
}| Status | Description |
|---|---|
201 Created | Create Webhook |
Failures use the standard error responses (4xx/5xx) with the shared error envelope.
{
"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 /2.0/webhooks/{id} · scope webhooks:read
Retrieve a single webhook subscription by id.
| 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. |
| Status | Description |
|---|---|
200 OK | Get Webhook |
Failures use the standard error responses (4xx/5xx) with the shared error envelope.
{
"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 /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.
| Parameter | In | Type | Required | Description | Example |
|---|---|---|---|---|---|
id | path | string | Yes | Resource identifier in the path. |
| 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). |
{
"url": "",
"events": "",
"status": "active",
"description": ""
}| Status | Description |
|---|---|
200 OK | Update Webhook |
Failures use the standard error responses (4xx/5xx) with the shared error envelope.
{
"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 /2.0/webhooks/{id} · scope webhooks:write
Delete a webhook subscription. Its delivery history is retained.
| Parameter | In | Type | Required | Description | Example |
|---|---|---|---|---|---|
id | path | string | Yes | Resource identifier in the path. |
| Status | Description |
|---|---|
200 OK | Delete Webhook |
Failures use the standard error responses (4xx/5xx) with the shared error envelope.
{
"success": true,
"data": {
"id": 0,
"deleted": false
}
}POST /2.0/webhooks/{id}/ping · scope webhooks:write
Queue a test ping delivery to the subscription, for verifying the endpoint and signature handling.
| Parameter | In | Type | Required | Description | Example |
|---|---|---|---|---|---|
id | path | string | Yes | Resource identifier in the path. |
| Status | Description |
|---|---|
200 OK | Ping a Webhook |
Failures use the standard error responses (4xx/5xx) with the shared error envelope.
{
"success": true,
"data": {
"queued": false,
"delivery_id": 0,
"message": ""
}
}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.
| Parameter | In | Type | Required | Description | Example |
|---|---|---|---|---|---|
id | path | string | Yes | Resource identifier in the path. |
| Status | Description |
|---|---|
200 OK | Rotate Webhook Secret |
Failures use the standard error responses (4xx/5xx) with the shared error envelope.
{
"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 /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.
| 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. |
| Status | Description |
|---|---|
200 OK | List Webhook Deliveries |
Failures use the standard error responses (4xx/5xx) with the shared error envelope.
{
"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 /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.
| 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. |
| Status | Description |
|---|---|
200 OK | Redeliver a Webhook Delivery |
Failures use the standard error responses (4xx/5xx) with the shared error envelope.
{
"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
}
}