Skip to main content
Documentation

Guide

Webhooks & signature verification

A signed JSON envelope per event, the exact bytes to verify against, and what happens to an endpoint that keeps failing.

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.

EventFires when
lead.createdNew lead
lead.status_changedLead status changed
lead.assignedLead assigned
quote.sentQuote sent
quote.acceptedQuote accepted
quote.declinedQuote declined
job.createdJob created
job.stage_changedJob stage changed
job.completedJob completed
job.assignedJob assigned
appointment.bookedAppointment booked
appointment.rescheduledAppointment rescheduled
appointment.canceledAppointment canceled
invoice.paidInvoice paid
message.receivedMessage received
message.sentMessage sent
message.delivery_failedMessage delivery failed
conversation.assignedConversation assigned
call.missedCall missed
call.completedCall ended
call.recording_readyCall recording ready
call.transcript_readyCall transcript ready
warranty.issuedWarranty issued
training.requestedTraining seat requested
training.enrolledTraining seat confirmed
training.deposit_paidTraining deposit paid
training.completedTraining completed
training.certifiedTraining certificate issued
review.private_feedbackPrivate 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.

Delivery body
{
  "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.

Node.js
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

Return a 2xx quickly — anything else counts as a failure, and redirects are not followed. A single success resets the counter; after 10 consecutive failures the endpoint is paused automatically and stays off until it is resumed from Settings → Integrations → Webhooks. Your URL must be 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.