Webhooks

Webhooks let your systems react to events in the Payments API as they happen, without polling. When a payment becomes funded, completes, or changes in some other way, we send an HTTP POST to the endpoints you have registered, with a JSON body describing the event.

Already using callbacks? Legacy callbacks keep working unchanged. You do not need to migrate to adopt webhooks, and can move over at your own pace. Be aware that webhooks use a different signing scheme, not just a different header name: webhooks are signed with a shared secret (HMAC-SHA256), callbacks with a key pair (Ed25519). Callback verification code will not work on webhooks. See Verifying webhook signatures and Legacy callback signatures.

Setting up endpoints

Endpoints are configured in the developer portal. There is no API call to register them. In the portal you:

  1. Add one or more endpoint URLs to receive events. Each URL must be publicly reachable over HTTPS.
  2. Choose which event types each endpoint subscribes to, for example all payment.status.* events, or only payment.status.completed.
  3. Copy the endpoint's signing secret, which you need to verify incoming requests.

You can register several endpoints, each with its own signing secret. Endpoints can also be given a rate limit in the portal. Deliveries that would exceed it are held and sent shortly after rather than dropped.

Request headers

Every webhook request carries these headers. The signature covers the id and the timestamp as well as the body, so all three are part of verification.

HeaderExampleDescription
content-typeapplication/jsonAlways application/json.
webhook-idf0c1d6f8-3a1b-4e2c-9b7a-2d9f5e8c1a44Unique ID for the event. Stable across retries, so use it to deduplicate.
webhook-timestamp1750287078Unix timestamp in seconds of this delivery attempt. Use it to reject replays.
webhook-signaturev1,Dc/SW9BusruU4IUu1kaQUbtqF4iSOTXUmlFnYaAMjzY=Signature over the id, timestamp and raw body.

webhook-id matches the id field in the body and does not change between retries of the same event, which makes it the right key for idempotency.

webhook-timestamp is the time we sent this attempt, not the time the event occurred. Retries arrive with a fresh timestamp, so a strict tolerance check will not reject legitimate retries. For event time, use occurred_at in the body.

Webhooks do not send the tenant_id, attempt-count or x-attempt-count headers that legacy callbacks include.

The event envelope

Every request body is a JSON envelope with the same top-level shape regardless of event type. The event-specific data lives under data.

