Webhooks
Register an endpoint and we'll POST a signed event to it whenever trip, quote or invoice activity happens — so your systems stay in sync without polling.
Events
Subscribe to any of these event types when you create an endpoint:
| Event | When it fires | Payload data |
|---|---|---|
trip.created | A trip is created (via the API, your booking pages, or an accepted quote). | Trip |
trip.updated | A trip changes — status, timing, vehicle or passenger details. | Trip |
trip.completed | A trip is marked completed. | Trip |
trip.cancelled | A trip is cancelled. | Trip |
driver.assigned | One or more drivers are assigned (or reassigned) to a trip. | Trip id + driver ids |
quote.accepted | A customer accepts a quote. | Quote |
invoice.created | An invoice is raised. | Invoice |
invoice.updated | Trips are attached to or detached from an invoice. | Invoice id, change, trip_ids |
invoice.paid | An invoice becomes fully paid. | Invoice id + status |
payment.succeeded | A payment is recorded against an invoice. | Invoice id + amount |
payment.failed | Reserved. Accepted on subscribe, but nothing emits it yet. | — |
Register an endpoint
Create a webhook with an https URL and the events you care about.
The response includes a secret — this is the only time
it's returned, so store it securely. You'll use it to verify every delivery.
Request
curl -X POST https://app.destination.dev/api/v1/webhooks \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks",
"events": ["trip.created", "trip.cancelled"]
}'
Response
{
"data": {
"endpoint_id": 1,
"url": "https://example.com/webhooks",
"events": ["trip.created", "trip.cancelled"],
"status": "enabled",
"secret": "whsec_182022b9888a6d2f2307..."
},
"meta": { "request_id": "req_1a2b3c4d5e6f7a8b" }
}
The event payload
Each delivery is a POST with a JSON body and these headers:
X-Destination-Event— the event type.X-Destination-Delivery— the unique event id (same value asidin the body).X-Destination-Signature—t=<timestamp>,v1=<signature>.
Respond with any 2xx status to acknowledge receipt.
t, not the body's created.
Each delivery attempt is signed afresh, so a retry carries a newer t than the
created timestamp of the original event. The id stays the same across
every attempt — that is the value to de-duplicate on.
POST body
{
"id": "evt_d49eba5792d7699d5a5d",
"type": "trip.created",
"created": 1785148251,
"data": {
"trip_id": 4821,
"reference": "TRIP-4821",
"status": "confirmed",
"pickup_at": "2026-08-02 09:15:00",
"customer_name": "Ada Lovelace"
},
"meta": { "business_id": 7 }
}
Verifying signatures
Before trusting a delivery, recompute the signature and compare it. Take the
t and v1 values from the
X-Destination-Signature header, then compute
HMAC-SHA256(t + "." + rawBody, secret) and check it equals
v1. Always compare using a constant-time function.
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_DESTINATION_SIGNATURE'];
parse_str(strtr($header, ',', '&'), $p); // t=..., v1=...
$expected = hash_hmac(
'sha256', $p['t'] . '.' . $payload, $secret
);
if (!hash_equals($expected, $p['v1'])) {
http_response_code(400);
exit;
}
$event = json_decode($payload, true);
const crypto = require("crypto");
// rawBody must be the exact bytes received.
const header = req.headers["x-destination-signature"];
const p = Object.fromEntries(
header.split(",").map((kv) => kv.split("="))
);
const expected = crypto
.createHmac("sha256", secret)
.update(p.t + "." + rawBody)
.digest("hex");
if (
!crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(p.v1)
)
) {
return res.status(400).end();
}
const event = JSON.parse(rawBody);
Retries & delivery
The first attempt is made immediately, as the event happens. If your endpoint doesn't
return a 2xx, we retry with exponential backoff — roughly 1, 2, 4, 8 and 16
minutes — for up to six attempts in total, then mark the delivery failed.
Inspect recent attempts with GET /api/v1/webhooks/{id}/deliveries, which
reports status, attempt, next_attempt_at and the
last_error for each.
Those intervals are a floor, not a guarantee: retries are drained by a background worker,
so a delivery becomes due at the stated time and is sent on the next tick. Deliveries whose
endpoint has since been deleted or disabled are marked failed rather than
retried.
id so retries are harmless.