Вебхуки

Ваша система узнаёт о начислении, не опрашивая наш API.

Открыть в кабинете

Вебхук — это запрос, который мы отправляем на ваш адрес, когда что-то произошло. Он избавляет от опроса API: вместо «спросить раз в минуту, не появилось ли нового» ваша система узнаёт о начислении в течение минуты после чека.

Подключение за пять минут

Вебхуки — возможность тарифа «Про» и выше; настраивают их владелец и администратор. На младших тарифах вместо формы показано, с какого тарифа она откроется.

На паузе за неоплату события идут как обычно: касса работает, и ваша система не должна разойтись с балансами гостей. После понижения тарифа адреса сохраняются, но события не записываются вовсе — ни сразу, ни задним числом после оплаты. Вернулась оплата «Про» — вернулись и события, настраивать заново ничего не нужно. Пропущенное за перерыв догоняется через API.

  1. В кабинете откройте «Интеграции» → «Вебхуки», впишите в поле «Адрес» адрес вашей системы, отметьте нужные события и нажмите «Добавить вебхук». Адрес — только https:// и только внешний: localhost и адреса внутренних сетей не принимаются, а имя, которое указывает на внутренний адрес, отвергается при каждой отправке. Переадресацию мы не выполняем: ответ 3xx считается неудачной доставкой, поэтому указывайте адрес, который принимает запрос сам.
  2. Сохраните секрет сразу — он показывается один раз, под формой, и повторно не открывается. В списке адресов остаются только его первые восемь знаков — «подпись 3f9a1c07…», — по ним видно, какой секрет где.
  3. Проведите чек на кассе и нажмите «Доставки» у адреса: там последние 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.refundedtransactionId — какой чек вернули, refundId, cardId, balance — итог после возврата (может уйти в минус, если начисленное уже потрачено), amount — сколько вернули, complete — возвращён ли чек целиком, locationId — та же точка, что у возвращаемой покупки
card.issuedcustomerId, cardId, source — откуда карта: то, что прислали в source при POST /customers (по умолчанию pos), admin — заведена в кабинете, import — перенос базы, join — гость оформил карту сам на странице выдачи; у касс Frontol и МойСклад — их имена
card.installedcardId, platform — сегодня всегда APPLE: Google Кошелёк о сохранении карты не сообщает
tier.changedcardId, tier — имя нового уровня. Приходит вслед за чеком, который перевёл гостя на уровень
points.expiredcardId, points — сколько сгорело. Сгорание идёт ночной задачей
points.adjustedcardId, points — правка со знаком (+50 или −50), balance — итог после неё

Подпись

Каждая доставка несёт два заголовка:

ЗаголовокЗначение
x-wallet-timestampВремя отправки, секунды Unix
x-wallet-signatureHMAC-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 карт — бесплатно. Программа настраивается за вечер.

Создать программу