Webhooks and signed callbacks
Last updated: 5 November 2026
DealerPricing can POST to your server when something happens on your developer account, for example when your balance runs low or a service history request finishes. Every request we send, account events and service history callbacks alike, is signed so you can check it came from us.
Set up an endpoint
- Sign in to the developer dashboard and open Webhooks.
- Create your signing secret and store it on your server (it is shown once; you can roll it later).
- Add your HTTPS endpoint URL, choose the events you want, and use Send test event to check it answers with a 2xx status.
You can add up to 5 endpoints. Webhooks are optional: the API works exactly the same without them.
Service history callbacks need no set-up: pass callback_url on the request as before. They are signed with the same secret. If you never created one, a secret is made the first time we send you a callback; roll it in the dashboard to see a new one.
What we send
A POST with a JSON body and these headers:
| Header | Value |
|---|---|
| DP-Signature | t=1762337702,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd: the Unix time we signed at and the HMAC-SHA256 signature. Two v1 values during a secret roll. |
| DP-Event-Id | The event id (evt_...). The same on every retry. |
| DP-Event-Type | The event type, for example balance.low. |
| DP-Delivery-Attempt | 1 for the first try, then 2, 3 and so on. |
| Content-Type | application/json |
Account events share one envelope. data depends on the type:
{
"id": "evt_1a2b3c4d5e6f7a8b9c0d1e2f",
"type": "balance.low",
"created": "2026-11-05T10:15:02.000Z",
"livemode": true,
"data": {
"balance_pence": 840,
"threshold_pence": 1000,
"currency": "GBP"
}
}Service history callbacks keep their existing body (event, request_id, sent_at, data).
Verify the signature
- Read the raw request body exactly as received (before any JSON parsing).
- Split
DP-Signatureon commas:tis the timestamp, eachv1is a signature. - Reject the request if
tis more than 5 minutes from your server's clock. - Compute HMAC-SHA256 of
<t>.<raw body>(the timestamp, a full stop, then the raw body) with your whole signing secret (includingwhsec_) as the key, as lowercase hex. - Accept if it matches any
v1value, using a constant-time comparison.
// Node.js 18+ with Express
const crypto = require('node:crypto')
const express = require('express')
const SECRET = process.env.DEALERPRICING_WEBHOOK_SECRET // whsec_...
function verifyDealerPricing(rawBody, header, secret, toleranceSeconds = 300) {
let t = null
const signatures = []
for (const part of String(header || '').split(',')) {
const [key, value] = part.trim().split('=')
if (key === 't' && /^\d+$/.test(value)) t = Number(value)
if (key === 'v1' && value) signatures.push(value)
}
if (t === null || signatures.length === 0) return false
// Reject old (or future) timestamps: stops replays of a captured request.
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`, 'utf8').digest()
// Constant-time compare against every v1 value (two during a secret roll).
return signatures.some((sig) => {
const given = Buffer.from(sig, 'hex')
return given.length === expected.length && crypto.timingSafeEqual(given, expected)
})
}
const app = express()
// Use the RAW body: re-serialised JSON will not match the signature.
app.post('/dealerpricing/events', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8')
if (!verifyDealerPricing(raw, req.get('DP-Signature'), SECRET)) return res.status(400).send('invalid signature')
const event = JSON.parse(raw)
// event.id (also the DP-Event-Id header) is the same on every retry: skip ids you have already handled.
res.sendStatus(200) // answer quickly, then do the work
})
app.listen(3000)# Python 3 with Flask
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["DEALERPRICING_WEBHOOK_SECRET"] # whsec_...
def verify_dealerpricing(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
t, signatures = None, []
for part in (header or "").split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isdigit():
t = int(value)
elif key == "v1" and value:
signatures.append(value)
if t is None or not signatures:
return False
# Reject old (or future) timestamps: stops replays of a captured request.
if abs(int(time.time()) - t) > tolerance_seconds:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
# Constant-time compare against every v1 value (two during a secret roll).
return any(hmac.compare_digest(expected, sig) for sig in signatures)
app = Flask(__name__)
@app.post("/dealerpricing/events")
def dealerpricing_events():
raw = request.get_data() # the raw body, not request.json
if not verify_dealerpricing(raw, request.headers.get("DP-Signature", ""), SECRET):
abort(400)
event = request.get_json()
# event["id"] is the same on every retry: skip ids you have already handled.
return "", 200<?php
// PHP 7.4+
function verify_dealerpricing(string $rawBody, string $header, string $secret, int $toleranceSeconds = 300): bool
{
$t = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't' && ctype_digit($value)) {
$t = (int) $value;
} elseif ($key === 'v1' && $value !== '') {
$signatures[] = $value;
}
}
if ($t === null || !$signatures) {
return false;
}
// Reject old (or future) timestamps: stops replays of a captured request.
if (abs(time() - $t) > $toleranceSeconds) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
// Constant-time compare against every v1 value (two during a secret roll).
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
$raw = file_get_contents('php://input'); // the raw body
$secret = getenv('DEALERPRICING_WEBHOOK_SECRET'); // whsec_...
if (!verify_dealerpricing($raw, $_SERVER['HTTP_DP_SIGNATURE'] ?? '', $secret)) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
// $event['id'] is the same on every retry: skip ids you have already handled.
http_response_code(200);Answer with any 2xx status within 10 seconds. Do slow work after you have answered.
Events
Choose which events each endpoint receives. Payloads never contain API keys, and events caused by sandbox keys have "livemode": false.
Balance
balance.low
Your prepaid balance has dropped below your low balance level.
Once each time a charged live request takes the balance below the level. It is sent again only after a top-up brings the balance back above the level. The level is the low balance alert level on your Billing page; if that alert is off, the level you set on the endpoint (default £10) is used.
{
"balance_pence": 840,
"threshold_pence": 1000,
"currency": "GBP"
}balance.topped_up
A top-up has been paid and added to your balance.
When a top-up payment is confirmed and credited.
{
"amount_pence": 5000,
"balance_pence": 5840,
"currency": "GBP"
}API keys
key.created
An API key was created on your account.
When a live or sandbox key is created. The key itself is never sent: only its name, mode and last four characters.
{
"key": {
"name": "Production server",
"mode": "live",
"last4": "9e80",
"created_at": "2026-11-05T09:00:00.000Z"
}
}key.revoked
An API key on your account was revoked.
When a key is revoked. Requests with that key are refused from that moment.
{
"key": {
"name": "Old staging key",
"mode": "sandbox",
"last4": "41c2",
"revoked_at": "2026-11-05T09:30:00.000Z"
}
}Plan and billing
plan.changed
Your API plan changed and the new per-call prices apply now.
When a subscription starts, a paid upgrade lands, a scheduled downgrade takes effect at renewal, or the subscription ends and the account returns to Pay As You Go.
{
"from_plan": "payg",
"from_plan_name": "Pay As You Go",
"to_plan": "starter",
"to_plan_name": "Starter",
"reason": "subscription_started"
}plan.change_scheduled
A plan change has been booked for the end of the current period.
When you schedule a downgrade or a cancellation, or remove one you scheduled earlier.
{
"from_plan": "pro",
"from_plan_name": "Pro",
"to_plan": "starter",
"to_plan_name": "Starter",
"kind": "downgrade",
"effective_date": "2026-12-06"
}subscription.payment_failed
A subscription renewal payment did not go through.
When a renewal charge is declined or overdue. At most once a day per subscription.
{
"plan": "starter",
"plan_name": "Starter",
"message": "Your subscription renewal payment failed. Update your card on the Billing page to keep your plan."
}Service history
service_history.completed
A service history request finished. Same body as the request callback.
When a service history request completes (with or without records). The data object is exactly the body we POST to the request's callback_url.
{
"event": "service_history.completed",
"request_id": "7d5c2a9e-3f1b-4c6d-9a8e-1b2c3d4e5f60",
"sent_at": "2026-11-05T10:12:01.000Z",
"data": {
"request_id": "7d5c2a9e-3f1b-4c6d-9a8e-1b2c3d4e5f60",
"vrm": "SK22AOM",
"status": "completed",
"message": "Service history retrieved. Sample data for testing. These are not the records of this or any real vehicle.",
"requested_at": "2026-11-05T10:11:40.000Z",
"completed_at": "2026-11-05T10:12:00.000Z",
"check_after_seconds": null,
"status_url": "https://www.dealerpricing.co.uk/api/v1/vehicle/service-history/7d5c2a9e-3f1b-4c6d-9a8e-1b2c3d4e5f60",
"callback_url": "https://example.com/hooks/service-history",
"cost_pence": 0,
"refunded_pence": 0,
"error": null,
"outcome": "records",
"error_code": null,
"service_history": {
"record_count": 7,
"services": [
{
"date": "2022-04-21",
"date_raw": "21/04/2022",
"type": "Service / Repair",
"mileage": 12,
"mileage_raw": "12 mi",
"mileage_unit": "mi",
"workshop": "Sample Motors (Main Dealer), Exampleton",
"notes": "Pre-delivery inspection"
},
{
"date": "2023-03-14",
"date_raw": "14/03/2023",
"type": "Service / Repair",
"mileage": 9214,
"mileage_raw": "9,214 mi",
"mileage_unit": "mi",
"workshop": "Sample Motors (Main Dealer), Exampleton",
"notes": "Oil change service"
},
{
"date": "2024-02-02",
"date_raw": "02/02/2024",
"type": "Service / Repair",
"mileage": 18905,
"mileage_raw": "18,905 mi",
"mileage_unit": "mi",
"workshop": "Sample Motors (Main Dealer), Exampleton",
"notes": "Inspection with oil change"
},
{
"date": "2024-07-15",
"date_raw": "15/07/2024",
"type": "Service / Repair",
"mileage": 33600,
"mileage_raw": "33,600 km",
"mileage_unit": "km",
"workshop": "Example Garage Europe, Exampleville",
"notes": "Puncture repair"
},
{
"date": "2024-09-11",
"date_raw": "11/09/2024",
"type": "Service / Repair",
"mileage": 23410,
"mileage_raw": "23,410 mi",
"mileage_unit": "mi",
"workshop": "Example Autos Service Centre, Exampleton",
"notes": "Brake fluid change"
},
{
"date": "2025-03-28",
"date_raw": "28/03/2025",
"type": "Service / Repair",
"mileage": 29876,
"mileage_raw": "29,876 mi",
"mileage_unit": "mi",
"workshop": "Example Autos Service Centre, Exampleton",
"notes": "Oil change service"
},
{
"date": "2026-03-06",
"date_raw": "06/03/2026",
"type": "Service / Repair",
"mileage": 38120,
"mileage_raw": "38,120 mi",
"mileage_unit": "mi",
"workshop": "Sample Motors (Main Dealer), Exampleton",
"notes": "Inspection with oil change and pollen filter"
}
],
"message": null,
"retrieved_at": "2026-11-05T10:12:00.000Z",
"sample": true,
"sample_notice": "Sample data for testing. These are not the records of this or any real vehicle."
},
"pdf_url": "https://www.dealerpricing.co.uk/api/service-history/pdf/5a3b1e00-5a3b-4e00-8000-000000000001?t=1765000000.EXAMPLE",
"pdf_url_expires_in_seconds": 604800,
"readable_until": "2026-11-12T10:12:00.000Z"
}
}service_history.failed
A service history request failed and was refunded. Same body as the request callback.
When a service history request fails. The data object is exactly the body we POST to the request's callback_url.
{
"event": "service_history.failed",
"request_id": "7d5c2a9e-3f1b-4c6d-9a8e-1b2c3d4e5f60",
"sent_at": "2026-11-05T10:12:01.000Z",
"data": {
"request_id": "7d5c2a9e-3f1b-4c6d-9a8e-1b2c3d4e5f60",
"vrm": "SK22AOM",
"status": "failed",
"message": "The service history could not be retrieved because of a fault. This request has been refunded in full.",
"requested_at": "2026-11-05T10:11:40.000Z",
"completed_at": "2026-11-05T10:12:00.000Z",
"check_after_seconds": null,
"status_url": "https://www.dealerpricing.co.uk/api/v1/vehicle/service-history/7d5c2a9e-3f1b-4c6d-9a8e-1b2c3d4e5f60",
"callback_url": "https://example.com/hooks/service-history",
"cost_pence": 200,
"refunded_pence": 200,
"error": "The service history could not be retrieved because the manufacturer network had a temporary fault. You can make a new request.",
"outcome": "failed",
"error_code": "SOURCE_UNAVAILABLE",
"service_history": null,
"pdf_url": null,
"pdf_url_expires_in_seconds": null,
"readable_until": "2026-11-12T10:12:00.000Z"
}
}Limits
limit.daily_cap_reached
A daily limit on your plan was reached; further live requests are refused until midnight UK time.
The first time in a UK day that a live request is refused for the plan's daily limit or an endpoint's daily cap. Once per limit per day.
{
"limit": "endpoint_daily_cap",
"endpoint": "provenance",
"daily_cap": 500,
"plan": "starter",
"plan_name": "Starter",
"resets_at": "2026-11-06T00:00:00.000Z"
}Account
account.email_changed
The sign-in email address of your developer account changed.
When the account email address changes: straight away for an account that is not verified yet, otherwise when the new address is confirmed.
{
"email": "new-address@example.com",
"previous_email": "old-address@example.com",
"changed_at": "2026-11-05T11:00:00.000Z"
}Announcements
api.notice
An announcement about the API, for example a change to error codes.
When DealerPricing publishes a notice to API customers.
{
"title": "Clearer error codes",
"message": "From 1 February 2027 every account gets the version 2 error codes. Until then you can switch early.",
"url": "https://www.dealerpricing.co.uk/developer/errors",
"effective_date": "2027-02-01"
}Rolling the secret
Use Roll secret in the dashboard if a secret may have leaked, or as routine. You see the new secret once. For the next 24 hours every request is signed with both the new and the old secret (two v1values), so you can update your server without failed checks. After that only the new one is used.
Retries and switching off
- A 2xx answer within 10 seconds counts as delivered. Anything else (including a redirect, which we do not follow) is a failure.
- We send each event as soon as it happens, usually within a few seconds.
- Failed events are tried again after about 5 minutes, 15 minutes, 30 minutes, 2 hours, 6 hours, 24 hours. After that the delivery is marked failed.
- If an endpoint fails 15 times in a row over at least 24 hours, we switch it off and email the account owner. Switch it back on in the dashboard when it is fixed.
- Service history callbacks keep their own retries (after 1, 10 and 60 minutes); the result can always be read from the request's status URL.
- The dashboard shows each endpoint's recent deliveries with their status and the last error.
Endpoint rules
- HTTPS only, with a valid certificate, and no user name or password in the URL.
- The host must be a public internet address. We refuse local names (such as
localhost) and private, loopback, link-local and other reserved addresses in IPv4 and IPv6, and we check every address the name resolves to each time we send. - The response body is ignored; only the status code matters.
Questions
- Do service history callbacks change?
- The body is unchanged. Callbacks now also carry the DP-Signature, DP-Event-Id, DP-Event-Type and DP-Delivery-Attempt headers, so you can check that a callback really came from DealerPricing. Integrations that ignore the new headers keep working.
- Why did my endpoint get a request I did not expect twice?
- Delivery is at least once: if your server answers slowly or with an error we try again. The event id is the same on every try, so store the ids you have handled and skip repeats.
- Are events sent in order?
- Not guaranteed. Use the created time in the body if order matters to you.
- Do sandbox keys send events?
- Yes, for sandbox service history requests. Those events have "livemode": false. Test events sent from the portal are also marked "livemode": false and "test": true.
See also the API documentation and the Developer API terms.