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.

Two silent cases to know about, because neither writes a delivery-attempt row:

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:

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 4xx400, 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: