# Guide to GC Notify Webhooks: Features and Best Practices

GC Notify callbacks tell your service what happened to the emails and text messages it sent. GC Notify is the Government of Canada's notification service, and when a message is delivered, bounces or fails, it can post a delivery receipt to a URL you choose, so your case management or records system can track each message without polling the API.

This guide covers how GC Notify callbacks are set up and authenticated, what a delivery receipt contains, how health checks, retries and suspensions work, and the practices that keep a callback endpoint reliable.

## What are GC Notify callbacks?

GC Notify calls its webhooks callbacks. For each service, you provide a URL and a Bearer token, and GC Notify sends a `POST` with a JSON body to that URL whenever the status of an email or text message changes. The token goes in the `Authorization` header as `Bearer <token>`.

GC Notify does not sign callbacks. The Bearer token is a static credential that your endpoint compares with the one you set. GC Notify was built from GOV.UK Notify, but it is a separate service with its own documentation, and its callbacks are configured separately.

## GC Notify callback features

| Feature | Details |
| --- | --- |
| Configuration | Sign in to GC Notify, then **API integration > Callbacks** |
| Method and format | `POST`, JSON body (`Content-Type: application/json`) |
| Authentication | `Authorization: Bearer <token>`, with the token you set |
| Signature and timestamp | None |
| Callback content | Email and text message delivery receipts. All values are strings |
| Event type | None. `status` describes the message |
| Health checks | `{"health_check": "true"}` on set-up, on update, and when you test response time |
| Response time | GC Notify asks you to respond within 1 second |
| Retries | 6 times over 25 minutes, then an email to your team |
| Suspension | After repeated errors, callbacks stop until you ask GC Notify to resume them |
| Ordering | Callbacks are sent in the order they are received |
| Source IPs | Not published |

## Delivery receipts

Every callback documented on the Callbacks page is a delivery receipt for one notification. GC Notify describes the fields in a table rather than a JSON example:

| Key | Description |
| --- | --- |
| `id` | The notification's ID in GC Notify (UUID) |
| `reference` | The reference your service sent with the notification |
| `to` | The recipient's email address or phone number |
| `status` | `created`, `sending`, `pending`, `sent`, `delivered`, `permanent-failure`, `temporary-failure`, `technical-failure`, `pending-virus-check` or `virus-scan-failed` |
| `status_description` | An English label, such as `In transit`, `Delivered`, `No such address`, `Carrier issue`, `Tech issue` or `Attachment has virus` |
| `provider_response` | Detail from the provider, only set for a technical failure |
| `created_at` | When your service sent the request |
| `completed_at` | The last time the status was updated, or nil |
| `sent_at` | When the notification was sent, or nil |
| `notification_type` | `email` or `sms` |

There is no field that names an event. Route on `status` and `notification_type`, and match receipts to your own records by `id` or `reference`. Status values and descriptions are only in English.

One notification can produce more than one receipt. GC Notify's example is an email that the receiving mail server first accepts, which produces a delivery callback, and then bounces, which produces another.

GC Notify's open-source sending code also contains a task for complaint callbacks, which the Callbacks page does not describe. Write your handler so a body that is not a delivery receipt is logged and acknowledged, not treated as an error.

