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

```javascript
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:

```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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Persist and reconcile

Since retries aren't documented, log events and reconcile via the API for anything critical.

## 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Courier webhooks reliably in minutes.