Sapa Moto

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

EventWhen
balance.lowBalance low. The deposit or the remaining credit limit fell below your threshold.
claim.updatedClaim updated. The status of a claim (damage, loss, complaint) changed.
order.courier_assignedCourier assigned. A courier was assigned or replaced (including a handover to another courier).
order.createdOrder created. An order was created — by API, in the cabinet, by import or by a dispatcher.
order.deliveredDelivered. 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_changedETA changed. The ETA moved by more than your threshold (5 minutes by default, set by your account manager).
order.failedNot delivered. The order could not be delivered; it goes back to the pickup point.
order.price_changedPrice changed. The final price changed (waiting time, return, address change).
order.returnedReturned. The order was returned to the pickup point.
order.status_changedStatus changed. Any status transition, with the previous and the new status and the reason code. Sent for every transition in order of the version.
pingPing. 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
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

HTTP
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
POST <your URL> — order.status_changed
{
  "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"
}
FieldMeaning
event_idstable id of the event — the same on every retry and on a manual replay
typeevent type
api_versionv1
created_atwhen the event happened
livemodefalse for sandbox events
order_id, order_versionfor order events; the version grows with every change of the order
dataevent 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>").

  1. Take the raw request body — before any JSON parsing or re-serialisation.
  2. Compute the HMAC-SHA256 of t + "." + body with the endpoint secret (the whole whsec_… string, UTF-8).
  3. Compare with v1 in constant time.
  4. 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
});

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, compare order_version with 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:

AttemptDelay after the previous one
21 minute
35 minutes
415 minutes
51 hour
63 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

POST <your URL> — 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

POST <your URL> — 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

POST <your URL> — 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"
}

These docs are built from the platform's OpenAPI specification. Download OpenAPI