# Guide to GOV.UK Notify Webhooks: Features and Best Practices

GOV.UK Notify callbacks tell your service what happened to the messages it sent through Notify. An email or text message is delivered or fails, a member of the public texts your service's number, or a letter comes back undelivered. Instead of polling Notify's API for each message, you receive the result as a `POST` to your own endpoint.

This guide covers the three kinds of callback Notify sends and what each contains, how to authenticate them with your bearer token, how Notify retries, and the practices that keep a callback receiver reliable.

## What are GOV.UK Notify callbacks?

Notify's name for its webhooks is callbacks. You can get them when:

* an email or text message you've sent is delivered or fails
* your service receives a text message
* a letter you sent is returned

To set one up, you give Notify a URL and a bearer token. Notify sends JSON in a `POST` with the token in the `Authorization` header as `Bearer <token>`. There is no signature, so the token is the only thing that tells your endpoint a request came from Notify.

None of the callbacks carries an event name. You tell them apart by their fields, and by which URL received them if you set up a different URL for each kind.

## GOV.UK Notify callback features

| Feature | Details |
| --- | --- |
| Configuration | Notify, **API integration** > **Callbacks** |
| Kinds | Delivery receipts (email and text message), received text messages, returned letters |
| Method and format | `POST`, `Content-Type: application/json` |
| Authentication | Your bearer token, sent as `Authorization: Bearer <token>` |
| Signature and timestamp header | None |
| Event name | None. Identify the kind by its fields |
| Retries | Every 5 minutes, up to 5 times |
| Timeout | 5 seconds, in Notify's sending code |
| Retried responses | Connection errors, `5xx` and `429`, in Notify's sending code. Other `4xx` aren't retried |
| Test keys | Delivery receipts work with test API keys, but not with smoke-testing numbers or addresses |
| Source IPs | Not published |

The timeout and the retry conditions come from Notify's open-source sending code (`alphagov/notifications-api`), not from the documentation page, so treat them as current behaviour rather than a promise.

## Callback payloads

### Delivery receipts

All values are strings except `template_version`, which is a number.

| Field | Format |
| --- | --- |
| `id` | Notify's ID for the notification (UUID) |
| `reference` | The reference your service sent, or `null` |
| `to` | The recipient's email address or phone number |
| `status` | `delivered`, `permanent-failure`, `temporary-failure` or `technical-failure` |
| `created_at` | When your service sent the request |
| `completed_at` | When the status last changed, or `null` |
| `sent_at` | When the notification was sent, or `null` |
| `notification_type` | `email` or `sms` |
| `template_id` | The template's ID (UUID) |
| `template_version` | The template's version number |

Built from that table, a receipt looks like this:

```json
{
  "id": "740e5834-3a29-46b4-9a6f-16142fde533a",
  "reference": "12345678",
  "to": "07700912345",
  "status": "delivered",
  "created_at": "2017-05-14T12:15:30.000000Z",
  "completed_at": "2017-05-14T12:15:30.000000Z",
  "sent_at": "2017-05-14T12:15:30.000000Z",
  "notification_type": "sms",
  "template_id": "f33517ff-2a88-4f6e-b855-c550268ce08a",
  "template_version": 1
}

```

### Received text messages

`id`, `source_number` (the sender), `destination_number` (your number), `message` and `date_received` (UTC). All values are strings.

### Returned letters

`notification_id`, `reference`, `date_sent`, `sent_by` (the email address of the team member who sent it, or `null`), `template_name`, `template_id`, `template_version`, `spreadsheet_file_name`, `spreadsheet_row_number` and `upload_letter_file_name`. Notify says it can take a few weeks to hear about a returned letter.

### A fourth kind in the code

Notify's sending code also has a complaint callback, with `notification_id`, `complaint_id`, `reference`, `to` and `complaint_date`. The documentation page doesn't mention it, so don't rely on it, but don't let it break your handler either: it shares `notification_id` with returned letters.

