Webhooks
Webhooks let you react to events (a deposit arriving, KYC completing, an account being issued) instead of polling. This guide covers the whole loop: registering an endpoint, verifying what arrives, what a retry really does, what reaches which endpoint, and every payload shape.
The routes
Webhook management lives ONLY on /api/v2/public — there is no /api/v1/partner equivalent.
Its request and response bodies are camelCase (eventTypes, maxAttempts), where the v1
partner surface is snake_case; the delivered payloads themselves are snake_case. Match the casing
shown in the API Reference.
| Route | Scope | What it does |
|---|---|---|
POST /api/v2/public/webhook-subscriptions |
webhooks:write |
Register an endpoint. Returns the signing secret once. |
GET /api/v2/public/webhook-subscriptions |
webhooks:read |
List your endpoints (unpaged, no secrets). |
GET /api/v2/public/webhook-subscriptions/{subscriptionId} |
webhooks:read |
Read one endpoint. |
PATCH /api/v2/public/webhook-subscriptions/{subscriptionId} |
webhooks:write |
Replace URL, event types, enabled state and attempt limit. |
DELETE /api/v2/public/webhook-subscriptions/{subscriptionId} |
webhooks:write |
Disable. The record and its history are kept; re-enable with PATCH. |
POST /api/v2/public/webhook-subscriptions/{subscriptionId}/secret-rotations |
webhooks:write |
Rotate the signing secret. Returns the new secret once. |
GET /api/v2/public/webhook-subscriptions/{subscriptionId}/delivery-attempts |
webhooks:read |
Diagnose a missed event. |
GET /api/v2/public/webhook-spec |
webhooks:read |
The event-type list and the X-Webhook-* signing spec. |
Every mutating call needs an Idempotency-Key header (see Idempotency).
Registering an endpoint
curl -X POST https://api.kwiikpay.io/api/v2/public/webhook-subscriptions \
-H "X-Api-Key: kp_live_..." \
-H "Idempotency-Key: register-webhook-a1b2c3" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/kwiikpay/webhooks", "eventTypes": ["deposit.received", "kyc.approved"], "maxAttempts": null }'
The 201 body is the only time you will see secret. Store it immediately.
{
"subscriptionId": "0192f8a0-5c1e-7b2a-9d3f-6a7b8c9d0e1f",
"url": "https://example.com/kwiikpay/webhooks",
"eventTypes": ["deposit.received", "kyc.approved"],
"enabled": true,
"secretVersion": 1,
"secret": "kwp_whsec_2gk1c8w4Qq3d0M7tYv9LxZpB5nHsRfUaEjWm6oCiKyA=",
"secretSha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"updatedAt": "2026-06-17T09:12:58.000Z"
}
Endpoint requirements
| Rule | Detail | Otherwise |
|---|---|---|
| HTTPS only | Absolute https:// URL; http:// is refused |
400 |
| Length | At most 2048 characters | 400 |
| No credentials | No user:password@ in the URL |
400 |
| Public destination | Not localhost, loopback, private (10/8, 172.16/12, 192.168/16), carrier-grade NAT (100.64/10), link-local, multicast or reserved. Checked on the literal host AND on what the hostname resolves to at registration, and re-resolved and re-checked at every delivery — a hostname that later resolves into a blocked range stops receiving. | 400 at registration; silent delivery failure later |
| Unique URL | One endpoint per URL per tenant, disabled endpoints included | 409 webhook_subscription_url_already_registered |
| Enabled-endpoint limit | At most 10 enabled endpoints per owning key subject (a subject-bound key's customer, or the tenant-wide group). Counted again when a disabled endpoint is re-enabled. | 409 webhook_subscription_limit_reached — disable one first |
eventTypes |
At least one; each must be in GET /webhook-spec (case-insensitive, duplicates collapsed); no wildcard |
400 "Unsupported Kwiikpay event type" |
maxAttempts |
null or omitted = 5; otherwise an integer 1-25 |
400 |
| Secret | kwp_whsec_ + 44 base64 characters (54 total); shown once |
— |
Three 403s that are not about scope
Registration can be refused with 403 for reasons a webhooks:write scope does not fix. Read
detail:
detail says |
Meaning | What to do |
|---|---|---|
| "API key is not bound to a subject" | The key has no subject binding, so nobody could own the endpoint | Re-issue the key from the dashboard, or ask an admin to bind it |
| "registration is unavailable while API-key subject-binding enforcement is disabled" | A temporary platform incident state. Existing endpoints keep delivering; PATCH, DELETE and rotation are refused the same way for the duration | Wait and retry later. Do not re-issue keys |
| "First-party webhook endpoints cannot be registered by a tenant-wide API key in this tenant" | Your tenant only allows customer-scoped endpoints | Use a subject-bound key |
Which events reach your endpoint
An endpoint's reach is decided by the API key that registers it and cannot be changed afterwards. The subscription response does not show which kind you have — you know from the key you used.
- A subject-bound key (one customer) creates an endpoint that receives only that customer's events, plus those of end-customers it onboarded itself.
- A tenant-wide key creates an endpoint that receives every customer's events in the tenant.
Two silent cases to know about, because neither writes a delivery-attempt row:
- An event that cannot be attributed to a customer is routed to tenant-wide endpoints only. A subject-bound endpoint never sees it.
- An event whose payload resolves no
customer_id,business_idoruser_idis not delivered to anyone.
If you registered with a subject-bound key and are waiting for tenant-wide traffic, nothing is wrong with your endpoint — it was never in the route.
The event catalogue
GET /api/v2/public/webhook-spec returns the closed list you may register for. It is the same
list for every tenant today; read it rather than hardcoding it, so new types appear without a
change on your side. Today it contains:
| Event type | Fires when |
|---|---|
customer.created |
An onboarding case was started for a customer or business |
kyc.pending_review |
A person's Sumsub review came back GREEN, but Kwiikpay has not yet made the compliance decision — they still cannot transact |
kyc.approved / kyc.rejected |
Kwiikpay decided a person's verification |
kyb.pending_review |
Same as kyc.pending_review, for a business |
kyb.approved / kyb.rejected |
Kwiikpay decided a business's verification |
viban.ready |
A fiat account is issued and usable — the only event carrying bank coordinates |
virtual_account_request.completed |
Same moment as viban.ready, provisioning-request shape |
customer.account_ready |
Same moment again, same shape as virtual_account_request.completed |
virtual_account_request.needs_correction |
The banking provider needs more information |
customer.needs_information |
Same moment, same shape |
virtual_account_request.rejected |
Account provisioning failed |
deposit.received |
A fiat or crypto deposit completed (data.deposit_type says which) |
withdrawal.completed |
A fiat or crypto withdrawal settled (data.type says which) |
deposit.returned |
A completed fiat deposit was recalled to its sender by the banking provider. The balance is not adjusted automatically (data.balance_adjusted is false, data.status is under_review): Kwiikpay operations reconcile it by hand. If you hold balances for your own clients, freeze the amount on your side when this arrives |
withdrawal.returned |
A fiat withdrawal was rejected by the beneficiary bank and came back. The withdrawal is already reversed and its fee refunded when this fires (data.balance_adjusted is true, data.ledger_transaction_id is the reversing entry) |
conversion.completed |
An exchange order settled |
beneficiary.approved / beneficiary.rejected |
Kwiikpay staff decided a beneficiary's staff approval |
fiat.beneficiary.updated |
A beneficiary's payability changed: a staff approval decision, a bank-registration transition to Registered or NeedsReview, or the beneficiary was superseded by a replacement (staff correction/recovery, or a self-service/partner replace) — subscribe to this instead of polling for "has this payee become usable yet" |
beneficiary.approved, beneficiary.rejected and fiat.beneficiary.updated are the one exception
to the legacy envelope below: they arrive in the newer kwiikpay.webhooks.v1 shape with camelCase
keys — specVersion, eventType, tenantId, resourceId (the beneficiary id), occurredAt,
correlationId and data. Headers, signatures and retry rules are identical for all three.
For beneficiary.approved/beneficiary.rejected, data carries beneficiary_id, subject_id,
approval_status, provider, currency and rejection_reason (empty when approved).
For fiat.beneficiary.updated, data carries fiat_beneficiary_id, subject_id, subject_type,
status, currency_code, provider_code, provider_configuration_id, approval_status,
bank_registration_status, bank_registration_reason (customer-safe text — never the raw
operator-facing reason shown to staff), bank_registered_at, superseded_by_beneficiary_id (the
replacement's id — an empty string while this beneficiary is still the active payee),
display_name, masked_account_identifier (never the full IBAN/account number), payment_rail
and change (approval | registration | superseded, naming which of the three triggers fired).
Idempotency key shape: fiat-beneficiary:{id}:updated:{approval_status}:{bank_registration_status}:{change}:{superseded_by_beneficiary_id|-}:{failure_code|-}
— a redelivery of the exact same transition dedupes; a genuinely new transition (even for the same
beneficiary) is a new event.
Fan-out: one outcome, several deliveries
One fiat account being issued emits three events — viban.ready,
virtual_account_request.completed and customer.account_ready — and a provider asking for more
information emits two — virtual_account_request.needs_correction and
customer.needs_information. Each is its own delivery with its own X-Webhook-Id, but they share
the envelope id (the account id) and occurred_at, and ordering between them is not guaranteed.
They are not duplicates; if you dedupe on the envelope id you will drop viban.ready and lose
the bank details.
The envelope
Every delivery is an HTTP POST with Content-Type: application/json:
{
"id": "019ed568-9f73-7cc3-aa32-f97cdbc620d1",
"type": "deposit.received",
"occurred_at": "2026-06-17T11:46:49.000Z",
"api_version": "2026-05-legacy",
"data": { "...": "..." }
}
The envelope's id is the id of the resource the event is about (the payment, account or
customer id) — not the event id. Every event about the same resource carries the same envelope
id, so never dedupe on it. The event id is in the headers. api_version is always
2026-05-legacy.
Headers: two signature families, both on every delivery
Every delivery carries BOTH families below, signed with the same secret. Verify whichever you
prefer — both are supported. The x-kwiikpay-* scheme is the stronger one because its signed
input also binds the event id and the body hash; GET /webhook-spec describes only the
X-Webhook-* family.
| Header | Value |
|---|---|
X-Webhook-Id and x-kwiikpay-webhook-id |
The event id — identical on every retry of the same event. This is the header to dedupe on. It does NOT match the envelope id. |
X-Webhook-Event and x-kwiikpay-event-type |
The event type, e.g. deposit.received |
X-Webhook-Delivery-Id and x-kwiikpay-delivery-endpoint-id |
Your endpoint's subscriptionId — constant on every delivery to it. Do not dedupe on this. |
X-Webhook-Delivery-Attempt-Id |
Unique per delivery attempt — differs on every retry. It is the deliveryAttemptId on the delivery-attempts route, not an event-level key. |
X-Webhook-Timestamp |
ISO 8601 round-trip time this attempt was signed, e.g. 2026-08-09T10:15:30.0000000+00:00. Every attempt is re-signed at send time, so it is always fresh. |
X-Webhook-Signature |
sha256=<lowercase hex HMAC-SHA256> over "{X-Webhook-Timestamp}.{raw_body}" |
x-kwiikpay-signature-timestamp |
The same signing instant as Unix seconds |
x-kwiikpay-payload-sha256 |
Lowercase hex SHA-256 of the raw body (also payloadHash on the delivery-attempts route) |
x-kwiikpay-signature |
sha256=<lowercase hex HMAC-SHA256> over "{x-kwiikpay-signature-timestamp}.{x-kwiikpay-webhook-id}.{x-kwiikpay-payload-sha256}.{raw_body}" |
x-correlation-id |
Present when the operation that produced the event carried a correlation id |
Verifying signatures
Use the raw request bytes exactly as received — do not re-serialise the JSON. The key is the
UTF-8 bytes of your kwp_whsec_... secret. Header values are used verbatim (do not reformat the
timestamp).
import hashlib, hmac
def verify_x_webhook(raw_body: bytes, headers: dict, secret: str) -> bool:
# X-Webhook-Signature = sha256=HMAC_SHA256(secret, "{X-Webhook-Timestamp}." + raw_body)
signing_input = headers["X-Webhook-Timestamp"].encode() + b"." + raw_body
expected = "sha256=" + hmac.new(secret.encode(), signing_input, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, headers["X-Webhook-Signature"])
def verify_x_kwiikpay(raw_body: bytes, headers: dict, secret: str) -> bool:
# x-kwiikpay-signature = sha256=HMAC_SHA256(secret,
# "{unix_seconds}.{event_id}.{payload_sha256}." + raw_body)
if hashlib.sha256(raw_body).hexdigest() != headers["x-kwiikpay-payload-sha256"]:
return False
signing_input = ".".join([
headers["x-kwiikpay-signature-timestamp"],
headers["x-kwiikpay-webhook-id"],
headers["x-kwiikpay-payload-sha256"],
]).encode() + b"." + raw_body
expected = "sha256=" + hmac.new(secret.encode(), signing_input, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, headers["x-kwiikpay-signature"])
Worked example, so you can check your implementation offline:
- secret:
kwp_whsec_test x-kwiikpay-signature-timestamp:1750160809x-kwiikpay-webhook-id:0192f8a1-2222-7b2a-9d3f-6a7b8c9d0e1f- raw body:
{"id":"a","type":"t","occurred_at":"o","api_version":"2026-05-legacy","data":{}} x-kwiikpay-payload-sha256= SHA-256 of the raw body abovex-kwiikpay-signature=sha256=+ hex(HMAC-SHA256(secret,1750160809.0192f8a1-2222-7b2a-9d3f-6a7b8c9d0e1f.<payload_sha256>.<raw body>))
Reject anything that does not verify, and reject signatures older than your replay-window tolerance — a few minutes for clock skew is enough, because every attempt is signed at the moment it is sent.
Retries: what your response does
| Your endpoint returns | Outcome |
|---|---|
2xx |
Delivered. No further attempts. |
408, 425, 429, any 5xx, a timeout, or a connection failure |
Retried with exponential backoff: 30 s after attempt 1, then 1 m, 2 m, 4 m, 8 m, capped at 15 m — until the endpoint's attempt limit (maxAttempts, default 5, 1-25), then dead-lettered. |
Any other 4xx — 400, 401, 403, 404, 410, 422, ... |
Dead-lettered on the first attempt. Never retried. |
Each attempt has a hard 20-second deadline covering the connection, the send and reading your response body, so respond well inside that.
The consequence matters: if your endpoint cannot process right now — signature verifier down,
mid-deploy, an auth proxy answering 401 — return 503, never 401/403/400. A 401
permanently loses every event delivered in that window, deposit.received included, and nothing
you can call brings it back.
Deliveries may repeat and may arrive out of order. Key your dedupe logic on X-Webhook-Id and
design handlers so that processing the same event twice is harmless (upsert by the resource id in
data, not by "this webhook fired").
Recovering from a dead-lettered event
There is no partner-facing replay today. Recovery is a replay performed by Kwiikpay support:
contact support with the subscriptionId and the outboundWebhookEventId (or the resource id
and event type). A replay shows up on the delivery-attempts route as a further attempt with
isReplay: true and replayReason / replayCorrelationId / replayAttemptId set. In the
meantime, reconcile from the API: the v1 per-account transaction list for fiat, and the
withdrawal, deposit and account resources for state.
Diagnosing a missed event
curl "https://api.kwiikpay.io/api/v2/public/webhook-subscriptions/{subscriptionId}/delivery-attempts?status=DeadLettered&limit=50" \
-H "X-Api-Key: kp_live_..."
Rows are newest first. limit is clamped to 1-500 (default 100) and there is no cursor — narrow
with outboundWebhookEventId, deliveryId, deliveryAttemptId, eventType (exact) or status.
| Field | Meaning |
|---|---|
deliveryId |
One event paired with your endpoint; shared by every attempt of that pairing |
outboundWebhookEventId |
The event id (X-Webhook-Id) |
subscriptionId |
Your endpoint |
eventType, resourceId |
The event type and the envelope id |
payloadHash |
SHA-256 of the posted body (x-kwiikpay-payload-sha256) — identical on every attempt |
deliveryAttemptId, attemptNumber |
This attempt (X-Webhook-Delivery-Attempt-Id) and its 1-based number; retries and replays continue the count |
status |
Delivered (you returned 2xx); Pending (retryable failure, retry scheduled — see nextRetryAt); DeadLettered (terminal: limit reached, or a non-retryable 4xx). Failed and Delivering are reserved and not written by the current pipeline. The filter is case-insensitive; an unknown value is 400 invalid_webhook_delivery_status. |
responseStatusCode, responseBodyExcerpt |
What you returned — status, and the first 2048 bytes of your body; null when no response was received |
failureReason |
Why it did not succeed, ending with Status <code>. when there was a response |
nextRetryAt |
Only while Pending after a retryable failure |
isReplay, replayAttemptId, replayReason, replayCorrelationId |
Set on support-initiated replays |
recordedAt |
When the outcome was recorded (sort key) |
An endpoint id that does not exist, or that belongs to another customer, returns an empty page
rather than 404. A dead-lettered event has a DeadLettered row; an event that was never routed
to your endpoint (see "Which events reach your endpoint") has no row at all.
Rotating your signing secret
POST /api/v2/public/webhook-subscriptions/{subscriptionId}/secret-rotations with
{ "reason": "scheduled rotation" } returns the new secret once and bumps secretVersion.
The switch is immediate and total: from the moment the rotation commits, every delivery —
including queued retries of events created before the rotation — is signed with the new
secret, and the old one is never used again. There is no overlap window, so have your verifier
accept the new secret as part of the same change. Because 401 from your endpoint dead-letters
on the first attempt, a verifier still holding the old secret does not just fail — it loses
events. Compare secretSha256 on GET with the hash of the secret you hold to confirm you are
current.
If two rotations race, the loser gets 409 webhook_secret_rotation_conflict: the secret in that
response was never stored — re-read the endpoint and rotate again.
Updating and disabling
PATCH replaces the whole subscription: url, eventTypes and enabled are all required,
maxAttempts: null resets to the default. Read the endpoint first and send it back with your
changes. Re-enabling a disabled endpoint counts against the 10-enabled limit.
DELETE disables: no new events are routed to the endpoint from that moment, but deliveries
already queued (including scheduled retries) still go out, and events that occur while it is
disabled are not queued for later. The URL stays reserved to the disabled record.
Payload reference
Field-by-field tables for every event — including the two shapes of deposit.received and
withdrawal.completed, the fields that are always null, and the status vocabulary — are in
the "Outbound webhooks" section of the v1 API document. The short version of the
traps:
*_majoramounts andexecuted_rateare JSON numbers, not strings;*_minorare integers.deposit.receivedcrypto:referenceis the on-chain hash andprovider_transaction_idis the custodian's reference (not the hash);fee_*is always 0 andnet_*equals the gross;deposit_address_maskedis unmasked for addresses of 12 characters or fewer;customer_email,tenant_name,senderare alwaysnull.withdrawal.completed:data.type(fiat/crypto) is the discriminator, not the envelopetype; cryptocurrencyis alwaysnull(useasset_code) and cryptofee_minoris always 0;beneficiary_id,beneficiary_iban_masked,beneficiary_nameandblockchain_tx_hashare alwaysnullon both shapes today.conversion.completedreports the reserved source and the quoted target;executed_rateis derived from those two, not a provider-reported fill.- Provisioning events:
missing_fieldsis always[],vendor_error_codeis the literal"provider_status"whenever a reason exists,officer_noteduplicatesvendor_error_message, andstatusis an open vocabulary (active,pending,needs_information,failed, or any other internal state lowercased). kyc.*/kyb.*:customer_emailis alwaysnull,business_nameis your external reference,kyc_status/kyb_statusisapproved,pending_revieworrejected, andrejection_reasonsis[]except on rejection, where it is always a one-element array.