Вебхуки
Подписанные события на каждую смену статуса, курьера и сдвиг ETA — хотя бы один раз, по порядку, с повторами 24 часа.
Вместо опроса дайте нам HTTPS-адрес: мы отправляем на него POST с событием каждый раз, когда с вашими заказами
что-то происходит.
События
| Событие | Когда |
|---|---|
balance.low | Баланс ниже порога. Депозит или остаток кредитного лимита опустился ниже вашего порога. |
claim.updated | Претензия обновлена. Изменился статус претензии (повреждение, утеря, жалоба). |
order.courier_assigned | Курьер назначен. Курьер назначен или заменён (включая передачу заказа другому курьеру). |
order.created | Заказ создан. Заказ создан — через API, в кабинете, импортом или диспетчером. |
order.delivered | Вручён. Заказ вручён — с доказательствами, а для наложенного платежа — с суммой и способом. Это сигнал отправить покупателю электронный фискальный чек. |
order.eta_changed | ETA изменилось. ETA сдвинулось больше вашего порога (по умолчанию 5 минут, настраивает аккаунт-менеджер). |
order.failed | Не доставлен. Заказ не удалось вручить; он едет обратно на точку забора. |
order.price_changed | Цена изменилась. Изменилась итоговая цена (ожидание, возврат, смена адреса). |
order.returned | Возвращён. Заказ возвращён на точку забора. |
order.status_changed | Статус изменился. Любой переход статуса — с прежним и новым статусом и кодом причины. Отправляется на каждый переход в порядке версии. |
ping | Пинг. Тестовое событие от кнопки «Пинг» в кабинете. На него не подписываются; доставляется всегда. |
Пустая подписка получает все события. Позиция курьера вебхуками не отправляется — используйте
/track или страницу отслеживания.
Адреса
Добавляйте адреса в кабинете (раздел «Интеграция») или через 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"]}'В ответе — секрет подписи whsec_…; он показывается один раз, перевыпуск — в кабинете. Адрес — только https на
публичном хосте; до 10 адресов на среду. Тестовые и боевые адреса раздельные и получают события только своей среды.
Запрос
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"
}| Поле | Что это |
|---|---|
event_id | стабильный id события — одинаковый при каждом повторе и ручной повторной отправке |
type | тип события |
api_version | v1 |
created_at | когда произошло событие |
livemode | false для событий песочницы |
order_id, order_version | для событий заказа; версия растёт с каждым изменением заказа |
data | данные события; события заказа несут order_id, external_id, status, version и часто весь order |
Схемы содержимого каждого события — в справочнике API (раздел Webhooks). Внутри v1 могут
появляться новые поля — игнорируйте незнакомые.
Проверка подписи
X-Sapa-Signature: t=<unix-секунды>,v1=<hex>, где v1 = HMAC-SHA256(secret, "<t>.<сырое тело>").
- Возьмите сырое тело запроса — до любого разбора JSON и пересериализации.
- Посчитайте HMAC-SHA256 от
t + "." + телос секретом адреса (вся строкаwhsec_…, UTF-8). - Сравните с
v1за постоянное время. - Отклоняйте метки времени, отличающиеся от ваших часов больше чем на 5 минут, — это защищает от повтора перехваченных запросов.
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); // дубли — по event.event_id, обработка — асинхронно
res.sendStatus(200); // ответ — в течение 10 секунд
});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() # сырые байты, до разбора
if not verify(request.headers.get("X-Sapa-Signature", ""), raw):
abort(400)
event = request.get_json()
enqueue(event) # дубли — по event["event_id"]
return "", 200<?php
$secret = getenv('SAPA_WEBHOOK_SECRET'); // whsec_…
$raw = file_get_contents('php://input'); // сырое тело, до 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); // дубли — по $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 = тело запроса ровно в том виде, как пришло. */
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));
}
}Фреймворки, которые разбирают JSON до вашего обработчика (Express json(), Spring @RequestBody Map), меняют байты —
проверяйте подпись по сырому телу.
Гарантии доставки
- Хотя бы один раз. Событие может прийти несколько раз — убирайте дубли по
event_id(храните обработанные id несколько дней). - По порядку внутри заказа. События одного заказа на один адрес уходят строго в порядке
order_version: следующее ждёт, пока повторяется предыдущее. И всё же сравнивайтеorder_versionс тем, что у вас есть, и отбрасывайте старые события. - Outbox. События записываются в той же транзакции, что изменение заказа, поэтому перезапуск наших серверов сразу после смены статуса не теряет вебхук.
- Задержка. Обычно секунды после события (p95 ≤ 5 с).
Ответ и повторы
Ответьте любым 2xx в течение 10 секунд — тело ответа не читается. Работу делайте асинхронно после ответа.
Любой другой статус, таймаут или ошибка соединения — повтор с тем же event_id:
| Попытка | Пауза после предыдущей |
|---|---|
| 2 | 1 минута |
| 3 | 5 минут |
| 4 | 15 минут |
| 5 | 1 час |
| 6 | 3 часа |
| 7… | 6 часов, пока не пройдёт 24 часа от события |
Через 24 часа доставка помечается не доставленной: администратор вашей организации получает письмо, а наш мониторинг интеграций видит её.
Адрес, который отвечает ошибками дольше 24 часов, приостанавливается. Его события копятся 7 дней; когда вы
включаете адрес снова (PUT /v1/webhooks/{id} с {"status": "active"} или в кабинете), накопленные события
отправляются по порядку. Более старые истекают.
Журнал и повторная отправка
Кабинет (раздел «Интеграция») хранит каждую доставку 30 дней: время, тип, адрес, попытки, код ответа и первые 2 КБ
вашего ответа. «Отправить снова» повторно отправляет событие с тем же event_id; «Пинг» отправляет событие ping,
чтобы проверить адрес.
Примеры событий
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"
}