Вебхуки
Ваша система узнаёт о начислении, не опрашивая наш API.
Вебхук — это запрос, который мы отправляем на ваш адрес, когда что-то произошло. Он избавляет от опроса API: вместо «спросить раз в минуту, не появилось ли нового» ваша система узнаёт о начислении через секунду после чека.
Подключение за пять минут
- В кабинете откройте «Интеграции» → «Вебхуки», укажите адрес вашей системы и отметьте нужные события. Адрес — только
https://и только внешний: локальные и внутренние сети не принимаются. - Сохраните секрет сразу — он показывается один раз, повторно не открывается. Утёк или потерялся — перевыпустите в кабинете, адрес менять не придётся.
- Проведите чек на кассе — и посмотрите «Доставки» у адреса: там код ответа и текст ошибки по каждой попытке. «Доставлено, ответ 200» значит, что ваша сторона запрос приняла.
События
| Событие | Когда приходит |
|---|---|
transaction.committed | Чек проведён: начислены и списаны баллы |
transaction.refunded | Возврат покупки — начисление откачено |
card.issued | Гость получил карту |
card.installed | Карта установлена в телефон (сегодня — Apple Wallet) |
tier.changed | Гость перешёл на другой уровень |
points.expired | Баллы сгорели по сроку |
points.adjusted | Баллы поправлены вручную из кабинета |
Что приходит
Каждая доставка — POST с JSON-телом и User-Agent: Wallet-Loyalty-Webhook/1. Конверт один на все события:
| Поле | Что в нём |
|---|---|
event | Имя события из таблицы выше |
tenantId | Идентификатор вашей компании — один на все события |
occurredAt | Когда случилось, ISO 8601 с миллисекундами (UTC) |
data | Тело события — состав зависит от события, см. ниже |
transaction.committed — чек на 100 ₽, начислено 5 баллов
{
"event": "transaction.committed",
"tenantId": "4f21ac80-56d1-4a03-b7c9-2e8d10f37a55",
"occurredAt": "2026-08-28T13:06:23.228Z",
"data": {
"transactionId": "8c4be1d2-97a0-4d36-8f52-c01a76d94eb3",
"cardId": "d05f39ea-62c8-47b1-9a44-58e7b20c163f",
"barcode": "2001234567893",
"earned": 5,
"redeemed": 0,
"balance": 190,
"locationId": "b1d9f0c2-5a77-4e1e-9d3a-70c4f2b18e64"
}
}earned и redeemed — баллы этого чека, balance — итог по карте после него. Сумма чека в событии не передаётся: она ваша и у вас уже есть, а transactionId связывает событие с ответом API, которым чек проводился.
locationId — точка продаж, которую вы назвали при проведении чека, или null, если не называли. Правило начисления можно привязать к точке, и тогда один и тот же чек даёт на разных точках разные баллы: без этого поля разницу не с чем сопоставить.
Тела остальных событий — поля внутри data:
| Событие | Поля data |
|---|---|
transaction.refunded | transactionId — какой чек вернули, refundId, cardId, balance — итог после возврата (может уйти в минус, если начисленное уже потрачено), amount — сколько вернули, complete — возвращён ли чек целиком, locationId — та же точка, что у возвращаемой покупки |
card.issued | customerId, cardId, source — откуда гость пришёл: pos, страница выдачи, импорт |
card.installed | cardId, platform — сегодня всегда APPLE: Google Кошелёк о сохранении карты не сообщает |
tier.changed | cardId, tier — имя нового уровня |
points.expired | cardId, points — сколько сгорело |
points.adjusted | cardId, points — правка со знаком (+50 или −50), balance — итог после неё |
Подпись
Каждая доставка несёт два заголовка:
| Заголовок | Значение |
|---|---|
x-wallet-timestamp | Время отправки, секунды Unix |
x-wallet-signature | HMAC-SHA256 в hex от строки «timestamp.тело» на вашем секрете |
Проверка на Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret, timestamp, rawBody, signature) {
// Старую подпись не принимаем: перехваченный запрос иначе можно
// повторить через сутки
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest();
const received = Buffer.from(signature, 'hex');
if (expected.length !== received.length) return false;
// Сравнение постоянного времени: обычное «===» позволяет подобрать
// подпись побайтно по времени ответа
return timingSafeEqual(expected, received);
}То же на Python
import hashlib, hmac, time
def verify(secret: str, timestamp: str, raw_body: bytes, signature: str) -> bool:
if abs(time.time() - float(timestamp)) > 300:
return False
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)Приёмник целиком
Минимальный рабочий эндпоинт: сырое тело, проверка подписи, мгновенный ответ и обработка после него. На Express так:
Express: приёмник вебхуков
import express from 'express';
const app = express();
app.post(
'/hooks/wallet',
// Именно raw: express.json() разобрал бы тело до нас, и подпись
// пришлось бы считать по пересобранной строке — см. заметку выше
express.raw({ type: 'application/json' }),
(req, res) => {
const ok = verify(
process.env.WALLET_WEBHOOK_SECRET,
req.header('x-wallet-timestamp'),
req.body.toString('utf8'),
req.header('x-wallet-signature'),
);
if (!ok) return res.status(401).end();
// Отвечаем сразу: на ответ отведено 10 секунд, и они считаются
// до конца ответа, а не до конца вашей обработки
res.status(200).json({ ok: true });
const { event, data } = JSON.parse(req.body.toString('utf8'));
setImmediate(() => handle(event, data));
},
);
const seen = new Set();
function handle(event, data) {
// Событие может прийти дважды — например, если наш ответ потерялся
// по дороге. Ключ идемпотентности уже в теле
const key = data.transactionId ?? `${event}:${data.cardId}`;
if (seen.has(key)) return;
seen.add(key);
// ...дальше ваша логика: CRM, товароучёт, аналитика
}Зачем это на практике
- Синхронизация с CRM и товароучётом.
transaction.committedнесёт свежий баланс — карточка покупателя в вашей системе обновляется в момент чека, без ночных выгрузок и опроса API. Возвраты, сгорание и ручные правки (transaction.refunded,points.expired,points.adjusted) держат тот же баланс честным — без последнего правка из кабинета доехала бы до вашей CRM только со следующим чеком гостя. - Живая аналитика. Поток
transaction.committed— это лента покупок участников программы: выручка по часам, средний чек со скидкой и без, доля списаний — всё считается у вас, из событий. - Сценарии по установке карты.
card.installed— момент, когда гость донёс карту до телефона: хорошая точка для приветственной цепочки в вашей CRM. До установки push до гостя не доходит — этим событием видно, кого догонять другими каналами. - Реакция на уровень.
tier.changed— повод выдать привилегию на вашей стороне: доступ в закрытый зал, приоритет в очереди доставки, что угодно, о чём наша платформа не знает.
Повторы
Успехом считается любой ответ 2xx, отданный за 10 секунд. Иначе доставка повторяется: задержка растёт вдвое, начиная с минуты, до восьми попыток. Сервер, полежавший пять минут, получит событие сам; лежащий сутки не будет получать запрос каждую минуту.
- Отвечайте быстро. Приняли — верните 200 и обрабатывайте асинхронно. Десять секунд считаются до конца ответа, а не до начала обработки.
- Будьте готовы к повтору. Одно и то же событие может прийти дважды — например, если ваш ответ потерялся по дороге. Опирайтесь на идентификаторы из тела, как в примере приёмника выше.
- Смотрите историю. В кабинете видны код ответа и текст ошибки по каждой попытке — этого обычно хватает, чтобы понять, чья сторона виновата.
Чего вебхуки не заменяют
Они уведомляют, но не гарантируют порядок: события идут независимо, и tier.changed может прийти раньше transaction.committed, который его вызвал. Если ваша логика зависит от последовательности, опирайтесь на occurredAt и при необходимости дочитывайте состояние через API.
Частые вопросы
Что делать, если секрет утёк?
Перевыпустите его в кабинете: старая подпись сразу перестанет считаться верной. Адрес при этом менять не нужно.
Можно ли отправлять события на несколько адресов?
Да, адресов может быть несколько, и у каждого свой набор событий и свой секрет — например, отдельный для CRM и отдельный для аналитики.
Как быстро приходит событие?
Обычно в первые секунды после чека: событие пишется в очередь в той же операции, что и баллы, а отправляет его фоновый процесс — недоступный приёмник не задерживает кассу.
Почему card.installed не приходит для Google Кошелька?
Apple Wallet сам сообщает нам об установке карты — Google Кошелёк такого обратного вызова не даёт. Как только появится надёжный способ узнавать об установке, событие начнёт приходить и для него, с platform: GOOGLE.
Приходят ли вебхуки на паузе за неоплату?
Нет. Пауза останавливает интеграции, но не кассу: чеки проводятся, а внешние уведомления возобновятся после оплаты.
Первые 50 карт — бесплатно. Программа настраивается за вечер.
Создать программу
Снова