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.
You can set endpoints up in two ways: in the dashboard (Settings, then API; no code needed, see Managing endpoints from the dashboard) or through the API (the routes below). Both manage the same endpoints.
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 |
Managing endpoints from the dashboard
Business customers can add, change and disable webhook endpoints without calling the API. Sign in to the dashboard and open Settings, then the API tab. Webhooks are listed below your API keys.
Before you start. The tab shows what is still outstanding under "Before you can use the API"
(for example completed verification), and an Activate API access step if it is not on yet.
Until those are done an endpoint cannot be added. Personal (individual) accounts do not have
webhooks or API keys; a request from a personal account is refused with 403
api_access_not_available_personal.
Add an endpoint
- Choose Add endpoint.
- Enter the Endpoint URL. It must start with
https://and meet the endpoint requirements below (public host, no credentials, one endpoint per URL). - Under Events, tick at least one event. The list is the same closed list as
GET /api/v2/public/webhook-spec(see The event catalogue); each event is shown by its name, for exampledeposit.received. - Choose Add endpoint. The signing secret is shown once. Copy it into your verifier before you close the window; it cannot be shown again.
Change or stop an endpoint
- Edit endpoint changes the URL and the ticked events. Events you remove stop being routed to it from that moment.
- Disable endpoint stops new events being sent. It follows the same rules as
DELETEin the API (see Updating and disabling): deliveries already queued still go out, and events that happen while it is disabled are not replayed later. - Delivery attempts shows the attempts we made to your URL, with the result, so you can see why an event did not arrive. A replay started by KwiikPay support is marked as such.
What the dashboard does not do
- It does not rotate a signing secret. Use the API for that.
- It does not set the retry limit. Endpoints added in the dashboard use the default of 5 attempts; set a different limit (1 to 25) through the API.
- It does not send a test event. Check the event names you ticked against
GET /api/v2/public/webhook-spec, and use Delivery attempts to confirm the first real delivery reached you.
Which events reach it. An endpoint added in the dashboard belongs to your customer. It receives your own events, plus those of the end-customers you onboarded, the same as an endpoint registered with a key bound to your customer (see Which events reach your endpoint). Endpoints added in the dashboard and through the API share the same limit of 10 enabled endpoints per customer.
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 |
sub_customer.onboarding_phase_changed |
An end-customer's onboarding phase changed — whitelabel parents learn where their customer is and who must act (data.phase + data.case_status). For a customer whose verification was imported from your own verified record with a share token, the event also carries data.source = share_token and, while steps are still missing, data.missing_steps (a comma-separated list) |
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. kyc.approved means KwiikPay approved the customer, not that the identity provider's own review came back GREEN (that moment is kyc.pending_review) |
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. It is also sent when a USD account request is waiting for details the bank needs: data.missing_fields then lists the stable machine codes of the missing details (see Sub-customers), once per change of the missing set |
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 |
internal_transfer.completed |
A transfer between a parent and one of its end-customers completed, in either direction. It is a ledger move inside the pooled account, so no deposit.received or withdrawal.completed fires for it (see Internal transfers) |
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.
Internal transfers
internal_transfer.completed tells a parent that money moved between its own account and one of
its end-customers' accounts, in either direction. The two accounts sit in the same pooled account,
so the move is a ledger entry only: no bank payment is made and neither deposit.received nor
withdrawal.completed fires for either side. Without this event your own ledger would not see these
moves. It fires for every way a transfer can be made: the dashboard, the
.../end-customers/{endCustomerId}/internal-transfers route, the older .../consolidate route, and a
transfer linked to an exchange.
- Once, on commit. The event is created in the same step that completes the transfer, so a
completed transfer always has its event queued for your endpoints and a transfer that did not
complete has none. A refused
transfer fires nothing. A retry of a transfer that already completed (same
client_reference) fires nothing new; dedupe onX-Webhook-Idas for every event. - Both directions, to the parent. The event is delivered to the parent's endpoints, whichever side
is the source, and to tenant-wide endpoints as for every event.
customer_idis always the parent. An end-customer's own endpoint would not receive it: the payload names the parent's account. - No backfill. Transfers made before you subscribed are not re-sent. Some of them you can read
back.
GET /api/v1/partner/customers/{customerId}/end-customers/{endCustomerId}/internal-transfers(list, by id, or byclient_reference) returns the transfers made on the.../internal-transfersroute, from the dashboard's transfer action, or linked to an exchange. It does not return transfers made on the older.../consolidateroute. They have no transfer record, so they are not in the list, and a read by theirtransfer_id(which is ajournal_entry_id) returns 404. For those transfers, keep thejournal_entry_idthat the route returned.
data field |
Meaning |
|---|---|
transfer_id |
The transfer id, the same as internal_transfer.id on the transfer routes. It is also the envelope id. The older .../consolidate route has no transfer record: there it is the journal_entry_id that route returns |
tenant_id |
Your tenant id |
customer_id |
The parent customer id, the partner whose endpoint receives this |
end_customer_id |
The end-customer on the other side |
direction |
parent_to_end_customer or end_customer_to_parent, the same strings the transfer routes use. The older .../consolidate route is always end_customer_to_parent |
source_customer_id, destination_customer_id |
The customer the money left, and the customer it reached |
source_account_id, destination_account_id |
KwiikPay account ids of the two accounts, the same ids the account routes return (virtual_account_id on viban.ready). Never bank coordinates |
amount_minor |
Amount in minor units, a JSON integer (GBP 250.00 is 25000) |
currency, asset_scale |
ISO 4217 code (GBP or EUR) and its decimal places (2). No conversion takes place |
client_reference |
The client_reference UUID the transfer was made with; null on the older .../consolidate route |
origin_exchange_order_id |
The settled exchange the transfer was linked to; null when none |
status |
Always completed |
created_at, completed_at |
When the transfer was recorded and completed, written exactly as the envelope's occurred_at (which equals completed_at). They are equal today; on the older .../consolidate route both are the ledger posting time |
{
"id": "019ed5b1-4c2d-7e3f-9a01-b2c3d4e5f607",
"type": "internal_transfer.completed",
"occurred_at": "2026-10-29T09:15:30.1234560+00:00",
"api_version": "2026-05-legacy",
"data": {
"transfer_id": "019ed5b1-4c2d-7e3f-9a01-b2c3d4e5f607",
"tenant_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"customer_id": "019ed130-1a2b-7c3d-8e4f-0123456789ab",
"end_customer_id": "019ed130-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"direction": "parent_to_end_customer",
"source_customer_id": "019ed130-1a2b-7c3d-8e4f-0123456789ab",
"destination_customer_id": "019ed130-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"source_account_id": "019ed130-7a2c-7def-8a01-aabbccddeeff",
"destination_account_id": "019ed131-1b3d-7e0f-9b12-bbccddeeff00",
"amount_minor": 25000,
"currency": "GBP",
"asset_scale": 2,
"client_reference": "785a23b2-a916-4f29-b8ed-0dcb4412fb09",
"origin_exchange_order_id": null,
"status": "completed",
"created_at": "2026-10-29T09:15:30.1234560+00:00",
"completed_at": "2026-10-29T09:15:30.1234560+00:00"
}
}
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.internal_transfer.completed:customer_idis the parent, not the end-customer, andstatusis alwayscompleted. The direction decides which ofend_customer_idandcustomer_idis the source; readsource_customer_idanddestination_customer_idrather than inferring it.- Provisioning events:
missing_fieldsis[]except on acustomer.needs_informationthat reports a USD request waiting for details, where it holds the machine codes of the missing details,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.
Initiating actor
Once webhook attribution is enabled, customer-created/onboarding case/status, beneficiary and withdrawal payloads include data.attribution, preserving the resource’s original authenticated initiator across background processing and retries. Uncaptured records report unknown. The field may be absent during rollout or when replaying older saved events; treat absence as unknown. See Attribution & audit events for the field contract, coverage and the audit:read feed.