Webhooks
CarFleet POSTs to your URL when something happens in a workspace, so you are told instead of polling for it. Register an endpoint in Settings → API & MCP.
Two facts to design for
Both are cheap to handle now and expensive to discover in production.
Delivery is at-least-once
If your server commits the event and then times out, that looks exactly like a failure from our side and we will send it again. Every payload carries an id that is stable across retries — store it and ignore a repeat. Nobody can promise exactly-once over HTTP; we would rather say so than let you find out.
Order is not guaranteed
A retried event can arrive after a newer one. Decide what is true from the data in the payload, never from the order of arrival.
The request
POST https://your-server.example/carfleet
content-type: application/json
carfleet-event: booking.confirmed
carfleet-delivery: 6f1c…
carfleet-signature: t=1760000000,v1=9a3f…
{
"id": "6f1c…",
"event": "booking.confirmed",
"createdAt": "2026-08-18T09:14:03.221Z",
"data": { "bookingId": "…", "customerId": "…", "vehicleId": "…" }
}carfleet-delivery is the same value as id in the body, so a proxy log is enough to match a delivery without parsing it.
Verifying the signature
Do this before you trust anything in the body. The signing secret is shown once, when you create the endpoint.
The signed string is <timestamp>.<raw body> — the timestamp is inside the signature on purpose. Signing the body alone would let anyone who captured one delivery replay it for ever.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, rawBody, header, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(",").map((kv) => {
const i = kv.indexOf("=");
return [kv.slice(0, i), kv.slice(i + 1)];
}),
);
const t = Number(parts.t);
if (!Number.isFinite(t)) return false;
// Two-sided: a clock running ahead of ours is ordinary skew, not an attack.
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}Verify the raw body
Before any JSON parsing. Re-serialising changes the bytes and the signature will not match — this is the single most common reason a correct implementation appears broken.
What we expect back
Answer 2xx, and answer quickly — we wait ten seconds. Acknowledge first, do the work afterwards.
Anything else is a failure and we retry: 1m, 5m, 15m, 1h, 3h, 6h, 12h, 24h — eight attempts, then the delivery is marked failed and left alone.
A 3xx is a failure, not a success. We do not follow redirects; following one would post a signed payload to a host you never nominated.
An endpoint that fails twenty times in a row is switched off, with the time recorded, and the screen says so. Silence always has a visible reason.
Events
Every domain event CarFleet raises is available. Leave an endpoint’s event list empty to receive all of them.
| Group | Events |
|---|---|
| Bookings | booking.confirmedbooking.completedbooking.delivery_duebooking.return_duebooking.extendedbooking.ending_soon |
| Money | payment.capturedpayment.refundedpayment.deposit_heldpayment.canceledinvoice.issuedinvoice.due_sooninvoice.overdueinstallment.due_sooninstallment.overduefine.created |
| Quotes | quote.sentquote.viewedquote.acceptedquote.declinedquote.expired |
| Sales | lead.createddeal.createddeal.stage_changed |
| Fleet | vehicle.maintenance_duevehicle.document_expiringvehicle.idle |
| Operations | delivery.assignedtask.assignedtask.due_soontask.overdueinbox.message_receivedinbox.assigned |
| Investors | investor.payout_readyinvestor.payout_generatedinvestor.report_due |
| Documents | document.esign_timeout |
| Health | integration.failed |
Every event names its fleet
A workspace can run several owners’ cars. Any event about a car or a booking carries fleet: { id, code, name } — the same object as GET /v1/fleets — and vehicle: { id, plate }. A customer is only ever { id, name }.
Events the calendar raises
Raised by a sweep that runs every minute, not by a click. Each carries a dedupeKey naming the occurrence — the same invoice seven days late always has the same key — and an occurrence is sent once. data has the same fields the API returns for the same thing.
| Event | When | data |
|---|---|---|
invoice.issued | an invoice is issued | invoiceId, number, purpose, issueDate, dueDate, total, balance, customer, booking, vehicle, fleet |
invoice.due_soon | due within N days | the same + daysToDue |
invoice.overdue | unpaid past due — at 1, 7 and 30 days | the same + daysOverdue |
installment.due_soon | a scheduled month (lease, rent-to-own, monthly) due within N days | installmentId, bookingId, bookingNumber, number, of, dueDate, amount, invoice, customer, vehicle, fleet, daysToDue |
installment.overdue | unpaid past due — at 1, 7 and 30 days | the same + daysOverdue |
booking.ending_soon | an active booking ends within N days (again after an extension moves the end) | bookingId, number, type, pickupAt, dropoffAt, originalDropoffAt, extensionsCount, monthlyAmount, customer, vehicle, fleet, daysLeft |
booking.extended | a booking is extended | the same + addedDays, extensionAmount |
fine.created | a fine or toll is recorded (it may have happened weeks earlier) | fineId, kind, type, date, amount, authority, violation, number, status, customer, booking, vehicle, fleet |
vehicle.document_expiring | registration, insurance or a permit lapses within 30 days — at 30, 7 and 0 | vehicleId, plate, make, model, docType, detail, expiresAt, daysLeft, fleet |
N is 3 days unless the workspace sets it (Settings → API & MCP → Reminders ahead, 1–60). Amounts include VAT.
integration.failed — the one worth wiring first
Fires when one of the workspace’s own integrations has stopped working: e-signature, accounting, a payment gateway, WhatsApp.
{
"event": "integration.failed",
"data": {
"integration": "zoho_sign",
"detail": "invalid_code",
"hint": "reconnect_required",
"failingSince": "2026-08-15T10:58:33.173Z"
}
}detail is the provider’s own words, verbatim. hint is what to do about it.
We ship this because we have twice needed it ourselves: e-signature was silent for twelve days and WhatsApp for two, and in both cases the system knew and nobody was told. Point it at whatever you already watch.
Re-sent once a day while the integration stays broken, so a long outage is a heartbeat rather than a flood — and so it cannot be silenced by a stale flag, which is exactly how the WhatsApp alert made itself unfireable.
Money in payloads
Amounts are fils — AED × 100. 784140 is AED 7,841.40. There are no decimal amounts anywhere in this API.
Plain text, for an agent: /docs/webhooks.md.