Skip to main content

Webhooks and events

Every change the API cares about (a vehicle updated, an enquiry created, a deal changing status) is recorded as an event. Vehiso delivers each event to your webhook endpoints as an HTTPS POST, and keeps it readable at GET /v1/events for 30 days so you can catch up on anything you missed.

Webhooks need a paid Vehiso plan.

Event types​

ResourceEvent types
Vehiclesvehicle.created, vehicle.updated, vehicle.status_changed, vehicle.deleted
Batch uploadsvehicle_batch.completed
Enquiriesenquiry.created, enquiry.updated, enquiry.deleted
Customerscustomer.created, customer.updated, customer.deleted
Dealsdeal.created, deal.status_changed
Appointmentsappointment.created, appointment.updated, appointment.cancelled
Workshopjob_card.created, job_card.updated
Website themestheme_generation.started, theme_generation.succeeded, theme_generation.failed
Testingping

Payload​

Every delivery has the same envelope. data.object is the resource as the v1 API returns it, for example a vehicle exactly as GET /v1/vehicles/{id} would return it.

{
"id": "evt_2c9d1f4e-7a3b-4e8c-9d6f-1b2a3c4d5e6f",
"type": "vehicle.updated",
"created_at": "2026-09-27T14:02:11Z",
"data": {
"object": {
"id": "5f0c6a8e-2d41-4f7b-9a53-1f0e8c2b7d19"
}
}
}

The object is shown trimmed here. It is the resource as it stood when the event happened. A vehicle_batch.completed event carries the batch without its items, and a *.status_changed event adds data.previous_status. A ping carries { id, message }.

The request carries these headers:

HeaderDescription
Vehiso-Signaturet=<unix timestamp>,v1=<signature>. See Verifying signatures.
Vehiso-Event-IdThe event's id. Use it to ignore duplicates.
Vehiso-Event-TypeThe event's type, so you can route before parsing the body.
Content-Typeapplication/json

Setting up an endpoint​

Endpoints are managed in the DMS under Administration > Developers > Webhooks, or through the API with a key that has the webhooks:manage scope:

EndpointDoes
GET /v1/webhook-endpointsLists endpoints
POST /v1/webhook-endpointsCreates an endpoint
GET, PATCH, DELETE /v1/webhook-endpoints/{id}Reads, changes or deletes one
POST /v1/webhook-endpoints/{id}/roll-secretIssues a new signing secret
POST /v1/webhook-endpoints/{id}/testSends a ping event to the endpoint

An endpoint has an HTTPS url that resolves to a public internet address, an optional description, and the list of event types it subscribes to (at least one, or * for every type). ping cannot be subscribed to; it is only sent by the test call. A dealership can have up to 20 endpoints; creating more is refused with 422 webhook_endpoint_limit_reached.

curl https://api.vehiso.com/v1/webhook-endpoints \
-H "Authorization: Bearer $VEHISO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/vehiso", "description": "Stock to CRM", "events": ["vehicle.created", "vehicle.updated", "vehicle.deleted"]}'

When an endpoint is created, the response includes its signing secret (whsec_...). It is shown once. Store it with your other secrets; you need it to verify deliveries.

Responding to deliveries​

  • Answer with any 2xx status within 10 seconds. Anything else, or no answer in time, counts as a failure.
  • Verify the signature, record the event, answer 200, and do the real work afterwards (on a queue). Slow work in the request handler is the most common cause of timeouts.
  • Deliveries can arrive more than once and out of order. Use Vehiso-Event-Id to ignore an event you have already processed, and compare the object's updated_at with what you hold before overwriting newer data with older.

Retries​

A failed delivery is retried with increasing delays after the previous attempt:

Retry1234567
Delay1 minute5 minutes30 minutes2 hours6 hours12 hours24 hours

Automatic disabling​

An endpoint whose deliveries have all failed for 3 days straight is disabled, and the dealer is emailed. Fix the receiver, then re-enable the endpoint in the DMS (or with PATCH /v1/webhook-endpoints/{id}), and use /v1/events to fetch what was missed while it was down.

The DMS keeps a log of every delivery with the request and response, and can resend one.

Verifying signatures​

