Webhooks
Webhooks let your systems react to events in the Payments API in real time — a payment becoming funded, a payment completing, and more — without polling. When something happens, we send an HTTP POST to the endpoint(s) you have registered, with a JSON body describing the event.
This guide explains how to set webhooks up, what the messages look like, how to verify they really came from us, and how to handle them reliably.
Already using callbacks? Our existing callbacks continue to work unchanged — you don't need to migrate to adopt webhooks, and can move over at your own pace. Webhook signatures are verified exactly the same way as callback signatures, so any verification code you already have carries over directly.
Setting up endpoints
Webhook endpoints are configured from the developer portal — there is no API call to register them. In the portal you:
- Add one or more endpoint URLs that should receive events. The URL must be publicly reachable and served over HTTPS.
- Choose which event types each endpoint subscribes to (for example, all
payment.status.*events, or onlypayment.status.completed). - Copy the endpoint's signing key — used to verify incoming requests (see Verifying signatures).
You can register multiple endpoints, each with its own unique signing key.
The event envelope
Every webhook request body is a JSON envelope with the same top-level shape regardless of event type. The event-specific data lives under payload.
| Field | Type | Description |
|---|---|---|
id | string (UUID4) | Unique ID for this event. |
type | string | Event type, e.g. payment.status.completed. |
version | string | Schema version of the event, e.g. 2021-01-01. |
tenant_id | string (UUID4) | The tenant the event belongs to. May be null. |
topic | string | Delivery topic the event was published on. May be null. |
occurred_at | string (RFC 3339) | Timestamp the event occurred. Use this for ordering. |
payload | object | Event-specific data. Its shape depends on type and version. |
Example
A payment.status.completed event:
{
"id": "f0c1d6f8-3a1b-4e2c-9b7a-2d9f5e8c1a44",
"type": "payment.status.completed",
"version": "2021-01-01",
"tenant_id": "5b2c0a64-2bbf-49e3-9217-71a5e7b9c1d4",
"topic": "9c2c0a64-2bbf-49e3-9217-71a5e7b9c1d4",
"occurred_at": "2026-05-28T10:42:31Z",
"payload": {
"status": {
"id": "8c5a3c8a-d6a1-4eed-9c8a-3aab9d8fdda0",
"status": "COMPLETED",
"funds": { "received": 12500, "received_total": 12500 },
"details": { "provider_data": {} },
"occurred_at": "2026-05-28T10:42:31Z",
"payment_id": "1a64fa5c-1f1f-4f2c-a8a5-b6ad0f33d8e9"
},
"payment": {
"id": "1a64fa5c-1f1f-4f2c-a8a5-b6ad0f33d8e9",
"payment_order_id": "9c2c0a64-2bbf-49e3-9217-71a5e7b9c1d4",
"status": "COMPLETED",
"method": "swish",
"provider": "swish",
"metadata": {}
}
}
}Event types
Event types follow a dotted naming convention, e.g. payment.status.completed. The complete, authoritative list of event types — together with the payload schema and an example for each — is published as an OpenAPI spec in the docs. Refer to it rather than to a list maintained here, since new events are added over time.
Subscribe only to the event types you need, and treat unknown type values defensively so new events don't break your handler.
Verifying signatures
Anyone who learns your endpoint URL could POST to it, so always verify the signature before trusting a webhook and reject requests that fail.
Each endpoint has its own unique signing key, issued in the developer portal. We sign every request with that endpoint's key; recompute the signature over the raw request body and verify it with the key belonging to the endpoint that received the request. This is the same scheme used for callbacks, so existing callback verification code works unchanged.
To verify a request:
# 1. Read and decode the signature from the header
signature_header = GET_HEADER("x-ping-signature")
signature = BASE64_DECODE(signature_header)
# 2. Decode your public key (if stored as Base64)
pubkey_b64 = CONFIG["CALLBACK_PUBLIC_KEY"]
public_key = BASE64_DECODE(pubkey_b64)
# 3. Verify with Ed25519 (EdDSA)
is_valid = CRYPTO_VERIFY(
algorithm = "EdDSA",
message = raw_request_body,
signature = signature,
public_key = public_key,
curve = "ed25519"
)
# 4. Respond to the request
if is_valid do
accept(request) # respond 2xx
else
reject(request) # respond 4xx, do not process
end
Always compute the signature over the raw request body — re-serializing the parsed JSON can change whitespace or key order and break the comparison — and use a constant-time comparison.
Responding to webhooks
Respond with any 2xx status code to acknowledge receipt. Anything else (or a timeout) is treated as a failed delivery and will be retried.
- Acknowledge fast. Return
2xxas soon as you have safely stored the event, then do heavier work asynchronously. Slow responses risk timing out and being retried. - Don't gate your response on downstream work. If your processing fails after you've returned
2xx, you won't get an automatic retry — reconcile using the Payments API instead.
Reliability and retries
Webhook delivery is at-least-once:
- Retries — if your endpoint doesn't return
2xx, we retry delivery up to 20 times with exponential back-off to ensure delivery. A temporary outage on your side will recover once your endpoint is healthy again, as long as it is back before the attempts are exhausted.
Updated 1 day ago