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.
Registering an endpoint
Section titled “Registering an endpoint”POST /v1/webhookscurl -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.
Events
Section titled “Events”| 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.
What we send
Section titled “What we send”POST /hooks/courier HTTP/1.1Content-Type: application/jsonCourier-Event: job.deliveredCourier-Delivery: 0199d2d0-4c71-7b05-9e8a-1a6f2b3c4d5eCourier-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_atis when it happened, not when it was sent. Use it to order what you receive.environmentistestorlive, so a misrouted event is obvious.datais 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 |
Answering
Section titled “Answering”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.
Verifying the signature
Section titled “Verifying the signature”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:
- Split
Courier-Signatureon commas intot(a Unix timestamp) andv1(a hex digest). - Compute HMAC-SHA256 of
{t}.{body}with your signing secret — the timestamp, a full stop, and the exact bytes of the body as received. - Compare it with
v1in constant time. - Refuse it if
tis more than five minutes from your clock, so a captured request can’t be replayed.
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)}import hashlibimport hmacimport time
def verify_courier_signature(secret: str, body: bytes, header: str, tolerance: int = 300, now: float | None = None) -> bool: """Whether a webhook came from Courier.
body is the request body exactly as received, before any JSON parsing. header is the Courier-Signature header. """ parts = dict(part.strip().split("=", 1) for part in header.split(",") if "=" in part) try: timestamp = int(parts["t"]) presented = parts["v1"] except (KeyError, ValueError): return False
# Older than the tolerance is refused, so a captured request cannot be replayed later. if abs((time.time() if now is None else now) - timestamp) > tolerance: return False
signed = f"{timestamp}.".encode() + body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, presented)using System.Globalization;using System.Security.Cryptography;using System.Text;
public static class CourierWebhook{ /// <summary>Whether a webhook came from Courier.</summary> /// <param name="secret">The endpoint's signing secret, from when you registered it.</param> /// <param name="body">The request body exactly as received — read it as a string before deserialising.</param> /// <param name="header">The Courier-Signature header.</param> public static bool Verify(string secret, string body, string header, DateTimeOffset? now = null, TimeSpan? tolerance = null) { long? timestamp = null; string? presented = null;
foreach (string part in header.Split(',', StringSplitOptions.TrimEntries)) { if (part.StartsWith("t=", StringComparison.Ordinal) && long.TryParse(part.AsSpan(2), NumberStyles.None, CultureInfo.InvariantCulture, out long t)) timestamp = t; else if (part.StartsWith("v1=", StringComparison.Ordinal)) presented = part[3..]; }
if (timestamp is not { } sentAt || presented is null) return false;
// Older than the tolerance is refused, so a captured request cannot be replayed later. TimeSpan age = (now ?? DateTimeOffset.UtcNow) - DateTimeOffset.FromUnixTimeSeconds(sentAt); if (age.Duration() > (tolerance ?? TimeSpan.FromMinutes(5))) return false;
byte[] digest = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{sentAt}.{body}")); string expected = Convert.ToHexString(digest).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(presented)); }}<?php
/** * Whether a webhook came from Courier. * * $body is the request body exactly as received: file_get_contents('php://input'). * $header is the Courier-Signature header: $_SERVER['HTTP_COURIER_SIGNATURE']. */function courier_verify_signature(string $secret, string $body, string $header, int $tolerance = 300, ?int $now = null): bool{ $parts = []; foreach (explode(',', $header) as $part) { [$key, $value] = array_pad(explode('=', trim($part), 2), 2, ''); $parts[$key] = $value; } if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) { return false; }
// Older than the tolerance is refused, so a captured request cannot be replayed later. if (abs(($now ?? time()) - (int) $parts['t']) > $tolerance) { return false; }
$expected = hash_hmac('sha256', $parts['t'] . '.' . $body, $secret); return hash_equals($expected, $parts['v1']);}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.
Retries and de-duplication
Section titled “Retries and de-duplication”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 Gonestops retries for that delivery at once. A404doesn’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.
Managing endpoints
Section titled “Managing endpoints”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. |