Guide to Courier Webhooks: Features and Best Practices
Courier outbound webhooks notify your systems about notification-delivery activity: a message's status changes, a notification is submitted or published. If you're building on Courier, webhooks are how you react to delivery events without polling.
This guide covers how Courier webhooks work, the events you'll handle (there's an important correction on message events), how to verify the courier-signature, and the best practices for production.
What are Courier webhooks?
Courier outbound webhooks are HTTP POSTs delivered to endpoints you configure per workspace environment. The payload envelope is { "type": "<event>", "data": {...} }, and each delivery is signed with a courier-signature header.
An important correction up front: Courier does not emit per-status message events. There's no message:delivered, message:opened, or message:clicked. Instead, a single message:updated event carries the status and timestamps in data.
Courier webhook features
| Feature | Details |
|---|---|
| Configuration | Workspace outbound webhooks (environment-scoped: test vs production) |
| Signature header | courier-signature, formatted t=<timestamp>,signature=<hex_digest> |
| Signature scheme | HMAC-SHA256 over {timestamp}.{JSON.stringify(body)}, keyed with the signing secret |
| Payload | { "type": "<event>", "data": {...} } |
| Event format | colon (e.g. message:updated) |
| Retries | Not documented |
| SDK | @trycourier/courier (npm), trycourier (pip) |
Common events
Courier event names use colons, and message status lives inside a single event:
| Event | Fires when |
|---|---|
message:updated | A message's status changes (status + timestamps in data) |
notification:submitted | A notification is submitted |
notification:submission_canceled | A submission is canceled |
notification:published | A notification is published |
audiences:updated | An audience updates |
The full set also includes audiences:user:matched, audiences:user:unmatched, and audiences:calculated. For delivery status, read data on message:updated, don't look for per-status events.
Setting up Courier webhooks
Configure outbound webhooks per workspace, scoped to an environment (test vs production). Copy the signing secret into your environment for verification.
Securing Courier webhooks
Each delivery carries a courier-signature header formatted t=<timestamp>,signature=<hex>. To verify, build {timestamp}.{JSON.stringify(body)}, compute an HMAC-SHA256 with the signing secret, hex-encode it, and compare against the signature value in constant time. Validate the timestamp tolerance too.
const crypto = require("crypto");
const SECRET = process.env.COURIER_WEBHOOK_SECRET;
function verify(rawBody, signatureHeader) {
const params = Object.fromEntries(
(signatureHeader || "").split(",").map((kv) => kv.split("="))
);
const timestamp = params.t;
// Courier signs timestamp + "." + JSON.stringify(body)
const message = `${timestamp}.${rawBody.toString()}`;
const expected = crypto.createHmac("sha256", SECRET).update(message).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(params.signature || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.headers["courier-signature"])) {
return res.sendStatus(401);
}
res.sendStatus(200); // respond quickly, process async
const { type, data } = JSON.parse(req.body);
processQueue.add({ type, data });
});
The same check in Python:
import hashlib
import hmac
import os
SECRET = os.environ["COURIER_WEBHOOK_SECRET"].encode()
def verify(raw_body: bytes, signature_header: str) -> bool:
params = dict(kv.split("=", 1) for kv in (signature_header or "").split(",") if "=" in kv)
message = f"{params.get('t','')}.{raw_body.decode()}".encode()
expected = hmac.new(SECRET, message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, params.get("signature", ""))
Courier's signed message uses JSON.stringify(body), so verifying against the raw body (the exact bytes Courier stringified) is the safe approach; if you reparse and re-stringify, match Courier's serialization.
Courier webhook limitations and pain points
There are no per-status message events
The Problem: Handlers built to subscribe to message:delivered / message:opened / message:clicked receive nothing, those events don't exist. All message status changes come through message:updated.
Why It Happens: Courier models message status as a single updating event, not discrete per-status events.
Workarounds:
- Subscribe to
message:updatedand read the status and timestamps fromdata.
How Hookdeck Can Help: Hookdeck's filters can route message:updated events by their data.status, giving you per-status routing even though Courier emits one event.
The signed message includes the timestamp and stringified body
The Problem: The HMAC is over {timestamp}.{JSON.stringify(body)}, not the body alone. Signing the body by itself, or mismatching the serialization, fails.
Why It Happens: Courier binds the timestamp and uses JSON.stringify(body).
Workarounds:
- Build
timestamp + "." + bodyfrom the raw body and verify; validate the timestamp tolerance.
How Hookdeck Can Help: Hookdeck verifies the courier-signature at the edge, so your app receives pre-verified events without reconstructing the signed message.
Undocumented retries
The Problem: Courier doesn't document retry behavior, so you can't assume a failed delivery will be retried on a known schedule.
Why It Happens: The retry policy isn't published.
Workarounds:
- Persist events on receipt and reconcile via the API for anything critical.
How Hookdeck Can Help: Hookdeck durably stores and retries deliveries on a schedule you control.
Environment scoping
The Problem: Webhooks are scoped per environment (test vs production), so events for one environment won't reach the other's endpoint.
Why It Happens: Courier separates test and production.
Workarounds:
- Configure a webhook per environment and route accordingly.
How Hookdeck Can Help: Hookdeck can receive both environments at one ingestion point and route by environment.
Best practices
Verify over timestamp.body
Build {timestamp}.{body} from the raw body, HMAC-SHA256 with the signing secret, hex-encode, and compare against the signature value in constant time. Validate the timestamp.
Use message:updated for status
Read message status and timestamps from data on message:updated; don't look for per-status events.
Respond quickly, process asynchronously
Return 200 fast and defer heavy work to a queue. See why to process webhooks asynchronously.
Persist and reconcile
Since retries aren't documented, log events and reconcile via the API for anything critical.
Make Courier webhooks production-ready
Hookdeck verifies courier-signature, routes message:updated by status, and durably queues every event
Conclusion
Courier outbound webhooks deliver { type, data } events verified with a courier-signature HMAC over {timestamp}.{JSON.stringify(body)}. The key correction: message status comes through a single message:updated event (no per-status events), so read data. Verify against the raw body, respond quickly, and persist since retries aren't documented.
Hookdeck verifies the signature, can route message:updated by status, and durably queues every event at the edge, so your app only ever processes verified events.
Get started with Hookdeck for free and handle Courier webhooks reliably in minutes.