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.

Mock extension. In GOV.UK Pay you register webhooks in the admin tool. The mock has no admin tool, so it adds a small management API (below). It needs the bearer token. Message delivery is the same as the real service.

Management endpoints

EndpointWhat it does
POST /v1/webhooksRegister a webhook. callback_url is required (https). Optional: description, subscriptions (default: all event types). Returns 201 with the signing_key.
GET /v1/webhooksList 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}/messagesDelivery 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 typeSent when
card_payment_succeededThe payment is authorised: it reaches capturable, or success without a delayed capture.
card_payment_capturedThe payment reaches success (immediately, or after POST .../capture).
card_payment_refundedA refund is submitted.
card_payment_failed mock additionThe payment fails or errors (declined, provider error, cancelled by the user).
card_payment_cancelled mock additionThe payment is cancelled through the API.
card_payment_settledAccepted 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 2xx response 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_url must be https and must resolve to a public address. Private, loopback and link-local addresses are rejected with 422.
  • 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.