Skip to content

Webhooks

Register an HTTPS endpoint and we POST to it every time one of your deliveries moves. It saves you polling, and it is quickest exactly when it matters — when you have the most deliveries open.

POST /v1/webhooks
Terminal window
curl -u "$COURIER_KEY:$COURIER_SECRET" "$COURIER_API/v1/webhooks" \
-H 'Content-Type: application/json' \
-d '{
"url": "https://kitchen.example/hooks/courier",
"events": ["job.courier_assigned", "job.picked_up", "job.delivered", "job.cancelled", "job.failed"]
}'
Field
url Required. Must be https.
events The events to send. Leave it out, or send [], for all of them. An unknown name is refused with webhook.unknown_event.

201 Created:

{
"id": "0199d2c7-08aa-7c3e-a51f-3e2b6d0c9f44",
"url": "https://kitchen.example/hooks/courier",
"status": "active",
"events": ["job.courier_assigned", "job.picked_up", "job.delivered", "job.cancelled", "job.failed"],
"created_at": "2026-10-10T11:58:00+00:00",
"last_succeeded_at": null,
"last_failed_at": null,
"consecutive_failures": 0,
"suspension_reason": null,
"signing_secret": "whsec_…"
}

An endpoint belongs to the environment of the key that registered it: register with a ck_test_ key and you receive sandbox events only.

Event Sent when
job.booked A job is booked.
job.courier_assigned A courier took it. Sent again if the job is handed to another courier.
job.picked_up The courier collected it.
job.delivered It was handed over.
job.returned It couldn’t be delivered and was brought back to you.
job.cancelled It was called off.
job.failed No courier took it before the dispatch deadline.
job.status_changed Our operations team set its status by hand.
payment_method.dead A card on your account stopped working. Not about one job.

See The life of a job for how these fit together.

POST /hooks/courier HTTP/1.1
Content-Type: application/json
Courier-Event: job.delivered
Courier-Delivery: 0199d2d0-4c71-7b05-9e8a-1a6f2b3c4d5e
Courier-Signature: t=1760099464,v1=8c1f4e0b6a2d97c35e41f0a8b7d2c6e9134a5f08d2b7c6e1f9a0b3c4d5e6f718
Header
Courier-Event The event name, so you can route before parsing.
Courier-Delivery This delivery’s id. The same on every retry — de-duplicate on it.
Courier-Signature The signature. Verify it before trusting anything in the body.

Every body has the same envelope:

{
"type": "job.delivered",
"occurred_at": "2026-10-10T12:31:04+00:00",
"environment": "test",
"data": {
"occurredAt": "2026-10-10T12:31:04+00:00",
"environment": 1,
"jobId": "0199d2c5-1f3a-7d10-8b2e-6c4a9e0f2d31",
"reference": "JOB-7F3K2Q",
"merchant": "0199a001-5e2f-7a11-b3c4-9d8e7f6a5b4c",
"market": "0199a000-1b2c-7d3e-8f40-5a6b7c8d9e0f",
"courier": "0199b3e1-2a3b-7c4d-9e5f-6a7b8c9d0e1f",
"currency": "GBP",
"priceAmountMinor": 918,
"taxAmountMinor": 153,
"proof": {
"outcome": "handed_to_recipient",
"recipientName": "Alex",
"hasPhoto": true,
"hasSignature": false
}
}
}
  • Switch on type. It is the one field every webhook has in the same place.
  • occurred_at is when it happened, not when it was sent. Use it to order what you receive.
  • environment is test or live, so a misrouted event is obvious.
  • data is the event itself.

What data carries besides jobId, reference and merchant:

Event Also in data
job.booked market, currency, priceAmountMinor, taxAmountMinor, scheduledFor
job.courier_assigned, job.picked_up courier
job.delivered market, courier, currency, priceAmountMinor, taxAmountMinor, proof
job.returned market, courier, currency, priceAmountMinor, taxAmountMinor, reason
job.cancelled reason, and assignedCourier if someone was on the way
job.failed reason
job.status_changed from, to, reason
payment_method.dead paymentMethodId, brand, lastFour, reason — and no jobId

Answer with any 2xx within 10 seconds. Do the work afterwards — put the event on a queue and reply straight away. Anything else, a timeout, or a connection error counts as a failure and is retried.

Your endpoint is on the internet, so anyone can post to it. The signature is how you know a request came from us. It is the scheme Stripe uses:

  1. Split Courier-Signature on commas into t (a Unix timestamp) and v1 (a hex digest).
  2. Compute HMAC-SHA256 of {t}.{body} with your signing secret — the timestamp, a full stop, and the exact bytes of the body as received.
  3. Compare it with v1 in constant time.
  4. Refuse it if t is more than five minutes from your clock, so a captured request can’t be replayed.
verify.mjs
import { createHmac, timingSafeEqual } from 'node:crypto'
/**
* Whether a webhook came from Courier.
*
* @param {string} secret The endpoint's signing secret, from when you registered it.
* @param {Buffer|string} body The request body exactly as received — before any JSON parsing.
* @param {string} header The Courier-Signature header.
*/
export function verifyCourierSignature(secret, body, header, { toleranceSeconds = 300, now = Date.now() / 1000 } = {}) {
const parts = Object.fromEntries(
header.split(',').map((part) => {
const [key, ...value] = part.trim().split('=')
return [key, value.join('=')]
}),
)
const timestamp = Number(parts.t)
if (!Number.isInteger(timestamp) || !parts.v1) return false
// Older than the tolerance is refused, so a captured request cannot be replayed later.
if (Math.abs(now - timestamp) > toleranceSeconds) return false
const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(parts.v1)
return a.length === b.length && timingSafeEqual(a, b)
}

The most common mistake is verifying a body your framework has already parsed and re-serialised. Read the raw body first, verify it, then parse it.

Delivery is at least once. If your endpoint fails, we try again — ten attempts in all, waiting 10 seconds after the first failure and doubling each time, about an hour and a half altogether. Every retry sends exactly the same body with the same Courier-Delivery, so you may receive an event twice: record the ids you have handled and ignore repeats.

  • A 410 Gone stops retries for that delivery at once. A 404 doesn’t — it is more often a route not deployed yet than an endpoint withdrawn.
  • After 20 failures in a row, the endpoint is suspended and nothing more is sent to it until you resume it. Events raised while it was suspended are not sent on resuming; read your jobs to catch up.
  • Events can arrive a few seconds after they happen, and not necessarily in order. Use occurred_at, and read the job when you need to know where it stands now.
GET /v1/webhooks Your endpoints, with status (active or suspended), the last success and failure, and consecutive_failures.
GET /v1/webhooks/{id}/deliveries The last 50 deliveries to an endpoint: event, status, attempts, the response code and the last error.
POST /v1/webhooks/{id}/resume Switch a suspended endpoint back on.
POST /v1/webhooks/{id}/rotate-secret Issue a new signing secret. The old one stops working at once; the new one is in this response only.
DELETE /v1/webhooks/{id} Stop sending to an endpoint.