Documentation
Guide
Webhooks & signature verification
Subscribe an endpoint and Service VIN POSTs a signed JSON envelope to it whenever a matching event fires. Deliveries cost you no rate limit and arrive within seconds. A shop may subscribe up to 10 endpoints.
| Event | Fires when |
|---|---|
lead.created | New lead |
lead.status_changed | Lead status changed |
lead.assigned | Lead assigned |
quote.sent | Quote sent |
quote.accepted | Quote accepted |
quote.declined | Quote declined |
job.created | Job created |
job.stage_changed | Job stage changed |
job.completed | Job completed |
job.assigned | Job assigned |
appointment.booked | Appointment booked |
appointment.rescheduled | Appointment rescheduled |
appointment.canceled | Appointment canceled |
invoice.paid | Invoice paid |
message.received | Message received |
message.sent | Message sent |
message.delivery_failed | Message delivery failed |
conversation.assigned | Conversation assigned |
call.missed | Call missed |
call.completed | Call ended |
call.recording_ready | Call recording ready |
call.transcript_ready | Call transcript ready |
warranty.issued | Warranty issued |
training.requested | Training seat requested |
training.enrolled | Training seat confirmed |
training.deposit_paid | Training deposit paid |
training.completed | Training completed |
training.certified | Training certificate issued |
review.private_feedback | Private feedback received |
A test delivery sent from the dashboard carries the event name ping, which is not subscribable — handle it, or ignore it, but do not treat it as unknown.
Two headers come with every POST: X-ServiceVIN-Event so you can route before parsing, and X-ServiceVIN-Signature so you can trust it. Switch on version — it changes only on a breaking envelope change.
{
"version": "2026-07",
"event": "lead.created",
"shop_id": "9b2f1c6e-4a77-4d2b-9f31-0f1c9a8e5d20",
"occurred_at": "2026-09-05T14:20:00.000Z",
"data": {
"aggregateType": "lead",
"aggregateId": "6f1c37a2-91b8-4d0e-8a55-2c7e4b1d9f03"
}
}The header is t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256(secret, t + "." + rawBody). Verify against the raw bytes you received, before parsing — re-serializing JSON changes the bytes and the signature will not match. Compare in constant time, and reject anything older than about five minutes so a captured delivery cannot be replayed at you.
import crypto from "node:crypto";
// rawBody MUST be the exact bytes received — verify before JSON.parse.
function verify(rawBody, header, secret) {
const [tPart, v1Part] = header.split(",");
const t = tPart.slice(2);
const v1 = v1Part.slice(3);
// Reject replays: anything older than five minutes.
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}Retries and auto-pause
https and publicly resolvable; it is re-checked on every delivery, so a tunnel that points at a private address will be refused.One event can publish several external ones. job.completed exists because "when a job finishes" is the single most-automated moment in a shop, and making every subscriber filter job.stage_changed for a completed stage is a worse contract. Both still fire, so a subscriber that wants every move keeps getting every move.