Вебхуки
Ваша система узнаёт о начислении, не опрашивая наш API.
Открыть в кабинетеВебхук — это запрос, который мы отправляем на ваш адрес, когда что-то произошло. Он избавляет от опроса API: вместо «спросить раз в минуту, не появилось ли нового» ваша система узнаёт о начислении в течение минуты после чека.
Подключение за пять минут
Вебхуки — возможность тарифа «Про» и выше; настраивают их владелец и администратор. На младших тарифах вместо формы показано, с какого тарифа она откроется.
На паузе за неоплату события идут как обычно: касса работает, и ваша система не должна разойтись с балансами гостей. После понижения тарифа адреса сохраняются, но события не записываются вовсе — ни сразу, ни задним числом после оплаты. Вернулась оплата «Про» — вернулись и события, настраивать заново ничего не нужно. Пропущенное за перерыв догоняется через API.
- В кабинете откройте «Интеграции» → «Вебхуки», впишите в поле «Адрес» адрес вашей системы, отметьте нужные события и нажмите «Добавить вебхук». Адрес — только
https://и только внешний:localhostи адреса внутренних сетей не принимаются, а имя, которое указывает на внутренний адрес, отвергается при каждой отправке. Переадресацию мы не выполняем: ответ3xxсчитается неудачной доставкой, поэтому указывайте адрес, который принимает запрос сам. - Сохраните секрет сразу — он показывается один раз, под формой, и повторно не открывается. В списке адресов остаются только его первые восемь знаков — «подпись 3f9a1c07…», — по ним видно, какой секрет где.
- Проведите чек на кассе и нажмите «Доставки» у адреса: там последние 50 событий с состоянием каждого — «в очереди», «доставлено, ответ 200» или ошибка, число попыток и время следующего повтора. «Доставлено, ответ 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 — откуда карта: то, что прислали в source при POST /customers (по умолчанию pos), admin — заведена в кабинете, import — перенос базы, join — гость оформил карту сам на странице выдачи; у касс Frontol и МойСклад — их имена |
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 секунд. Иначе доставка повторяется: через 1, 2, 4, 8, 16, 32, 64, 128 и 256 минут, затем трижды через 6 часов — всего тринадцать попыток. Сервер, полежавший пять минут, получит событие сам, а упавший вечером — к утру.
- Отвечайте быстро. Приняли — верните 200 и обрабатывайте асинхронно. Десять секунд считаются до конца ответа, а не до начала обработки.
- Будьте готовы к повтору. Одно и то же событие может прийти дважды — например, если ваш ответ потерялся по дороге. Опирайтесь на идентификаторы из тела, как в примере приёмника выше.
- Смотрите историю. В «Доставках» видны код ответа или текст ошибки последней попытки, число попыток и время следующей — этого обычно хватает, чтобы понять, чья сторона виновата.
Чего вебхуки не заменяют
Они уведомляют, но не гарантируют порядок: у каждого события свои попытки доставки, и после сбоя tier.changed может прийти раньше transaction.committed, который его вызвал. Если ваша логика зависит от последовательности, опирайтесь на occurredAt и при необходимости дочитывайте состояние через API.
Частые вопросы
Что делать, если секрет утёк?
Удалите адрес на вкладке «Вебхуки» и добавьте тот же адрес заново — он получит новый секрет, а старый перестанет подписывать доставки. Отдельной кнопки «перевыпустить» нет. Пока новый секрет не вписан в приёмник, тот будет отвергать наши запросы — меняйте секрет на своей стороне сразу.
Можно ли отправлять события на несколько адресов?
Да, адресов может быть несколько, и у каждого свой набор событий и свой секрет — например, отдельный для CRM и отдельный для аналитики. Событие уходит на каждый подписанный на него адрес отдельной доставкой.
Как быстро приходит событие?
В пределах минуты: событие записывается в очередь сразу после проведения чека, а фоновый процесс разбирает очередь раз в минуту. Недоступный приёмник кассу не задерживает.
Почему card.installed не приходит для Google Кошелька?
Apple Wallet сам сообщает нам об установке карты — Google Кошелёк такого обратного вызова не даёт. Как только появится надёжный способ узнавать об установке, событие начнёт приходить и для него, с platform: GOOGLE.
Первые 20 карт — бесплатно. Программа настраивается за вечер.
Создать программу
Снова