Sapa Moto

Вебхуки

Подписанные события на каждую смену статуса, курьера и сдвиг ETA — хотя бы один раз, по порядку, с повторами 24 часа.

Вместо опроса дайте нам HTTPS-адрес: мы отправляем на него POST с событием каждый раз, когда с вашими заказами что-то происходит.

События

СобытиеКогда
balance.lowБаланс ниже порога. Депозит или остаток кредитного лимита опустился ниже вашего порога.
claim.updatedПретензия обновлена. Изменился статус претензии (повреждение, утеря, жалоба).
order.courier_assignedКурьер назначен. Курьер назначен или заменён (включая передачу заказа другому курьеру).
order.createdЗаказ создан. Заказ создан — через API, в кабинете, импортом или диспетчером.
order.deliveredВручён. Заказ вручён — с доказательствами, а для наложенного платежа — с суммой и способом. Это сигнал отправить покупателю электронный фискальный чек.
order.eta_changedETA изменилось. ETA сдвинулось больше вашего порога (по умолчанию 5 минут, настраивает аккаунт-менеджер).
order.failedНе доставлен. Заказ не удалось вручить; он едет обратно на точку забора.
order.price_changedЦена изменилась. Изменилась итоговая цена (ожидание, возврат, смена адреса).
order.returnedВозвращён. Заказ возвращён на точку забора.
order.status_changedСтатус изменился. Любой переход статуса — с прежним и новым статусом и кодом причины. Отправляется на каждый переход в порядке версии.
pingПинг. Тестовое событие от кнопки «Пинг» в кабинете. На него не подписываются; доставляется всегда.

Пустая подписка получает все события. Позиция курьера вебхуками не отправляется — используйте /track или страницу отслеживания.

Адреса

Добавляйте адреса в кабинете (раздел «Интеграция») или через 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"]}'

В ответе — секрет подписи whsec_…; он показывается один раз, перевыпуск — в кабинете. Адрес — только https на публичном хосте; до 10 адресов на среду. Тестовые и боевые адреса раздельные и получают события только своей среды.

Запрос

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"
}
ПолеЧто это
event_idстабильный id события — одинаковый при каждом повторе и ручной повторной отправке
typeтип события
api_versionv1
created_atкогда произошло событие
livemodefalse для событий песочницы
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>.<сырое тело>").

  1. Возьмите сырое тело запроса — до любого разбора JSON и пересериализации.
  2. Посчитайте HMAC-SHA256 от t + "." + тело с секретом адреса (вся строка whsec_…, UTF-8).
  3. Сравните с v1 за постоянное время.
  4. Отклоняйте метки времени, отличающиеся от ваших часов больше чем на 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 секунд
});

Фреймворки, которые разбирают JSON до вашего обработчика (Express json(), Spring @RequestBody Map), меняют байты — проверяйте подпись по сырому телу.

Гарантии доставки

  • Хотя бы один раз. Событие может прийти несколько раз — убирайте дубли по event_id (храните обработанные id несколько дней).
  • По порядку внутри заказа. События одного заказа на один адрес уходят строго в порядке order_version: следующее ждёт, пока повторяется предыдущее. И всё же сравнивайте order_version с тем, что у вас есть, и отбрасывайте старые события.
  • Outbox. События записываются в той же транзакции, что изменение заказа, поэтому перезапуск наших серверов сразу после смены статуса не теряет вебхук.
  • Задержка. Обычно секунды после события (p95 ≤ 5 с).

Ответ и повторы

Ответьте любым 2xx в течение 10 секунд — тело ответа не читается. Работу делайте асинхронно после ответа.

Любой другой статус, таймаут или ошибка соединения — повтор с тем же event_id:

ПопыткаПауза после предыдущей
21 минута
35 минут
415 минут
51 час
63 часа
7…6 часов, пока не пройдёт 24 часа от события

Через 24 часа доставка помечается не доставленной: администратор вашей организации получает письмо, а наш мониторинг интеграций видит её.

Адрес, который отвечает ошибками дольше 24 часов, приостанавливается. Его события копятся 7 дней; когда вы включаете адрес снова (PUT /v1/webhooks/{id} с {"status": "active"} или в кабинете), накопленные события отправляются по порядку. Более старые истекают.

Журнал и повторная отправка

Кабинет (раздел «Интеграция») хранит каждую доставку 30 дней: время, тип, адрес, попытки, код ответа и первые 2 КБ вашего ответа. «Отправить снова» повторно отправляет событие с тем же event_id; «Пинг» отправляет событие ping, чтобы проверить адрес.

Примеры событий

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"
}

Документация собирается из спецификации OpenAPI платформы. Скачать OpenAPI