Guide to GOV.UK Pay Webhooks: Features and Best Practices
GOV.UK Pay is the payment platform the Government Digital Service runs for central government, councils, the NHS and other public bodies. A service takes card payments for a licence, a fine or council tax through a GOV.UK Pay payment page, and the money is handled by the service's payment service provider. Webhooks tell the service's own systems when each payment is authorised, captured, settled or refunded, without polling the API.
In this guide: the callback URL rules for live services, the four payment events, how to verify Pay-Signature, and how to build a receiver that tolerates duplicates and events in the wrong order.
What are GOV.UK Pay webhooks?
Each GOV.UK Pay service can have webhooks, created in the admin tool with a callback URL, a description and the payment events to send. When a subscribed payment event happens, GOV.UK Pay posts a JSON message describing the payment as it stands, in the same shape the API returns when you get a payment.
Webhooks only cover payments today: resource_type is always payment. The admin tool keeps a history of each webhook's events and shows every delivery attempt, which helps when a payment's state in your system doesn't match GOV.UK Pay.
GOV.UK Pay webhook features
| Feature | Details |
|---|---|
| Signature | hex HMAC-SHA256 |
| Signature header | pay-signature |
| Body | JSON |
| Event routing | Body field event_type |
Rules from the GOV.UK Pay webhooks documentation:
- Live callback URLs must use HTTPS with TLS 1.2 and end in
.gov.uk. Other domains need an exception from GOV.UK Pay support, granted only for domains owned by the service or its parent organisation. - Sandbox and "not live yet" services can use any HTTPS URL.
- The secret is shown by selecting View next to the webhook.
- Delivery is at least once, messages can arrive out of order, and retries stop after "several attempts", with no published schedule or timeout.
Events and payloads
Route on the top-level event_type. GOV.UK Pay currently documents payment webhooks, with resource_type set to payment. Use webhook_message_id to identify repeated deliveries.
Some documented events are listed below; see the provider documentation for the full list.
| Event | Meaning |
|---|---|
card_payment_succeeded | your payment service provider has authorised the payment |
card_payment_captured | GOV.UK Pay has taken ('captured') the payment from the user's bank account |
card_payment_settled | your payment service provider has sent the payment to your bank account. |
card_payment_refunded | the refund has been sent to the user's bank account by your payment service provider |
The introduction to the GOV.UK Pay docs also mentions updates when a payment fails or expires, but no event_type value for either is documented. Until one is, check resource.state from the Get payment API for payments that never reach card_payment_succeeded.
This is the card_payment_captured sample from the webhook samples library:
{
"webhook_message_id": "123abc",
"api_version": 1,
"created_date": "2019-07-11T10:36:26.988Z",
"resource_id": "hu20sqlact5260q2nanm0q8u93",
"resource_type": "payment",
"event_type": "card_payment_captured",
"resource": {
"amount": 5000,
"description": "Pay your council tax",
"reference": "12345",
"language": "en",
"email": "sherlock.holmes@example.com",
"state": {
"status": "success",
"finished": true
},
"payment_id": "hu20sqlact5260q2nanm0q8u93",
"payment_provider": "stripe",
"created_date": "2021-10-19T10:05:45.454Z",
"refund_summary": {
"status": "available",
"amount_available": 5000,
"amount_submitted": 0
},
"settlement_summary": {},
"card_details": {
"last_digits_card_number": "1234",
"first_digits_card_number": "123456",
"cardholder_name": "Sherlock Holmes",
"expiry_date": "04/24",
"billing_address": {
"line1": "221 Baker Street",
"line2": "Flat b",
"postcode": "NW1 6XE",
"city": "London",
"country": "GB"
},
"card_brand": "Visa",
"card_type": "debit"
},
"delayed_capture": false,
"moto": false,
"provider_id": "10987654321",
"return_url": "https://your.service.gov.uk/completed"
}
}
resource_id is the same as resource.payment_id, and amount is in pence. reference is the reference your service set when it created the payment, which is usually how you find the application or invoice it belongs to. GOV.UK Pay's field table names the message ID id, while its example uses webhook_message_id, so read whichever is present.
Setting up GOV.UK Pay webhooks
- In the GOV.UK Pay admin tool, pick your service from My services.
- Go to Settings, then Developers, then Webhooks, and select Create a new webhook.
- Enter a Callback URL, such as
https://payments.example-council.gov.uk/webhooks/govuk-pay, and a Description. - Select the payment events to send and Save.
- Select View next to the webhook to reveal its signing secret, and store it in your server's configuration, for example as
GOVUK_PAY_WEBHOOK_SECRET.
Update changes the URL, description or events later, and Deactivate webhook on the View page pauses delivery until you reactivate it.
A Hookdeck source URL is not a .gov.uk domain, so on a live service it needs the same exception as any other non-.gov.uk URL. In sandbox, create a source with the GOV.UK Pay type, paste the signing secret as its verification secret and use the source URL as the callback. To test against a local server:
npx hookdeck-cli listen 3000 govuk-pay --path /webhooks/govuk-pay
Then make a test payment with one of the sandbox test cards.
Securing GOV.UK Pay webhooks
Pay-Signature holds a lower-case hex HMAC-SHA256 of the request body, keyed with the signing secret. GOV.UK Pay specifies that both the body and the key are UTF-8 with no byte order mark, so hash the bytes exactly as received. The verification section has examples in several languages.
Node.js verification
const crypto = require('node:crypto');
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const signature = headers['pay-signature'];
if (typeof signature !== 'string') return false;
const key = Buffer.from(secret, 'utf8');
const message = rawBody;
const candidates = [signature];
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
const received = Buffer.from(candidate, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
GOV.UK Pay asks for a 2xx before you process the message, and warns that messages can arrive in any order. The handler stores the message and lets a worker apply it:
const express = require('express');
const app = express();
app.post('/webhooks/govuk-pay', express.raw({ type: '*/*' }), async (req, res) => {
const signingSecret = process.env.GOVUK_PAY_WEBHOOK_SECRET;
if (!signingSecret) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, signingSecret)) return res.sendStatus(401);
let message;
try {
message = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
if (!message || typeof message !== 'object' || Array.isArray(message)) return res.sendStatus(400);
try {
await savePaymentMessage({
messageId: message.webhook_message_id ?? message.id,
paymentId: message.resource_id,
eventType: message.event_type,
occurredAt: message.created_date,
body: message,
});
} catch {
return res.sendStatus(503);
}
res.sendStatus(200);
});
savePaymentMessage inserts the message only if its ID is new. The worker then decides what to do from the payment's progress rather than arrival order: a card_payment_settled that arrives before card_payment_captured still means the money has been taken. If you need certainty, call the Get payment API for resource_id and use the state it returns.
No timestamp is signed, so a captured message would still verify if resent later. Deduplicating on the message ID handles that. Hookdeck's GOV.UK Pay source checks Pay-Signature the same way, replies 200 to GOV.UK Pay, and records a mismatched signature as failed verification.
Make GOV.UK Pay webhooks production-ready. Hookdeck Event Gateway verifies
Pay-Signature, keeps a searchable record of every payment message and retries delivery to your case management or finance system when it is down.
GOV.UK Pay webhook limitations and pain points
Live callbacks must be on .gov.uk
The Problem: You can't point a live service at a third-party endpoint, a cloud function URL or a webhook gateway without approval.
Why It Happens: GOV.UK Pay requires live callback URLs to end in .gov.uk, and grants exceptions only for domains belonging to the service or its parent organisation.
Workarounds:
- Host the receiver on a
.gov.uksubdomain that your organisation controls. - Email GOV.UK Pay support early if you need an exception, since approval can take time.
How Hookdeck Can Help: In sandbox and "not live yet" modes, a Hookdeck source works without any exception, so you can build and test against real GOV.UK Pay deliveries before your live domain is ready.
Retry policy is not specified
The Problem: You can't tell how long an outage your receiver can survive.
Why It Happens: GOV.UK Pay says only that it retries when it doesn't get a 2xx quickly and stops "after several attempts". It publishes no count, interval or timeout.
Workarounds:
- Respond before processing so a slow downstream system never causes a timeout.
- After an incident, compare the webhook's event history in the admin tool with your records, and fetch missing payments from the API.
How Hookdeck Can Help: Hookdeck accepts each message immediately and applies a retry policy you define, with alerts when delivery to your service keeps failing.
Out-of-order and repeated messages
The Problem: A payment can appear settled before it appears captured, or the same message can arrive twice.
Why It Happens: GOV.UK Pay documents at-least-once delivery and warns that order is not guaranteed. A 2xx that GOV.UK Pay doesn't receive also leads to a resend.
Workarounds:
- Deduplicate on the message ID.
- Never move a payment backwards to an earlier state, and confirm with the API when in doubt.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeated webhook_message_id values, and its event log shows the full sequence for each payment.
No events for failed or expired payments
The Problem: You don't hear about payments that never complete.
Why It Happens: The docs say you can be told when a payment fails or expires, but the documented event_type values cover only succeeded, captured, settled and refunded.
Workarounds:
- Poll the API for payments that are still unfinished after the payment link's lifetime.
- Capture any unexpected
event_typeyour receiver logs, in case GOV.UK Pay adds these events.
How Hookdeck Can Help: Hookdeck keeps every request, including any with an event_type your code didn't expect, so a new event type can be inspected and replayed once you handle it.
Best practices
Store, acknowledge, then process
GOV.UK Pay explicitly asks for an immediate 2xx. Put the message in a table or queue and do the rest in a worker, following our guide to asynchronous webhook processing.
Treat the message ID as an idempotency key
Record webhook_message_id with a unique constraint alongside the change it caused. Implementing webhook idempotency walks through the pattern.
Protect cardholder data
Payloads include the cardholder's name, billing address, email and partial card numbers. Restrict who can read stored messages and leave resource out of routine logs.
Use the admin tool's event history
View details on each event shows when it was sent, the body and every previous attempt. Check it first when a payment's status looks wrong.
Conclusion
GOV.UK Pay webhooks deliver four payment events, each signed in Pay-Signature with a hex HMAC of the body. Verify the raw bytes, acknowledge before doing anything slow, deduplicate on the message ID, and don't assume the events arrive in payment order. Plan for the .gov.uk rule early if your receiver lives anywhere else.
Hookdeck Event Gateway records each GOV.UK Pay message and delivers it to your back-office systems with retries you control.
Get started with Hookdeck for free and handle GOV.UK Pay webhooks reliably in minutes.