Webhooks
Signed events on every status change, courier change and ETA shift — at least once, in order, with retries for 24 hours.
Instead of polling, give us an HTTPS endpoint: we POST an event to it every time something happens to your orders.
Events
| Event | When |
|---|---|
balance.low | Balance low. The deposit or the remaining credit limit fell below your threshold. |
claim.updated | Claim updated. The status of a claim (damage, loss, complaint) changed. |
order.courier_assigned | Courier assigned. A courier was assigned or replaced (including a handover to another courier). |
order.created | Order created. An order was created — by API, in the cabinet, by import or by a dispatcher. |
order.delivered | Delivered. The order was handed over — with proof and, for cash on delivery, the amount and the method. This is the signal to send the electronic fiscal receipt to the buyer. |
order.eta_changed | ETA changed. The ETA moved by more than your threshold (5 minutes by default, set by your account manager). |
order.failed | Not delivered. The order could not be delivered; it goes back to the pickup point. |
order.price_changed | Price changed. The final price changed (waiting time, return, address change). |
order.returned | Returned. The order was returned to the pickup point. |
order.status_changed | Status changed. Any status transition, with the previous and the new status and the reason code. Sent for every transition in order of the version. |
ping | Ping. A test event sent by the «Ping» button in the cabinet. Not subscribable; always delivered. |
An empty subscription receives every event. The courier position is never sent by webhooks — use
/track or the tracking page.
Endpoints
Add endpoints in the cabinet (section «Интеграция») or with the API:
curl https://partner.sapamoto.kz/v1/webhooks \
-H "Authorization: Bearer $SAPA_KEY" -H "Content-Type: application/json" \
-d '{"url": "https://shop.example.kz/hooks/sapa", "event_types": ["order.status_changed", "order.delivered"]}'The response contains the signing secret whsec_… — it is shown only once; rotate it in the cabinet. Endpoints
must use https on a public host; up to 10 endpoints per environment. Test and live endpoints are separate and
receive only the events of their environment.
The request
POST /hooks/sapa HTTP/1.1
Host: shop.example.kz
Content-Type: application/json
User-Agent: SapaMoto-Webhooks/1
X-Sapa-Signature: t=1761299590,v1=5f0c3be7a1d24c09e8b6f3a2d1c0b9a8e7f6d5c4b3a29180f7e6d5c4b3a2918f
X-Sapa-Event-Id: 0192f1c5-0a1b-7c2d-8e3f-4a5b6c7d8e9f
X-Sapa-Event-Type: order.status_changed
X-Sapa-Delivery-Id: 0192f1c5-1b2c-7d3e-9f4a-5b6c7d8e9f0a{
"api_version": "v1",
"created_at": "2026-10-24T09:12:30Z",
"data": {
"external_id": "SHOP-100245",
"order_id": "0192f1b2-6d4e-7a10-b3c2-8e1f0d9a4c57",
"previous_status": "assigned",
"status": "to_pickup",
"version": 4
},
"event_id": "0192f1c5-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"livemode": true,
"order_id": "0192f1b2-6d4e-7a10-b3c2-8e1f0d9a4c57",
"order_version": 4,
"type": "order.status_changed"
}| Field | Meaning |
|---|---|
event_id | stable id of the event — the same on every retry and on a manual replay |
type | event type |
api_version | v1 |
created_at | when the event happened |
livemode | false for sandbox events |
order_id, order_version | for order events; the version grows with every change of the order |
data | event data; order events carry order_id, external_id, status, version and often the full order |
Payload schemas of every event are in the API reference (section Webhooks). New fields can
appear in v1 — ignore unknown fields.
Verifying the signature
X-Sapa-Signature: t=<unix seconds>,v1=<hex> where v1 = HMAC-SHA256(secret, "<t>.<raw body>").
- Take the raw request body — before any JSON parsing or re-serialisation.
- Compute the HMAC-SHA256 of
t + "." + bodywith the endpoint secret (the wholewhsec_…string, UTF-8). - Compare with
v1in constant time. - Reject timestamps more than 5 minutes away from your clock — it stops replays of captured requests.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.SAPA_WEBHOOK_SECRET; // whsec_…
function verify(header = "", raw) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const expected = crypto
.createHmac("sha256", SECRET)
.update(Buffer.concat([Buffer.from(`${parts.t}.`), raw]))
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return fresh && typeof parts.v1 === "string" && parts.v1.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}
app.post("/hooks/sapa", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.get("X-Sapa-Signature"), req.body)) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
queue.push(event); // deduplicate by event.event_id and process asynchronously
res.sendStatus(200); // answer within 10 seconds
});import hashlib, hmac, os, time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["SAPA_WEBHOOK_SECRET"].encode() # whsec_…
def verify(header: str, raw: bytes) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
expected = hmac.new(SECRET, t.encode() + b"." + raw, hashlib.sha256).hexdigest()
return t.isdigit() and abs(time.time() - int(t)) < 300 and hmac.compare_digest(expected, v1)
@app.post("/hooks/sapa")
def hook():
raw = request.get_data() # raw bytes, before parsing
if not verify(request.headers.get("X-Sapa-Signature", ""), raw):
abort(400)
event = request.get_json()
enqueue(event) # deduplicate by event["event_id"]
return "", 200<?php
$secret = getenv('SAPA_WEBHOOK_SECRET'); // whsec_…
$raw = file_get_contents('php://input'); // raw body, before json_decode
$parts = [];
foreach (explode(',', $_SERVER['HTTP_X_SAPA_SIGNATURE'] ?? '') as $p) {
[$k, $v] = array_pad(explode('=', $p, 2), 2, '');
$parts[$k] = $v;
}
$expected = hash_hmac('sha256', ($parts['t'] ?? '') . '.' . $raw, $secret);
$fresh = abs(time() - (int) ($parts['t'] ?? 0)) < 300;
if (!$fresh || !hash_equals($expected, $parts['v1'] ?? '')) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
enqueue($event); // deduplicate by $event['event_id']
http_response_code(200);import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class SapaSignature {
/** header = X-Sapa-Signature, rawBody = the request body exactly as received. */
public static boolean verify(String secret, String header, String rawBody) throws Exception {
String t = null, v1 = null;
for (String part : header.split(",")) {
if (part.startsWith("t=")) t = part.substring(2);
if (part.startsWith("v1=")) v1 = part.substring(3);
}
if (t == null || v1 == null) return false;
if (Math.abs(Instant.now().getEpochSecond() - Long.parseLong(t)) > 300) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = HexFormat.of().formatHex(mac.doFinal((t + "." + rawBody).getBytes(StandardCharsets.UTF_8)));
return MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), v1.getBytes(StandardCharsets.UTF_8));
}
}Frameworks that parse JSON before your handler (Express json(), Spring @RequestBody Map) change the bytes —
verify against the raw body.
Delivery guarantees
- At least once. An event may arrive more than once — deduplicate by
event_id(store processed ids for a few days). - In order per order. Events of one order to one endpoint are sent strictly in order of
order_version: the next one waits while the previous one is being retried. Still, compareorder_versionwith what you have and drop older events. - Outbox. Events are written in the same transaction as the change of the order, so a restart of our servers right after a status change never loses a webhook.
- Latency. Usually within seconds of the event (p95 ≤ 5 s).
Responding and retries
Answer any 2xx within 10 seconds — the body is ignored. Do the work asynchronously after answering.
Any other status, a timeout or a connection error is retried with the same event_id:
| Attempt | Delay after the previous one |
|---|---|
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 15 minutes |
| 5 | 1 hour |
| 6 | 3 hours |
| 7… | 6 hours, until 24 hours after the event |
After 24 hours the delivery is marked failed: the administrator of your organization gets an e-mail and our integration monitoring sees it.
An endpoint that keeps failing for more than 24 hours is suspended. Its events are held for 7 days; when you
re-enable it (PUT /v1/webhooks/{id} with {"status": "active"} or in the cabinet) the held events are sent in
order. Older events expire.
Delivery log and replay
The cabinet (section «Интеграция») keeps every delivery for 30 days: time, type, URL, attempts, the
status code and the first 2 KB of your response. Replay («Отправить снова») re-sends an event with the same
event_id; Ping («Пинг») sends a ping event to test an endpoint.
Example payloads
order.courier_assigned
{
"api_version": "v1",
"created_at": "2026-10-24T09:12:30Z",
"data": {
"courier": {
"name": "Айдос",
"phone": "+7 700 *** ** 15",
"transport": "moped"
},
"external_id": "SHOP-100245",
"order_id": "0192f1b2-6d4e-7a10-b3c2-8e1f0d9a4c57",
"replaced": false,
"status": "assigned",
"version": 3
},
"event_id": "0192f1c5-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"livemode": true,
"order_id": "0192f1b2-6d4e-7a10-b3c2-8e1f0d9a4c57",
"order_version": 3,
"type": "order.courier_assigned"
}order.eta_changed
{
"api_version": "v1",
"created_at": "2026-10-24T09:12:30Z",
"data": {
"eta": "2026-10-24T10:00:00Z",
"external_id": "SHOP-100245",
"order_id": "0192f1b2-6d4e-7a10-b3c2-8e1f0d9a4c57",
"previous_eta": "2026-10-24T09:48:00Z",
"status": "to_pickup",
"version": 5
},
"event_id": "0192f1c5-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"livemode": true,
"order_id": "0192f1b2-6d4e-7a10-b3c2-8e1f0d9a4c57",
"order_version": 5,
"type": "order.eta_changed"
}order.failed
{
"api_version": "v1",
"created_at": "2026-10-24T09:12:30Z",
"data": {
"external_id": "SHOP-100245",
"order_id": "0192f1b2-6d4e-7a10-b3c2-8e1f0d9a4c57",
"reason_code": "recipient_unreachable",
"status": "failed",
"version": 9
},
"event_id": "0192f1c5-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"livemode": true,
"order_id": "0192f1b2-6d4e-7a10-b3c2-8e1f0d9a4c57",
"order_version": 9,
"type": "order.failed"
}