← API v2 Overview

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.

GroupEventsScope
Vehiclesvehicle.created, vehicle.updated, vehicle.reserved, vehicle.sold, vehicle.deletedvehicles:read
Contactscontact.created, contact.updated, contact.deletedcontacts:read
Leadslead.created, lead.updated, lead.deletedleads:read
Appointmentsappointment.created, appointment.updated, appointment.cancelledappointments:read
Invoicesinvoice.created, invoice.issued, invoice.paid, invoice.cancelled, invoice.creditedinvoices:read
Ordersorder.created, order.issued, order.cancelled, order.convertedorders:read
Purchasespurchase.created, purchase.issued, purchase.paid, purchase.cancelled, purchase.creditedpurchases:read
Dealsdeal.created, deal.updated, deal.stage_changeddeals:read
Comms / Contentcall.created, review.created, review.updated, blog.publishedcalls: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).

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)
AttributeTypeRequiredDescriptionExample
idinteger-Subscription id.
urlstring-The HTTPS endpoint deliveries are POSTed to.
descriptionstring-Optional label for the subscription.
eventsstring-The subscribed events: the string "*" (all) or an array of event names.
scopesarray of string-The scope snapshot used to project each delivery payload (a subset of the creating key's scopes).
statusenum-active subscriptions receive deliveries; auto-disabled after sustained failures.
secret_hintstring-The last 4 characters of the signing secret, for identification. The full secret is only returned once, on create.
secretstring-The signing secret, returned in cleartext ONLY in the create response. Store it securely; it cannot be retrieved again.
failure_countinteger-Consecutive terminal delivery failures; reset to 0 on any success.
last_statusenum or null-Outcome of the most recent delivery attempt, or null.
last_delivery_atinteger or null-Unix timestamp of the most recent delivery attempt, or null.
createdinteger-Unix timestamp the subscription was created.
updatedinteger-Unix timestamp the subscription was last updated.
Example object
{
    "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)
ParameterInTypeRequiredDescriptionExample
cursorquerystring-Keyset pagination cursor from a previous response's meta.pagination.next_cursor. When supplied, page/total are not returned.
fieldsquerystring-Comma-separated dot-paths to return only those fields, e.g. id,data.stock.price_channel.
Responses
StatusDescription
200 OKList 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 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
AttributeTypeRequiredDescriptionExample
urlstringYesThe HTTPS endpoint deliveries are POSTed to (max 512 chars).https://example.com/motordesk/webhook
eventsstringYesThe events to subscribe to: the string "*" or an array of event names.["vehicle.created","vehicle.updated","vehicle.sold"]
scopesarray 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"]
descriptionstring-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"
}
Responses
StatusDescription
201 CreatedCreate 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 Get Webhook

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

Retrieve a single webhook subscription by id.

Parameters (2)
ParameterInTypeRequiredDescriptionExample
idpathstringYesResource identifier in the path.
fieldsquerystring-Comma-separated dot-paths to return only those fields, e.g. id,data.stock.price_channel.
Responses
StatusDescription
200 OKGet 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 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)
ParameterInTypeRequiredDescriptionExample
idpathstringYesResource identifier in the path.
Request body
AttributeTypeRequiredDescriptionExample
urlstring-The HTTPS endpoint deliveries are POSTed to (max 512 chars).
eventsstring-The events to subscribe to: the string "*" or an array of event names.
statusenum-Enable or disable delivery.
descriptionstring-Optional label (max 128 chars).
{
    "url": "",
    "events": "",
    "status": "active",
    "description": ""
}
Responses
StatusDescription
200 OKUpdate 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 Delete Webhook

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

Delete a webhook subscription. Its delivery history is retained.

Parameters (1)
ParameterInTypeRequiredDescriptionExample
idpathstringYesResource identifier in the path.
Responses
StatusDescription
200 OKDelete Webhook

Failures use the standard error responses (4xx/5xx) with the shared error envelope.

{
    "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)
ParameterInTypeRequiredDescriptionExample
idpathstringYesResource identifier in the path.
Responses
StatusDescription
200 OKPing a Webhook

Failures use the standard error responses (4xx/5xx) with the shared error envelope.

{
    "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)
ParameterInTypeRequiredDescriptionExample
idpathstringYesResource identifier in the path.
Responses
StatusDescription
200 OKRotate 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
    }
}

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)
ParameterInTypeRequiredDescriptionExample
idpathstringYesResource identifier in the path.
fieldsquerystring-Comma-separated dot-paths to return only those fields, e.g. id,data.stock.price_channel.
Responses
StatusDescription
200 OKList 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 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)
ParameterInTypeRequiredDescriptionExample
idpathstringYesResource identifier in the path.
delivery_idpathstringYesResource identifier in the path.
Responses
StatusDescription
200 OKRedeliver 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
    }
}