FieldTypeDescription
idstring (UUID4)Unique ID for this event. Matches the webhook-id header.
typestringEvent type, for example payment.status.completed.
versionstringSchema version of the event.
occurred_atstring (RFC 3339)When the event occurred. Use this for ordering.
dataobjectEvent-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": "1",
  "occurred_at": "2026-05-28T10:42:31.123456Z",
  "data": {
    "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 use a dotted naming convention, such as payment.status.completed. The authoritative list, with the data schema and an example for each, is in the developer portal. New event types are added over time, so use the portal rather than a list maintained here.

Subscribe only to the event types you need, and handle unknown type values gracefully so new events do not break your handler.

Verifying webhook signatures

Anyone who learns your endpoint URL can POST to it. Verify the signature before trusting a webhook, and reject requests that fail.

Webhooks follow the Standard Webhooks signature scheme.

Algorithm. HMAC-SHA256 using the endpoint's signing secret. This is a symmetric scheme: the same secret signs and verifies. Treat it as a credential and never expose it client-side.

Key. Each endpoint has its own signing secret, issued in the developer portal. Verify with the secret belonging to the endpoint that received the request.

Signed payload. The id, the timestamp and the raw body, joined with periods:

{webhook-id}.{webhook-timestamp}.{raw request body}

Encoding. The digest is Base64 with a v1, prefix, for example v1,Dc/SW9BusruU4IUu1kaQUbtqF4iSOTXUmlFnYaAMjzY=. The v1 versions the whole scheme, including what gets signed, not just the digest algorithm.

Multiple signatures. webhook-signature may contain a space-delimited list of values so that a secret rotation can sign with both the old and the new secret during the overlap window. Split on spaces and accept the request if any entry verifies.

To verify a request:

# 1. Read the three headers. You need the raw body, see the notes below.
webhook_id        = GET_HEADER("webhook-id")
webhook_timestamp = GET_HEADER("webhook-timestamp")
signature_header  = GET_HEADER("webhook-signature")

# 2. Reject stale deliveries to limit replay. 5 minutes is a reasonable tolerance.
#    The timestamp is the time of this attempt, so retries are unaffected.
if ABS(NOW_UNIX() - TO_INT(webhook_timestamp)) > 300 do
    reject(request)                            # respond 4xx, do not process
end

# 3. Recompute the digest over "{id}.{timestamp}.{body}".
signed_payload = webhook_id + "." + webhook_timestamp + "." + raw_request_body
expected       = BASE64_ENCODE(HMAC_SHA256(key = CONFIG["WEBHOOK_SIGNING_SECRET"],
                                           message = signed_payload))

# 4. Compare against every signature in the header using a constant-time compare.
#    More than one may be present during a secret rotation.
is_valid = ANY(SPLIT(signature_header, " "), fn candidate ->
    "v1," <> digest = candidate                # skip entries with another version
    CONSTANT_TIME_EQUALS(digest, expected)
end)

# 5. Respond.
if is_valid do
    accept(request)                            # respond 2xx
else
    reject(request)                            # respond 4xx, do not process
end

Two details account for most verification failures:

Sign the raw request body exactly as received. Re-serializing parsed JSON can change whitespace, key order or unicode escaping, which breaks the comparison. Most frameworks need explicit configuration to retain the raw body.

Compare in constant time. Use your language's hmac.compare_digest or crypto.timingSafeEqual equivalent rather than ==.

Rotating a signing secret

When you rotate an endpoint's secret in the portal there is an overlap window during which we sign with both the old and the new secret and send both values in webhook-signature. If your verification accepts any entry in the list, as in step 4 above, you can add the new secret to your configuration and remove the old one whenever it suits you, with no failed deliveries.

Legacy callback signatures

Applies only to legacy callbacks. New integrations should use webhooks.

Callbacks are signed with an Ed25519 (EdDSA) key pair over the raw request body only. No id or timestamp is included in the signed payload. You verify with the public key for your tenant.

HeaderValueStatus
signature<base64-signature>Canonical.
x-ping-signature<base64-signature>Deprecated. Same value, kept for backwards compatibility.

Both headers carry the same value, with no version prefix. New code should read signature.

To verify a callback:

signature  = BASE64_DECODE(GET_HEADER("signature"))
public_key = BASE64_DECODE(CONFIG["CALLBACK_PUBLIC_KEY"])

is_valid = CRYPTO_VERIFY(
    algorithm  = "EdDSA",
    curve      = "ed25519",
    message    = raw_request_body,
    signature  = signature,
    public_key = public_key
)

Callbacks also carry tenant_id and attempt-count headers, plus the deprecated x-attempt-count, which webhooks do not send.

Responding to webhooks

Respond with any 2xx status code to acknowledge receipt. Any other status, or a timeout, counts as a failed delivery and will be retried.

Return 2xx as soon as you have safely stored the event, then do heavier work asynchronously. We allow 20 seconds to connect and 40 seconds for a response. Exceeding either counts as a failed attempt.

Do not make your response depend on downstream work. If your processing fails after you have returned 2xx, there is no automatic retry, and you will need to reconcile using the Payments API.

Reliability and retries

Delivery is at-least-once.

If your endpoint does not return 2xx, we retry up to 20 attempts with exponential back-off. A temporary outage on your side recovers once your endpoint is healthy again, provided it is back before the attempts are exhausted.

Deduplicate on webhook-id. It is stable across retries, so a handler that records processed ids and ignores repeats is safe against duplicate delivery. The same event delivered to two of your endpoints carries the same id, so key your dedup store per endpoint if both feed the same processing.

Do not rely on arrival order. Order events by occurred_at in the body rather than by the order requests reach you, and make handlers tolerant of an older event arriving after a newer one.


Did this page help you?