Stock Webhook API
Receive a dealer's stock in your own system. When a car the dealer has chosen to share is added, changed or taken off sale, we send it to your endpoint as JSON.
Version 1 · Early accessHow it works
DealerGate is a dealer management system for UK car dealers. A dealer can share their stock with a third party through this webhook. There's no sign-up form: the dealer asks us to connect you, and we set it up together.
- The dealer asks DealerGate to connect you.
- You send us the HTTPS address of your endpoint.
- We give you a signing secret for that dealer and send a test event.
- The dealer switches on the cars they want to share, and those cars start arriving.
You only receive cars the dealer has switched on. If they switch a car off, or it's sold, we tell you to remove it.
Each dealer has their own secret. You can use one endpoint for several dealers and tell them apart with dealer.id.
Your endpoint
Your endpoint needs to:
- use HTTPS with a valid certificate;
- accept a
POSTwith a JSON body; - check the signature on every request (see Checking signatures);
- answer with any
2xxstatus within 10 seconds.
Anything else counts as a failure and we'll retry, including a timeout or a redirect. We don't follow redirects.
Save the delivery and answer first. Do slower work, like downloading images, afterwards.
Requests
Each request is one event for one car. These headers come with every request:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | DealerGate-Webhook/1.0 |
X-DealerGate-Event | The event name, such as vehicle.upsert. |
X-DealerGate-Delivery | The delivery ID. A retry of the same delivery keeps the same ID. |
X-DealerGate-Timestamp | When this attempt was sent, in Unix seconds. |
X-DealerGate-Signature | sha256= followed by the signature in hex. |
Every body has the same outer fields:
| Field | Type | Notes |
|---|---|---|
event | string | The event name. |
schema_version | integer | Currently 1. See Versions. |
delivery_id | string | UUID. Matches X-DealerGate-Delivery. |
sent_at | string | When this attempt was sent, in UTC (ISO 8601). |
dealer | object | See Dealer fields. |
vehicle | object | Not sent on test.ping. |
Events
| Event | Meaning | What to do |
|---|---|---|
vehicle.upsert | A car was switched on or changed. | Add the car, or replace what you hold for that vehicle.id. |
vehicle.removed | The car is no longer shared. | Take the car down. |
test.ping | A test from DealerGate. | Check the signature and answer 200. |
vehicle.upsert
Every vehicle.upsert holds the whole car, not just what changed.
{
"event": "vehicle.upsert",
"schema_version": 1,
"delivery_id": "6f1c2e0a-8b47-4c1d-9a35-2f7d1e4b6c90",
"sent_at": "2026-11-05T09:12:33Z",
"dealer": {
"id": "DG-00-0000",
"name": "Example Motors",
"phone": "01234 567890",
"email": "sales@example-motors.co.uk",
"website": "https://www.example-motors.co.uk",
"address_line_1": "1 High Street",
"town": "Luton",
"postcode": "LU1 1AA"
},
"vehicle": {
"id": "a3e9b7d2-41f6-4c08-b1de-7c5a90e2f314",
"registration": "AB73CDE",
"status": "in_stock",
"vehicle_type": "Car",
"make": "Vauxhall",
"model": "Corsa",
"generation": "Hatchback (2019 onwards)",
"derivative": "1.2 Turbo Design 5dr",
"trim": "Design",
"body_type": "Hatchback",
"fuel_type": "Petrol",
"transmission": "Manual",
"drivetrain": "Front Wheel Drive",
"colour": "Blue",
"doors": 5,
"seats": 5,
"year": 2024,
"first_registered": "2024-01-18",
"mileage": 34515,
"owners": 1,
"engine_size_cc": 1199,
"power_bhp": 99,
"co2_g_km": 118,
"emission_class": "Euro 6",
"mpg_combined": 53.3,
"insurance_group": "12E",
"mot_expiry": "2027-01-17",
"service_history": "Full dealership history",
"price": 10294,
"price_on_application": false,
"vat_status": "No VAT",
"attention_grabber": "ONE OWNER, FULL HISTORY",
"description": "A one owner Corsa with full dealership service history.",
"features": ["Apple CarPlay", "Parking sensors", "Heated seats"],
"images": [
"https://images.example.com/ab73cde/1.jpg",
"https://images.example.com/ab73cde/2.jpg"
],
"videos": [],
"updated_at": "2026-11-05T09:08:02Z"
}
}vehicle.removed
vehicle.reason is one of:
sold: the car has been sold.switched_off: the dealer stopped sharing this car.unavailable: the car is no longer on sale for any other reason.
{
"event": "vehicle.removed",
"schema_version": 1,
"delivery_id": "0d4b8a31-7e52-4f6a-8c19-5be2a7f3c021",
"sent_at": "2026-11-05T15:40:07Z",
"dealer": {
"id": "DG-00-0000",
"name": "Example Motors"
},
"vehicle": {
"id": "a3e9b7d2-41f6-4c08-b1de-7c5a90e2f314",
"registration": "AB73CDE",
"reason": "sold"
}
}test.ping
{
"event": "test.ping",
"schema_version": 1,
"delivery_id": "c27e9f14-3a68-4b0d-a5e1-91d4f06b7e38",
"sent_at": "2026-11-05T09:00:00Z",
"dealer": {
"id": "DG-00-0000",
"name": "Example Motors"
}
}Vehicle fields
Use id as your key for a car. A registration can change, for example when a private plate is removed.
A field marked null can be empty. Text fields hold what the dealer has on record, so don't rely on a fixed list of values.
| Field | Type | Notes |
|---|---|---|
id | string | UUID. Never changes. |
registration | string | UK registration, no spaces. |
status | string | in_stock or reserved. A reserved car has a buyer but isn't sold yet. |
vehicle_type | string | null | For example Car or Van. |
make | string | |
model | string | |
generation | string | null | |
derivative | string | null | The full version name. |
trim | string | null | |
body_type | string | null | |
fuel_type | string | null | |
transmission | string | null | |
drivetrain | string | null | |
colour | string | null | |
doors | integer | null | |
seats | integer | null | |
year | integer | null | Year of manufacture. |
first_registered | string | null | Date, YYYY-MM-DD. |
mileage | integer | null | In miles. |
owners | integer | null | Number of previous owners. |
engine_size_cc | integer | null | |
power_bhp | number | null | |
co2_g_km | number | null | |
emission_class | string | null | For example Euro 6. |
mpg_combined | number | null | |
insurance_group | string | null | |
mot_expiry | string | null | Date, YYYY-MM-DD. |
service_history | string | null | |
price | number | null | In pounds. Empty if price_on_application is true. |
price_on_application | boolean | |
vat_status | string | null | For example No VAT, Ex VAT or Inc. VAT. |
attention_grabber | string | null | The dealer's short headline for the car. |
description | string | null | The dealer's advert text. |
features | string[] | Equipment names. |
images | string[] | Image URLs in the dealer's order. The first is the main photo. |
videos | string[] | Video URLs. |
updated_at | string | When the car last changed, in UTC (ISO 8601). |
vin | string | Only present if the dealer has agreed to share it. |
Image URLs can change, so always use the ones in the latest delivery.
The feed holds advert details only. It never includes what the dealer paid for a car, profit figures, seller or customer details, or internal notes.
Dealer fields
vehicle.upsert has all of these. vehicle.removed and test.ping have id and name only.
| Field | Type | Notes |
|---|---|---|
id | string | The dealer's DealerGate ID, such as DG-00-0000. Never changes. |
name | string | |
phone | string | null | |
email | string | null | |
website | string | null | |
address_line_1 | string | null | |
town | string | null | |
postcode | string | null |
Checking signatures
The signature proves a request came from DealerGate and wasn't changed on the way. Check it on every request, and keep your secret on your server.
- Read the raw request body as bytes, before any JSON parsing.
- Join the
X-DealerGate-Timestampvalue, a full stop and the raw body. - Work out the HMAC-SHA256 of that text with your secret, in lowercase hex.
- Add
sha256=to the front and compare it toX-DealerGate-Signature, using a constant-time comparison. - Reject the request if the timestamp is more than 5 minutes from your clock. This stops an old request being replayed.
Answer 401 if a check fails.
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
const secret = process.env.DEALERGATE_SECRET;
// express.raw keeps the exact bytes we sent, which is what the signature covers.
app.post('/dealergate', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('X-DealerGate-Timestamp') || '';
const signature = req.get('X-DealerGate-Signature') || '';
const expected = 'sha256=' + crypto.createHmac('sha256', secret)
.update(timestamp + '.')
.update(req.body)
.digest('hex');
const recent = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
const matches = signature.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!recent || !matches) return res.sendStatus(401);
const delivery = JSON.parse(req.body);
// Save the delivery here, then answer. Do slow work afterwards.
res.sendStatus(200);
});
app.listen(process.env.PORT || 3000);PHP
<?php
$secret = getenv('DEALERGATE_SECRET');
// Read the exact bytes we sent, which is what the signature covers.
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_DEALERGATE_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_DEALERGATE_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
$recent = ctype_digit($timestamp) && abs(time() - (int) $timestamp) <= 300;
if (!$recent || !hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
$delivery = json_decode($body, true);
// Save the delivery here, then answer. Do slow work afterwards.
http_response_code(200);Python (Flask)
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ['DEALERGATE_SECRET'].encode()
@app.post('/dealergate')
def dealergate():
timestamp = request.headers.get('X-DealerGate-Timestamp', '')
signature = request.headers.get('X-DealerGate-Signature', '')
# get_data() returns the exact bytes we sent, which is what the signature covers.
signed = timestamp.encode() + b'.' + request.get_data()
expected = 'sha256=' + hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
recent = timestamp.isdigit() and abs(time.time() - int(timestamp)) <= 300
if not recent or not hmac.compare_digest(expected, signature):
abort(401)
delivery = request.get_json()
# Save the delivery here, then answer. Do slow work afterwards.
return '', 200Test your code
These made-up values should give your code exactly this signature. Skip the 5-minute check for this test, because the timestamp is in the past.
| Secret | dgwh_test_secret |
| Timestamp | 1762333200 |
| Body | {"event":"test.ping"} |
| Signature | sha256=d35db8d212fd4138ddf7f7b745cc80be244ef31045a68c71875d113a285aae05 |
Delivery and retries
Timing
We check for changes every 5 minutes and send only the cars that changed, so a change normally reaches you within a few minutes.
Volume
We send one car per request, at up to 2 requests a second. When a dealer first switches on their stock, expect a burst of hundreds of requests.
Duplicates and order
You may occasionally receive the same delivery twice, so make sure handling it twice does no harm. Replacing the car by vehicle.id does this for you.
After a retry, deliveries can arrive out of order. Compare sent_at and ignore anything older than what you hold for that car.
Failures
If a delivery fails, we try again at the next check, then at growing gaps of up to an hour, for up to 24 hours. After that we pause the feed and contact you. Once it's fixed, we send everything that changed while it was paused.
Getting back in step
If your copy of a dealer's stock goes wrong, ask us to resend it. Every car the dealer has switched on arrives again as vehicle.upsert.
Versions
We may add new fields at any time, so ignore any you don't recognise. If we ever remove or change a field, schema_version goes up and we'll tell you first.
| Version | Date | Changes |
|---|---|---|
| 1 | November 2026 | First release. |
Support
Email support@dealergate.co.uk or call 0333 772 4639. If a delivery went wrong, send us the dealer ID and the delivery ID.