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.
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:
| Event | When it fires |
|---|---|
invoice.status_changed | An invoice entered a notifiable status: pending, paid, cancelled, expired, error or partially_refunded. The technical processing and cancelling states produce no webhook. |
invoice.qr_scanned | The 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.refunded | A refund finished, completed or failed; on failure the reason is in refund.error_code. |
catalog.item_processed | The 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.test | A 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.
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.
{
"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.
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.
A delivery is successful on any 2xx. Answer fast — within 5 seconds — and do the real work asynchronously.
429, and network errors. Invoice, refund and subscription events get 11 attempts: 10 s, 30 s, then 1, 1.5, 2, 5, 10, 15, 30 and 60 minutes — roughly a two-hour delivery window. catalog.* and receipt.* events get up to three attempts, so confirm their outcome by reading the resource instead.4xx. Do not answer 401, 403 or 404 to a legitimate event — it is lost until you replay it by hand from the delivery log in the dashboard.307 and 308 are followed, up to two hops, with the same body and the same signature. 301, 302 and 303 are a non-delivery with no retry: an HTTP client must switch to GET on them and drop the body, so your handler would never see the payload. Point webhook_url straight at the final https address. A non-delivery caused by a redirect does not increment the circuit-breaker counter: the channel stays active and looks healthy in the dashboard while the events are lost.GET /api/v1/invoices/{id}. Any successful delivery resets the counter: fix the endpoint and press the test-notification button in the dashboard. The channel state is visible in the API key list as webhook_status: active / paused / disabled.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.
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.
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.
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.
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.
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.
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.
The sandbox is free, never calls Kaspi, and fires the same webhooks your production integration will receive.
Create an account