Вебхуки: приём событий
Всё, что происходит с номерами и сообщениями, шлюз отправляет на ваш адрес POST-запросом, подписанным секретом. Те же события можно забирать списком.
Как приходит событие
POST /api/xaxumessage/events HTTP/1.1
Content-Type: application/json
User-Agent: xaxumessage/0.1
X-Xaxu-Event-Id: 1042
X-Xaxu-Event-Type: message.created
X-Xaxu-Timestamp: 1791625691
X-Xaxu-Signature: sha256=5d0c3e…
{"id":1042,"type":"message.created","createdAt":"2026-10-10T09:28:11.200Z","tenantId":"c3d4e5f6-1a2b-4c3d-8e9f-0a1b2c3d4e5f","accountId":"5b1e9c3a-8f2d-4c71-9a0e-2d6f1b7c4e10","data":{…}}| Поле | Что это |
|---|---|
id | номер события, растёт монотонно — по нему убирайте дубли |
type | тип события (все типы) |
createdAt | когда событие произошло |
tenantId, accountId | ваш id клиента и номер, к которому относится событие |
data | содержимое — своё у каждого типа |
Что должен делать ваш приёмник
- Проверить подпись (ниже) и отклонить запрос с неверной подписью или старше 5 минут.
- Ответить 2xx быстро — за 15 секунд. Долгую обработку делайте после ответа (очередь, фоновая задача).
- **Убирать дубли по
id**: доставка «хотя бы один раз», одно событие может прийти повторно. - Не полагаться на порядок: события могут прийти не по порядку. Статус сообщения не откатывается назад — сравнивайте по порядку статусов.
Подпись
X-Xaxu-Signature = sha256= + hex(HMAC-SHA256(secret, <X-Xaxu-Timestamp>.<сырое тело запроса>)). secret — секрет, выданный методом PUT /v1/webhook. Считайте подпись по точным байтам тела, а не по пересобранному JSON.
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody — ровно те байты, что пришли (не JSON.stringify от разобранного объекта!)
export function verify(rawBody: string, headers: Headers, secret: string): boolean {
const ts = headers.get('x-xaxu-timestamp');
const sig = headers.get('x-xaxu-signature');
if (!ts || !sig) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // старше 5 минут — отклонить
const expected = 'sha256=' + createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
const a = Buffer.from(expected), b = Buffer.from(sig);
return a.length === b.length && timingSafeEqual(a, b);
}
// Next.js (App Router): app/api/xaxumessage/events/route.ts
export async function POST(req: Request) {
const raw = await req.text();
if (!verify(raw, req.headers, process.env.XM_WEBHOOK_SECRET!)) return new Response('bad signature', { status: 401 });
const event = JSON.parse(raw);
// дедупликация по event.id, затем обработка; долгую работу — в очередь
return new Response('ok');
}<?php
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_XAXU_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_XAXU_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $raw, getenv('XM_WEBHOOK_SECRET'));
if (abs(time() - (int)$ts) > 300 || !hash_equals($expected, $sig)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
// дедупликация по $event['id'], затем обработка
http_response_code(200);import hmac, hashlib, time
def verify(raw_body: bytes, headers, secret: str) -> bool:
ts = headers.get('X-Xaxu-Timestamp', '')
sig = headers.get('X-Xaxu-Signature', '')
if not ts or abs(time.time() - int(ts)) > 300:
return False
mac = hmac.new(secret.encode(), ts.encode() + b'.' + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest('sha256=' + mac, sig)В TypeScript-клиенте это уже готово: verifyWebhookSignature() и readVerifiedEvent(request, secret).
Повторы
Если приёмник ответил не 2xx или не ответил за 15 секунд, шлюз повторяет доставку с растущей паузой (от 5 секунд до 1 часа), всего до 12 попыток — около 2,5 часов. Потом событие откладывается, оператор получает уведомление и может отправить отложенные заново. Ничего не теряется: всё доступно через GET /v1/events.
GET /v1/events?after=<последний обработанный id> — чтобы догнать то, что пропустили, пока приёмник лежал.Указать адрес для событий
/v1/webhookКуда шлюз будет отправлять события (POST). Если секрета подписи ещё нет и вы не передали свой, шлюз создаст его и вернёт один раз в поле secret — сохраните. При смене адреса секрет сохраняется.
url: null выключает отправку событий и удаляет секрет (при повторном включении будет выдан новый). События по-прежнему можно забирать методом GET /v1/events.
Тело запроса
urlurl | nullобязательноsecretstring ≥ 8curl -X PUT "https://msg.xaxu.am/v1/webhook" \
-H "Authorization: Bearer $XM_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.ru/api/xaxumessage/events"}'{
"url": "https://example.ru/api/xaxumessage/events",
"secret": "whsec_3q2Lw0…"
}Забрать события списком
/v1/eventsВсе события клиента по порядку id. Подходит, чтобы догнать пропущенное после простоя приёмника или вовсе обходиться без вебхука: запоминайте next и передавайте его в after.
Параметры запроса
afterintid больше этого (по умолчанию 0)limitint 1…500curl "https://msg.xaxu.am/v1/events?after=1041&limit=100" \
-H "Authorization: Bearer $XM_API_KEY"{
"events": [
{
"id": 1042,
"type": "message.created",
"accountId": "5b1e9c3a-8f2d-4c71-9a0e-2d6f1b7c4e10",
"createdAt": "2026-10-10T09:28:11.200Z",
"data": {
"channel": "whatsapp",
"chatExternalId": "79181234567@c.us",
"message": {
"id": "0b8e4d2a-6c1f-4a3e-9b7d-5e2f1c0a9d8b",
"accountId": "5b1e9c3a-8f2d-4c71-9a0e-2d6f1b7c4e10",
"chatId": "9e2f7a1c-3b4d-4e5f-8a6b-7c8d9e0f1a2b",
"externalId": "BAE5F4C1D2A3B4C5",
"clientId": null,
"direction": "in",
"origin": "contact",
"kind": "text",
"text": "Здравствуйте! Есть свободные номера на выходные?",
"data": {},
"quotedExternalId": null,
"sender": {
"id": "79181234567@c.us",
"name": "Анна",
"phone": "79181234567"
},
"status": "received",
"statusDetail": null,
"sentAt": "2026-10-10T09:28:11.000Z",
"editedAt": null,
"deletedAt": null,
"attachments": [],
"reactions": []
}
}
}
],
"next": 1042
}