Anyone can send a POST to your endpoint, so check every delivery's signature before trusting it. The Vehiso-Signature header looks like this:

Vehiso-Signature: t=1790517731,v1=c33e172510208dba13b06a08a10d9bc7a8f8fe0d90f33030ddf3e13741473f47

To verify it:

  1. Split the header on , and each part on the first =. t is a Unix timestamp in seconds; v1 is the signature.
  2. Build the signed payload: the timestamp, a full stop, and the raw request body exactly as received, "<t>.<raw body>".
  3. Compute an HMAC-SHA256 of that payload, keyed with your endpoint's whole signing secret (including the whsec_ prefix), and hex-encode it.
  4. Compare it with v1 in constant time. If there is more than one v1, accept the delivery if any of them matches.
  5. Reject the delivery if t is more than 5 minutes away from your server's clock. This stops an old, captured delivery from being replayed.

Use the raw body bytes. If your framework parses JSON first and you re-serialise it, whitespace and key order change and the signature will not match.

import crypto from 'node:crypto';
import express from 'express';

const TOLERANCE_SECONDS = 5 * 60;

export function verifyVehisoSignature(rawBody, header, secret, now = Date.now() / 1000) {
if (!header) return false;

let timestamp;
const signatures = [];
for (const part of header.split(',')) {
const i = part.indexOf('=');
const key = part.slice(0, i).trim();
const value = part.slice(i + 1).trim();
if (key === 't') timestamp = value;
if (key === 'v1') signatures.push(value);
}

if (!/^\d+$/.test(timestamp ?? '') || signatures.length === 0) return false;
if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false;

const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest();

return signatures.some((signature) => {
const received = Buffer.from(signature, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}

const app = express();

// express.raw keeps the body as a Buffer, exactly as it was sent.
app.post('/webhooks/vehiso', express.raw({type: 'application/json'}), (req, res) => {
const ok = verifyVehisoSignature(
req.body,
req.get('Vehiso-Signature'),
process.env.VEHISO_WEBHOOK_SECRET,
);
if (!ok) return res.sendStatus(400);

const event = JSON.parse(req.body.toString('utf8'));
// Record event.id, queue the work, then answer quickly.
res.sendStatus(200);
});

app.listen(3000);

Test your verification​

Use these values to check your code before pointing a real endpoint at it. With the tolerance check disabled (or your clock set to the timestamp), this must verify:

Secretwhsec_TESTSECRET
Timestamp1790517731
Raw body{"id":"evt_2c9d1f4e-7a3b-4e8c-9d6f-1b2a3c4d5e6f","type":"ping","created_at":"2026-09-27T14:02:11Z","data":{"object":{}}}
Headert=1790517731,v1=c33e172510208dba13b06a08a10d9bc7a8f8fe0d90f33030ddf3e13741473f47

Then send a real ping with Send test in the DMS or POST /v1/webhook-endpoints/{id}/test.

Rolling the signing secret​

If a signing secret leaks, roll it in the DMS or with POST /v1/webhook-endpoints/{id}/roll-secret. The new secret is shown once. Update your receiver straight away.

Catching up with /v1/events​

Events are kept for 30 days and can be read with a key that has the events:read scope. A key only sees events for resources its other scopes allow (an enquiry.* event needs enquiries:read, for example), and a branch-restricted key only events about its branches or about no branch. See Scopes.

EndpointDoes
GET /v1/eventsLists events, oldest first
GET /v1/events/{id}Reads one event

Each event has the same shape as a webhook payload. The list pages with limit and cursor like every other list, but it is ordered by when each event happened, since events never change. It also takes:

ParameterDescription
typeComma-separated event types. vehicle.* matches a family and * every type.
created_sinceOnly events created at or after this ISO 8601 time. (updated_since is accepted as a synonym.)

After an outage, fetch everything since the last event you processed, and skip any event whose id you have already recorded; the de-duplication you already do for webhooks makes this safe.

curl "https://api.vehiso.com/v1/events?type=vehicle.*,enquiry.created&created_since=2026-09-27T09:00:00Z&limit=100" \
-H "Authorization: Bearer $VEHISO_API_KEY"

If your endpoint may have been down for longer than 30 days, re-sync from the list endpoints with updated_since instead.