Webhooks
Webhooks tell your service about a payment's outcome without polling. The messages follow the real
GOV.UK Pay webhooks: a JSON body signed with an HMAC in the
Pay-Signature header.
Management endpoints
| Endpoint | What it does |
|---|---|
POST /v1/webhooks | Register a webhook. callback_url is required (https). Optional: description, subscriptions (default: all event types). Returns 201 with the signing_key. |
GET /v1/webhooks | List webhooks (paginated like the search endpoints). |
GET /v1/webhooks/{webhookId} | Get one webhook, including its signing key. |
DELETE /v1/webhooks/{webhookId} | Deactivate it. It stops receiving events; its history stays readable. Returns 204. |
GET /v1/webhooks/{webhookId}/messages | Delivery history: each message, its attempts, response codes and the body that was sent (newest first). |
Register a webhook
curl -X POST https://publicapi.payments.platform-engineering.com/v1/webhooks \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"callback_url": "https://your.service.example/pay-webhook",
"description": "Payment outcomes",
"subscriptions": ["card_payment_succeeded", "card_payment_captured", "card_payment_failed"]
}'
The response includes external_id, status (ACTIVE), subscriptions and the signing_key you use to verify messages.
Event types
| Event type | Sent when |
|---|---|
card_payment_succeeded | The payment is authorised: it reaches capturable, or success without a delayed capture. |
card_payment_captured | The payment reaches success (immediately, or after POST .../capture). |
card_payment_refunded | A refund is submitted. |
card_payment_failed mock addition | The payment fails or errors (declined, provider error, cancelled by the user). |
card_payment_cancelled mock addition | The payment is cancelled through the API. |
card_payment_settled | Accepted as a subscription (it is a real event type) but never sent, because the mock does not settle payments. |
The message
POST https://your.service.example/pay-webhook Content-Type: application/json Pay-Signature: 9a3f...c41e{ "webhook_message_id": "b1tz0k3n8e4y6wmv5qj2pdx7uf", "api_version": 1, "created_date": "2026-09-29T13:20:39.723Z", "resource_id": "hu20sqlact5260q2nanm0q8u93", "resource_type": "payment", "event_type": "card_payment_captured", "resource": { "payment_id": "hu20sqlact5260q2nanm0q8u93", "amount": 2500, "state": { "status": "success", "finished": true }, "...": "the payment, as GET /v1/payments/{id} returns it" } }
Verify the signature
The Pay-Signature header is the lower-case hexadecimal HMAC-SHA256 of the raw request body, keyed with the webhook's signing_key. Ignore messages where it does not match.
// Node.js
const crypto = require('crypto')
const expected = crypto.createHmac('sha256', signingKey).update(rawBody).digest('hex')
const valid = expected === request.headers['pay-signature']
shell, to check a saved body
printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SIGNING_KEY"
Delivery rules
- Any
2xxresponse counts as delivered. Return it quickly, then process the message. - The mock makes up to two attempts per message, each with a 2 second timeout. There is no later retry queue. The real service retries several times, so your integration should still tolerate duplicates and out-of-order messages.
- Messages are sent as the payment changes state, so the request that caused the change (for example the user's Confirm click) waits for delivery.
callback_urlmust be https and must resolve to a public address. Private, loopback and link-local addresses are rejected with422.- Check what was sent, and whether it worked, with
GET /v1/webhooks/{webhookId}/messages. - Not modelled: updating a webhook, rotating the signing key, and dispute or settlement events.