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
| Resource | Event types |
|---|---|
| Vehicles | vehicle.created, vehicle.updated, vehicle.status_changed, vehicle.deleted |
| Batch uploads | vehicle_batch.completed |
| Enquiries | enquiry.created, enquiry.updated, enquiry.deleted |
| Customers | customer.created, customer.updated, customer.deleted |
| Deals | deal.created, deal.status_changed |
| Appointments | appointment.created, appointment.updated, appointment.cancelled |
| Workshop | job_card.created, job_card.updated |
| Website themes | theme_generation.started, theme_generation.succeeded, theme_generation.failed |
| Testing | ping |
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:
| Header | Description |
|---|---|
Vehiso-Signature | t=<unix timestamp>,v1=<signature>. See Verifying signatures. |
Vehiso-Event-Id | The event's id. Use it to ignore duplicates. |
Vehiso-Event-Type | The event's type, so you can route before parsing the body. |
Content-Type | application/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:
| Endpoint | Does |
|---|---|
GET /v1/webhook-endpoints | Lists endpoints |
POST /v1/webhook-endpoints | Creates an endpoint |
GET, PATCH, DELETE /v1/webhook-endpoints/{id} | Reads, changes or deletes one |
POST /v1/webhook-endpoints/{id}/roll-secret | Issues a new signing secret |
POST /v1/webhook-endpoints/{id}/test | Sends 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
2xxstatus 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-Idto ignore an event you have already processed, and compare the object'supdated_atwith what you hold before overwriting newer data with older.
Retries
A failed delivery is retried with increasing delays after the previous attempt:
| Retry | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| Delay | 1 minute | 5 minutes | 30 minutes | 2 hours | 6 hours | 12 hours | 24 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:
- Split the header on
,and each part on the first=.tis a Unix timestamp in seconds;v1is the signature. - Build the signed payload: the timestamp, a full stop, and the raw request body exactly as received,
"<t>.<raw body>". - Compute an HMAC-SHA256 of that payload, keyed with your endpoint's whole signing secret (including the
whsec_prefix), and hex-encode it. - Compare it with
v1in constant time. If there is more than onev1, accept the delivery if any of them matches. - Reject the delivery if
tis 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.
- Node.js
- PHP
- Python
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);
<?php
function verify_vehiso_signature(string $payload, ?string $header, string $secret, int $tolerance = 300): bool
{
if ($header === null || $header === '') {
return false;
}
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't') {
$timestamp = $value;
} elseif ($key === 'v1') {
$signatures[] = $value;
}
}
if ($timestamp === null || !ctype_digit($timestamp) || $signatures === []) {
return false;
}
if (abs(time() - (int) $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
// The raw body, before anything decodes it.
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_VEHISO_SIGNATURE'] ?? null;
if (!verify_vehiso_signature($payload, $header, getenv('VEHISO_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$event = json_decode($payload, true);
// Record $event['id'], queue the work, then answer quickly.
http_response_code(200);
In Laravel, read the raw body with $request->getContent() and the header with $request->header('Vehiso-Signature').
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
TOLERANCE_SECONDS = 5 * 60
def verify_vehiso_signature(payload: bytes, header: str | None, secret: str) -> bool:
if not header:
return False
timestamp = None
signatures = []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t":
timestamp = value
elif key == "v1":
signatures.append(value)
if not timestamp or not timestamp.isdigit() or not signatures:
return False
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode(), timestamp.encode() + b"." + payload, hashlib.sha256
).hexdigest()
return any(hmac.compare_digest(expected, signature) for signature in signatures)
app = Flask(__name__)
@app.post("/webhooks/vehiso")
def vehiso_webhook():
payload = request.get_data() # raw bytes, exactly as sent
if not verify_vehiso_signature(
payload, request.headers.get("Vehiso-Signature"), os.environ["VEHISO_WEBHOOK_SECRET"]
):
abort(400)
event = json.loads(payload)
# Record event["id"], queue the work, then answer quickly.
return "", 200
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:
| Secret | whsec_TESTSECRET |
| Timestamp | 1790517731 |
| Raw body | {"id":"evt_2c9d1f4e-7a3b-4e8c-9d6f-1b2a3c4d5e6f","type":"ping","created_at":"2026-09-27T14:02:11Z","data":{"object":{}}} |
| Header | t=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.
| Endpoint | Does |
|---|---|
GET /v1/events | Lists 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:
| Parameter | Description |
|---|---|
type | Comma-separated event types. vehicle.* matches a family and * every type. |
created_since | Only 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.