> **Inspect GOV.UK Notify callbacks as they arrive.** Use a [Hookdeck Inspect](https://inspect.hookdeck.com) URL as your callback URL while you send with a test API key to see the exact headers and body Notify sends, with no account or setup required.

## Setting up GOV.UK Notify callbacks

Generate a long random bearer token first, for example with `openssl rand -hex 32`.

1. Sign in to GOV.UK Notify.
2. Go to the **API integration** page.
3. Select **Callbacks**.
4. Enter a callback URL and bearer token for each kind of callback you want, and save.
5. Send an email or text message with a test API key and confirm that a delivery receipt arrives.

**With Hookdeck.** GOV.UK Notify is a supported source in Hookdeck. Its setup note reads: "Paste the bearer token you set for the callback in GOV.UK Notify (API integration > Callbacks)." Create a source with the **GOV.UK Notify** type and use its URL as the callback URL. In **Bearer Token**, paste only the token, without `Bearer `. Notify sends `Bearer <token>`, and the source checks that header. The source accepts `POST` only, which is what Notify sends. A request with a missing or wrong token still gets a `200` from Hookdeck, but Hookdeck marks it as failed verification and doesn't deliver it to your service.

For local development, `hookdeck listen 3000 govuk-notify --path /webhooks/notify` gives you a public URL that forwards to your machine.

## Securing GOV.UK Notify callbacks

Check that the `Authorization` header uses the `Bearer` scheme, then compare the token with the one you stored, in constant time.

```javascript
const crypto = require('crypto');
const express = require('express');

const TOKEN = process.env.NOTIFY_CALLBACK_TOKEN; // the bare bearer token

function verifyBearer(header) {
  if (!TOKEN || typeof header !== 'string') return false;
  const match = /^bearer (.+)$/i.exec(header);
  if (!match) return false;
  const a = Buffer.from(match[1], 'utf8');
  const b = Buffer.from(TOKEN, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/notify', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyBearer(req.get('authorization'))) {
    return res.sendStatus(401);
  }

  let callback;
  try {
    callback = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  if (callback.notification_type && callback.status) {
    // Delivery receipt: update the notification with callback.id / callback.reference.
  } else if (callback.source_number) {
    // Received text message.
  } else if (callback.complaint_id) {
    // Complaint (sent by Notify's code, not on the documentation page).
  } else if (callback.notification_id) {
    // Returned letter.
  }

  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

TOKEN = os.environ.get("NOTIFY_CALLBACK_TOKEN", "")

def verify_notify_callback(headers) -> bool:
    if not TOKEN:
        return False  # never accept when unconfigured
    header = headers.get("Authorization", "")
    scheme, _, token = header.partition(" ")
    if scheme.lower() != "bearer" or not token:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(token.encode("utf-8"), TOKEN.encode("utf-8"))

```

The bearer token is static. It proves the sender knows the token, but it doesn't cover the body, so a modified payload still passes, and nothing stops a captured request from being replayed. Use HTTPS only, keep the token out of logs, and change it in **Callbacks** if it might have leaked. Callbacks contain personal data such as phone numbers and the text of received messages, which is another reason to keep them off plain HTTP.

> **Make GOV.UK Notify callbacks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the bearer token, answers Notify well inside its 5-second timeout, and stores every receipt so you can retry and replay delivery to your service.

## GOV.UK Notify callback limitations and pain points

### Five retries, five minutes apart

**The Problem:** An outage of half an hour or so can lose delivery receipts and received messages for good.

**Why It Happens:** Notify retries every 5 minutes, up to 5 times. Its sending code also gives up straight away on a `4xx` other than `429`.

**Workarounds:**

* Don't return a `4xx` for an authenticated request you could process later, such as a receipt for a notification you haven't saved yet. Return a `5xx` if you need Notify to try again.
* Reconcile with Notify's API for any notification still without a final status.

**How Hookdeck Can Help:** Hookdeck accepts the callback as soon as it arrives and stores it, then delivers to your service with [retries](/docs/retries) you configure, over hours or days rather than 25 minutes.

### A 5-second timeout

**The Problem:** A handler that writes to a slow database or calls another service times out and gets retried, even though it eventually succeeded.

**Why It Happens:** Notify's sending code waits 5 seconds for a response.

**Workarounds:**

* Authenticate, queue the payload and return `200`, then process in the background.

**How Hookdeck Can Help:** Hookdeck answers Notify immediately and then delivers to your service, with a [delivery timeout](/docs/destinations#set-a-delivery-timeout) you control.

### No event name

**The Problem:** One endpoint receiving all three kinds has to guess which kind it got.

**Why It Happens:** Notify's callbacks carry their fields and nothing else.

**Workarounds:**

* Use a different URL, or a different path, for each kind of callback. Notify stores a URL and token per kind.
* If you share a URL, branch on distinctive fields: `notification_type` for receipts, `source_number` for received texts, `notification_id` for returned letters, checking `complaint_id` first.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can route on those fields, so each kind reaches its own handler.

## Best practices

### Test with a test API key

Delivery receipts work with test API keys, so you can exercise your callback endpoint without sending real messages. They don't work with Notify's smoke-testing phone numbers or email addresses.

### Use `reference` to link receipts to your records

Set a `reference` when you send, and look it up when the receipt arrives. It saves storing Notify's `id` before the response comes back.

### Deduplicate receipts

Retries mean the same callback can arrive twice. Key delivery receipts on `id` and `status`, received texts on `id`, and returned letters on `notification_id`. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Return `200` once the token checks out and the body is queued. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

GOV.UK Notify sends delivery receipts, received text messages and returned letters as JSON `POST` requests with your bearer token in `Authorization: Bearer <token>`. There is no signature and no event name, so compare the token in constant time over HTTPS, tell the kinds apart by URL or by their fields, answer within 5 seconds, and remember that five retries over 25 minutes is all Notify gives you.

[Hookdeck Event Gateway](https://hookdeck.com) checks the token, answers Notify immediately, and keeps every callback so a short outage on your side doesn't cost you receipts.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle GOV.UK Notify callbacks reliably in minutes.