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