> **Inspect GC Notify callbacks as they arrive.** Point your callback URL at a [Hookdeck Inspect](https://inspect.hookdeck.com) URL and send a message with a test API key to see the receipt's headers and body, with no account or setup required.

## Setting up GC Notify callbacks

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

1. Sign in to GC Notify.
2. Go to the **API integration** page and select **Callbacks**.
3. Enter your HTTPS callback URL and the Bearer token.
4. Save. GC Notify sends a health check request to the URL.

Callbacks work with test API keys, so you can try the set-up without sending real messages. They do not work with GC Notify's smoke-testing email addresses and phone numbers.

GC Notify's guidance on the token is to keep it secure, change it if you have any reason to doubt it, check that every callback carries it, and give GC Notify a hashed value so that GC Notify does not hold your true token. Whatever value you enter is the value GC Notify sends after `Bearer `.

To route through Hookdeck, create a Hookdeck source with the **GC Notify** type and use its URL as the callback URL. The source has one field, **Bearer Token**: paste the bearer token you set for the callback in GC Notify. Paste only the token. GC Notify sends `Bearer <token>` in the `Authorization` header, and Hookdeck matches the `Bearer ` scheme itself. The source accepts `POST` only, which is the method GC Notify uses.

GC Notify's documentation does not say whether the health check request carries the token. Hookdeck answers it with a `200` either way, so set-up succeeds, but if the health check arrives without the token it shows in Hookdeck as a request that failed verification.

## Securing GC Notify callbacks

Read the `Authorization` header, check that the scheme is `Bearer` (case-insensitively), and compare the rest with your stored token in constant time. Reject the request if either is missing.

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

const CALLBACK_TOKEN = process.env.GC_NOTIFY_CALLBACK_TOKEN; // the token only, no "Bearer "

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

const app = express();

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

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

  if (receipt.health_check === 'true') return res.sendStatus(200);

  // Store the receipt keyed on receipt.id and receipt.status, then process it asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

CALLBACK_TOKEN = os.environ.get("GC_NOTIFY_CALLBACK_TOKEN", "")  # the token only

def verify_bearer(header) -> bool:
    if not CALLBACK_TOKEN or not header:
        return False
    scheme, _, token = header.partition(" ")
    if scheme.lower() != "bearer":
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(token.encode("utf-8"), CALLBACK_TOKEN.encode("utf-8"))

```

The token is static. It authenticates the sender, not the body, so a modified receipt still passes, and with no timestamp a captured request can be replayed. Serve the endpoint over HTTPS only, use a long random token, and change it in GC Notify and in your environment together if it might have leaked.

> **Make GC Notify callbacks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the Bearer token, answers GC Notify straight away, and queues every delivery receipt durably before your service sees it.

## GC Notify callback limitations and pain points

### A one-second budget and a suspension at the end

**The Problem:** Your service stops receiving callbacks entirely, and has to ask GC Notify to switch them back on.

**Why It Happens:** GC Notify asks callback endpoints to respond within 1 second. A failing callback is retried 6 times over 25 minutes, after which GC Notify emails your team. If errors keep coming, GC Notify suspends callbacks for the service, and they stay off until you follow its instructions and contact GC Notify to resume them.

**Workarounds:**

* Respond with a `200` as soon as the token is checked and the receipt is stored, and do everything else afterwards.
* Monitor your endpoint's response times. GC Notify offers response time testing from **API Integration > Callbacks**.
* Make sure the email address on the service reaches someone who will act on a failure notice.

**How Hookdeck Can Help:** Hookdeck accepts each callback as soon as it arrives and stores it, so GC Notify sees fast, successful responses even while your own service is down. Hookdeck then delivers to your service with [retries](/docs/retries) you configure.

### Several receipts per message

**The Problem:** A message is marked delivered, and later failed, or the same status is recorded twice.

**Why It Happens:** A notification can produce more than one receipt as its status changes, and retries can deliver the same receipt again.

**Workarounds:**

* Key stored receipts on `id` plus `status`, and upsert rather than insert.
* Use `completed_at` to tell which receipt is the most recent.

**How Hookdeck Can Help:** Hookdeck [deduplication](/docs/deduplication) can drop repeated receipts before they reach your service, based on fields such as `id` and `status`.

### Nothing names the event

**The Problem:** Your handler has to guess what kind of request it received.

**Why It Happens:** Receipts carry no event type, health checks share the same URL, and the sending code has a complaint callback the docs do not describe.

**Workarounds:**

* Check for `health_check` first, then for the receipt fields, and log anything else.
* Route on `status` and `notification_type`.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can keep health checks away from your service and route email and text message receipts to different handlers.

## Best practices

### Store the token, not the header

Keep only the token in your configuration and match the `Bearer ` scheme in code. If your configuration holds `Bearer abc` and your code compares it with the token after the scheme, every callback fails.

### Acknowledge health checks

Return `200` to `{"health_check": "true"}` without treating it as a receipt. GC Notify uses it to decide whether your endpoint is reachable.

### Make handlers idempotent

Store each receipt before processing it, keyed on `id` and `status`. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

One second is a tight budget. Verify, store, return `200`, and do the work afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

GC Notify sends delivery receipts as JSON `POST` requests with your token in an `Authorization: Bearer` header and no signature. Check the scheme and compare the token in constant time over HTTPS, acknowledge health checks, key receipts on `id` and `status`, and respond within a second, because repeated failures end in a suspension that only GC Notify can lift.

[Hookdeck Event Gateway](https://hookdeck.com) checks the token, answers GC Notify immediately and keeps every receipt until your service has processed it, so an outage on your side does not get your callbacks suspended.

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