Kaspi Pay payment webhooks via ApiPay

Kaspi Pay sends merchants no callbacks of its own — in the app a payment is only visible on screen. ApiPay posts them to your HTTPS endpoint instead, signed and retried, so your code finds out about a payment without polling.

Which events exist?

You set one webhook_url per API key in the dashboard, and every event for that key is posted there as POST with Content-Type: application/json. The full list:

EventWhen it fires
invoice.status_changedAn invoice entered a notifiable status: pending, paid, cancelled, expired, error or partially_refunded. The technical processing and cancelling states produce no webhook.
invoice.qr_scannedThe customer scanned a QR invoice and is on the payment screen (qr_substate: "scanned", status still pending). Sent once per QR, and it is transient — paid and cancelled are both still possible afterwards.
invoice.refundedA refund finished, completed or failed; on failure the reason is in refund.error_code.
catalog.item_processedThe result of a catalogue operation, one event per item: an upload of 50 items produces up to 50 deliveries.
qr_refund.*identified, completed, expired, failed, execution_uncertain — the refund-by-QR flow.
subscription.*created, payment_succeeded, payment_failed, grace_period_started, expired, paused, resumed, cancelled.
receipt.*issued, failed — fiscal receipts in Kaspi OFD.
cashbox.*shift_closed, shift_close_failed.
webhook.testA manual test from the dashboard. It carries a dummy invoice with status: "test" — your receiver should quietly ignore it.

One of them deserves a rule of its own: qr_refund.execution_uncertain means the outcome of a refund is not proven. A handler must never start a second refund on it — raise a task for a human instead.

What does the payload look like?

Every event is a JSON object with event, the resource, an optional source (the name of the API key that created the invoice) and a timestamp. All timestamps are UTC with a +00:00 offset. Sandbox events carry is_sandbox: true.

invoice.status_changed

{
  "event": "invoice.status_changed",
  "invoice": {
    "id": 42,
    "external_order_id": "order_123",
    "amount": "15000.00",
    "status": "paid",
    "description": "Order payment",
    "kaspi_invoice_id": "13234689513",
    "client_name": "Ivan Ivanov",
    "client_phone": "87071234567",
    "is_sandbox": false,
    "kaspi_source_type": "GOLD",
    "kaspi_sale_type": "Remote",
    "paid_at": "2026-02-12T14:35:00+00:00"
  },
  "source": "My API Key",
  "timestamp": "2026-02-12T14:35:01+00:00"
}

Conditional fields appear only when they are not null: paid_at only on paid, cancelled_at only on cancelled, error_message and error_code on error, subtotal and discount_sum only for a discounted or line-item invoice. For a QR invoice with no phone number, client_phone is null.

How do I verify the signature?

When a webhook secret is set for the API key, every request carries

X-Webhook-Signature: sha256=<hmac_sha256(raw_body, webhook_secret)>

Compute the HMAC over the raw bytes of the request body, not over a re-serialised JSON object — reordered keys or changed spacing produce a different digest. Compare in constant time. The requests also arrive with User-Agent: Kaspi-Pay-API/1.0, but a user agent proves nothing; the signature does.

Requirements for the endpoint itself: HTTPS, publicly reachable, no authentication of your own in front of it, and no redirects. A URL on a private IP is rejected with 422 when you save it. In production, an organisation whose business profile has not been approved yet must point webhook_url at a real domain: an IP address is rejected with 422 webhook_url_requires_domain, a tunnel address (ngrok and similar) with 422 webhook_url_tunnel_forbidden. Tunnels remain available for sandbox testing.

What happens if my server is down?

A delivery is successful on any 2xx. Answer fast — within 5 seconds — and do the real work asynchronously.

How do I avoid processing the same event twice?

Deduplicate. ApiPay sends one webhook per real transition, but duplicates are still possible, so key your handler on (invoice.id, invoice.status) for invoice.status_changed, on (refund.id, refund.status) for invoice.refunded, and on qr_refund.id for the refund flow. Key subscription.* on (event, subscription.id, invoice_id): unlike invoice.status_changed, subscription and refund events have no server-side single-delivery gate.

Expect sequences that look wrong and are not: cancelled → paid and expired → paid (a payment won the race), error → pending (reconciliation), paid → partially_refunded. The terminal status is set by Kaspi, so trust the event rather than a local timer.

On the sending side, pass external_order_id_idempotency when you create an invoice: a repeated call with the same key is answered with 409 instead of creating a second invoice. Catalog operations use an Idempotency-Key header for the same purpose.

How do I read status without webhooks?

Webhooks are the main path; polling is the fallback for systems that physically cannot accept incoming HTTP. GET /api/v1/invoices/{id} has a raised limit of 1000 requests per minute for exactly that case, while the general ceiling is 200 requests per minute per key.

Deliveries themselves are auditable: GET /api/v1/webhook-logs lists attempts (filters include invoice_id, event and status), and GET /api/v1/webhook-logs/{id} returns one delivery in full with the request body, the response status and the response time. Records are kept for 14 days. Catalog events have their own log at GET /api/v1/catalog/webhook-logs; subscription.* and receipt.* events are not written to the delivery log at all.

How do I test it?

Every account gets a sandbox organisation that never calls Kaspi. Create an invoice there, then drive it through its life cycle with POST /api/v1/invoices/{id}/simulate-status and watch the webhooks land. The dashboard also has a manual test button that fires webhook.test. In the sandbox the retry ladder for invoice events is shortened to three attempts; refunds and subscriptions retry on the full ladder in both modes.

Frequently asked questions

Does Kaspi Pay send webhooks to the merchant?

No. Kaspi Pay itself has no webhooks for merchants — in the app a payment is only visible on screen. ApiPay sends them from its own side: invoice.status_changed on every notifiable status change of an invoice, plus invoice.qr_scanned, invoice.refunded, catalog.item_processed (one event per catalogue item), the subscription.*, qr_refund.*, receipt.* and cashbox.* families, and a manual webhook.test.

How do I verify an ApiPay webhook signature?

Compute HMAC-SHA256 over the raw request body using your webhook secret and compare it with the hex digest in the X-Webhook-Signature header, which has the form sha256=<hex>. Verify the raw bytes, not a re-serialised JSON object, and compare in constant time. The header is present only when a webhook secret is set for the API key.

What happens if my server does not respond?

A delivery counts as successful on any 2xx. HTTP 500 and above, exactly 429, and network errors are retried: 11 attempts for invoice, refund and subscription events, with pauses of 10 s, 30 s, then 1, 1.5, 2, 5, 10, 15, 30 and 60 minutes — about two hours in total. Catalog and receipt events get up to three attempts. Other 4xx responses are not retried at all. After five consecutive failures deliveries are suspended for 5 minutes, after ten for 30 minutes, after twenty for 2 hours, and after fifty accumulated failures webhooks for that key are switched off. Events that fall into an open breaker are not queued and are not delivered later — read those statuses with GET /api/v1/invoices/{id}. Any successful delivery, including the test webhook from the dashboard, resets the counter and re-enables the channel.

Can I read the payment status without webhooks?

Yes, but it is the fallback path, not the main one. GET /api/v1/invoices/{id} has a raised limit of 1000 requests per minute for systems that cannot accept incoming HTTP at all. Delivery attempts themselves are readable through GET /api/v1/webhook-logs, with one delivery in full at GET /api/v1/webhook-logs/{id}; those records are kept for 14 days.

Where can I read more?

Build it against the sandbox first

The sandbox is free, never calls Kaspi, and fires the same webhooks your production integration will receive.

Create an account