Вебхуки

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

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

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

  1. В кабинете откройте «Интеграции» → «Вебхуки», укажите адрес вашей системы и отметьте нужные события. Адрес — только https:// и только внешний: локальные и внутренние сети не принимаются.
  2. Сохраните секрет сразу — он показывается один раз, повторно не открывается. Утёк или потерялся — перевыпустите в кабинете, адрес менять не придётся.
  3. Проведите чек на кассе — и посмотрите «Доставки» у адреса: там код ответа и текст ошибки по каждой попытке. «Доставлено, ответ 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 — откуда гость пришёл: pos, страница выдачи, импорт
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 секунд. Иначе доставка повторяется: задержка растёт вдвое, начиная с минуты, до восьми попыток. Сервер, полежавший пять минут, получит событие сам; лежащий сутки не будет получать запрос каждую минуту.

  • Отвечайте быстро. Приняли — верните 200 и обрабатывайте асинхронно. Десять секунд считаются до конца ответа, а не до начала обработки.
  • Будьте готовы к повтору. Одно и то же событие может прийти дважды — например, если ваш ответ потерялся по дороге. Опирайтесь на идентификаторы из тела, как в примере приёмника выше.
  • Смотрите историю. В кабинете видны код ответа и текст ошибки по каждой попытке — этого обычно хватает, чтобы понять, чья сторона виновата.

Чего вебхуки не заменяют

Они уведомляют, но не гарантируют порядок: события идут независимо, и tier.changed может прийти раньше transaction.committed, который его вызвал. Если ваша логика зависит от последовательности, опирайтесь на occurredAt и при необходимости дочитывайте состояние через API.

Частые вопросы

Что делать, если секрет утёк?

Перевыпустите его в кабинете: старая подпись сразу перестанет считаться верной. Адрес при этом менять не нужно.

Можно ли отправлять события на несколько адресов?

Да, адресов может быть несколько, и у каждого свой набор событий и свой секрет — например, отдельный для CRM и отдельный для аналитики.

Как быстро приходит событие?

Обычно в первые секунды после чека: событие пишется в очередь в той же операции, что и баллы, а отправляет его фоновый процесс — недоступный приёмник не задерживает кассу.

Почему card.installed не приходит для Google Кошелька?

Apple Wallet сам сообщает нам об установке карты — Google Кошелёк такого обратного вызова не даёт. Как только появится надёжный способ узнавать об установке, событие начнёт приходить и для него, с platform: GOOGLE.

Приходят ли вебхуки на паузе за неоплату?

Нет. Пауза останавливает интеграции, но не кассу: чеки проводятся, а внешние уведомления возобновятся после оплаты.

Первые 50 карт — бесплатно. Программа настраивается за вечер.

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