Webhooks

Webhooks let you react to events (a deposit arriving, KYC completing) instead of polling. This is the recommended way to keep your own systems in sync with KwiikPay.

Registering an endpoint

curl -X POST https://api.kwiikpay.io/api/v2/public/webhook-subscriptions \
  -H "X-Api-Key: kwp_live_..." \
  -H "Idempotency-Key: register-webhook-a1b2c3" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/kwiikpay/webhooks", "eventTypes": ["deposit.received", "kyc.approved"] }'

Note: /api/v2/public request/response bodies use camelCase (eventTypes), while /api/v1/partner uses snake_case (event_types on its equivalent webhook endpoints) — the two generations haven't converged on one wire format yet. Match the casing shown in the API Reference for whichever generation you're calling.

Fetch the live, authoritative list of event types your tenant can subscribe to via GET /api/v2/public/webhook-spec — always read this rather than hardcoding an event list, since it reflects what your tenant is actually entitled to receive.

Your endpoint must be HTTPS and respond 2xx quickly. Non-2xx responses and timeouts are retried with backoff up to a configured attempt limit.

The envelope

Every delivery is an HTTP POST with Content-Type: application/json and a common envelope; the per-event fields live under data.

{
  "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 field is the id of the RESOURCE the event is about (e.g. the payment or account id) — not the event id. Every event about the same resource carries the same envelope id, so never dedupe on it. The event id travels in the X-Webhook-Id header (below).

Headers

Header What it's for
X-Webhook-Id The event id — identical across every retry of the same event. This is the header to dedupe on. It does NOT match the envelope id (which is the resource id).
X-Webhook-Delivery-Id Your endpoint's own id — constant across every delivery to it. Do not dedupe on this.
X-Webhook-Delivery-Attempt-Id Unique per delivery attempt — differs on every retry. It can only detect an exact duplicate of one attempt; it is NOT an event-level dedupe key.
X-Webhook-Event The event type, e.g. deposit.received
X-Webhook-Timestamp ISO-8601 timestamp this delivery attempt was signed at — every attempt is re-signed at send time, so the timestamp is always fresh and a replay window sized for clock skew (minutes) is enough
X-Webhook-Signature sha256=<hex HMAC-SHA256> over "{X-Webhook-Timestamp}.{raw_body}", using your subscription's CURRENT signing secret

Verifying signatures

import hmac, hashlib

def verify(raw_body: bytes, timestamp: str, signature_header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), f"{timestamp}.{raw_body.decode()}".encode(), hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature_header)

Reject any request that doesn't verify, and reject requests whose X-Webhook-Timestamp is older than your replay-window tolerance.

Idempotent handling

Deliveries can repeat. Key your dedup logic on X-Webhook-Id (the event id) so a retried delivery of an event you already processed is a no-op, and design your handler so processing the same event twice is harmless (e.g. upsert by data's own resource id, not by "this webhook fired"). X-Webhook-Delivery-Attempt-Id changes on every retry — it identifies one delivery attempt, never the event.

Rotating your signing secret

POST /api/v2/public/webhook-subscriptions/{subscriptionId}/secret-rotations issues a new secret (returned once, in the response body). Signing uses the CURRENT secret at send time: the moment the rotation commits, every subsequent delivery — including queued retries of events created before the rotation — is signed with the new secret, so nothing in flight keeps the old signature. Deploy the new secret to your verification code as part of the rotation itself.