Gareth Wilson Gareth Wilson

Guide to Courier Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationWorkspace outbound webhooks (environment-scoped: test vs production)
Signature headercourier-signature, formatted t=<timestamp>,signature=<hex_digest>
Signature schemeHMAC-SHA256 over {timestamp}.{JSON.stringify(body)}, keyed with the signing secret
Payload{ "type": "<event>", "data": {...} }
Event formatcolon (e.g. message:updated)
RetriesNot documented
SDK@trycourier/courier (npm), trycourier (pip)

Common events

Courier event names use colons, and message status lives inside a single event:

EventFires when
message:updatedA message's status changes (status + timestamps in data)
notification:submittedA notification is submitted
notification:submission_canceledA submission is canceled
notification:publishedA notification is published
audiences:updatedAn 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:updated and read the status and timestamps from data.

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 + "." + body from 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.