# Webhooks

CarFleet POSTs to your URL when something happens in a workspace. Register an endpoint in
**Settings → API & MCP**.

## Before you write any code

Two facts decide the shape of your receiver. Both are cheap to design for 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. Use the data in the
payload, not the order of arrival, to decide what is true.

## The request

```http
POST https://your-server.example/carfleet
content-type: application/json
carfleet-event: booking.confirmed
carfleet-delivery: 6f1c…            ← same as `id` in the body
carfleet-signature: t=1760000000,v1=9a3f…

{
  "id": "6f1c…",
  "event": "booking.confirmed",
  "createdAt": "2026-08-18T09:14:03.221Z",
  "data": { "bookingId": "…", "customerId": "…", "vehicleId": "…" }
}
```

## Verifying the signature

Do this before you trust anything in the body. Your 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.

```js
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 against the **raw** body, before any JSON parsing. Re-serialising changes the bytes and the
signature will not match.

## What we expect back

Answer **2xx** and answer quickly — we wait 10 seconds. Do the work afterwards; acknowledge first.

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.confirmed`, `booking.completed`, `booking.delivery_due`, `booking.return_due` |
| Money | `payment.captured`, `payment.refunded`, `payment.deposit_held`, `payment.canceled`, `invoice.overdue` |
| Quotes | `quote.sent`, `quote.viewed`, `quote.accepted`, `quote.declined`, `quote.expired` |
| Sales | `lead.created`, `deal.created`, `deal.stage_changed` |
| Fleet | `vehicle.maintenance_due`, `vehicle.document_expiring`, `vehicle.idle` |
| Operations | `delivery.assigned`, `task.assigned`, `task.due_soon`, `task.overdue`, `inbox.message_received`, `inbox.assigned` |
| Investors | `investor.payout_ready`, `investor.payout_generated`, `investor.report_due` |
| Documents | `document.esign_timeout` |
| **Health** | **`integration.failed`** |

### `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.

```json
{
  "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.
