Постбэки ТурКарты
Server-to-server уведомления о ключевых событиях ваших приведённых пользователей: открыл приложение, зарегистрировался, выпустил карту, пополнил, получил возврат.
Версия контракта: v1. Базовый формат и подпись стабильны;
новые поля в data могут добавляться (см. Версии).
Обзор #
Постбэк — это HTTP POST с JSON-телом, который ТурКарта отправляет
на ваш заранее согласованный URL каждый раз, когда привязанный к вам пользователь
достигает значимого этапа. Каждое сообщение подписано HMAC-подписью на вашем
секрете, поэтому вы можете доверять его источнику и содержимому.
Пользователь привязывается к вам по первому касанию (deep-link
t.me/turkarta_bot?startapp=<ваш-код> или одноразовый инвайт).
Привязка неизменна. Постбэки приходят только по таким пользователям.
| Событие | Когда | Ключ сверки |
|---|---|---|
| app.opened | пользователь впервые открыл приложение по вашей ссылке | Telegram id |
| registration | пользователь завершил регистрацию (подтвердил email) | |
| card.issued | карта выпущена и активирована | |
| topup.funded | пополнение карты зачислено | |
| card.refunded | возврат за выпуск или блокировка карты |
Как это работает #
- At-least-once. Мы гарантируем доставку каждого реально произошедшего события. Если ваш сервер недоступен, мы повторяем доставку (см. Повторы). Дубликаты возможны — делайте обработку идемпотентной.
- Только реальные события. Постбэк ставится в очередь в той же транзакции, что и само действие. Если действие откатилось (например, выпуск карты не удался) — постбэк не отправляется. Вы никогда не получите «card.issued» по несуществующей карте.
- Порядок не гарантирован. Обычно события приходят по порядку, но не
полагайтесь на это — ориентируйтесь на
occurred_atи тип события. - Только подписанные. Каждый запрос несёт
X-Turkarta-Signature. Запросы без валидной подписи нужно отклонять.
Подключение #
Адрес эндпоинта (URL) задаёт менеджер ТурКарты — это защита: постбэки несут email пользователей, поэтому сменить куда они уходят можно только на нашей стороне. Чтобы подключиться, передайте менеджеру:
| URL | HTTPS-эндпоинт, принимающий POST (например https://partner.example/turkarta/postback) |
| События | какие из пяти событий вам нужны (по умолчанию — все) |
Подписным секретом вы управляете сами в партнёрском кабинете: кнопка «Показать секрет» открывает текущий, «Перевыпустить» генерирует новый. Храните его как пароль; им проверяется подпись каждого постбэка. После перевыпуска сразу обновите секрет на своей стороне — подпись считается уже новым секретом.
Там же — кнопка «Тестовое событие» (отправляет пробный
app.opened на ваш URL) и статус всех доставок.
Формат запроса #
Метод POST, Content-Type: application/json. Заголовки:
| X-Turkarta-Event | тип события, например topup.funded |
| X-Turkarta-Delivery | уникальный id этой доставки (используйте для дедупликации повторов) |
| X-Turkarta-Signature | sha256=<hex> — HMAC-SHA256 тела на вашем секрете |
Тело — единый конверт. Метаданные на верхнем уровне, полезная нагрузка события — в data:
{
"event": "topup.funded",
"partner_code": "biblioglobus",
"occurred_at": "2026-06-16T11:18:27.561029+00:00",
"data": {
"user_ref": "1f3c…e9",
"email": "tourist@example.com",
"topup_ref": "9ab2…7c",
"card_ref": "4d10…aa",
"amount_rub": "15000.00",
"amount_usd": "150.00",
"funded_at": "2026-06-16T11:18:27+00:00"
}
}
| event | тип события (дублирует X-Turkarta-Event) |
| partner_code | ваш код партнёра |
| occurred_at | момент события, ISO-8601 UTC |
| data | поля, зависящие от типа события (см. ниже) |
user_ref — это наш внутренний стабильный
идентификатор пользователя (не Telegram id, не PII). Один и тот же
user_ref приходит во всех событиях этого пользователя — используйте
его, чтобы связать события одной воронки. Денежные суммы приходят строками,
чтобы исключить ошибки округления float.Проверка подписи #
Подпись считается над точными байтами тела, которые вы получили — не пересериализуйте JSON перед проверкой. Сравнивайте в постоянном времени.
signature = "sha256=" + HMAC_SHA256(key = secret, message = raw_request_body) // hex
Python
import hashlib, hmac
def verify(secret: str, raw_body: bytes, header: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, header or "")
# FastAPI / Flask: возьмите СЫРОЕ тело (await request.body() / request.get_data()),
# а не разобранный JSON, иначе подпись не сойдётся.
Node.js
const crypto = require("crypto");
function verify(secret, rawBody, header) {
const expected =
"sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(header || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: используйте express.raw({ type: "application/json" }), чтобы получить
// req.body как Buffer (сырые байты), затем JSON.parse уже после проверки подписи.
X-Turkarta-Delivery. Дедупликацию стройте на
X-Turkarta-Delivery и/или на бизнес-id из data
(topup_ref, card_ref).События #
Ниже — поля data для каждого типа. Верхний уровень конверта
(event, partner_code, occurred_at) одинаков везде.
app.opened #
Пользователь впервые открыл приложение по вашей ссылке (момент привязки).
Email на этом шаге ещё неизвестен — он появится в registration.
| user_ref | стабильный id пользователя |
| tg_id | Telegram id (число), если пришёл из Telegram |
| tg_username | Telegram username без @, может отсутствовать |
| source | код/инвайт из ссылки привязки |
{ "event":"app.opened","partner_code":"biblioglobus",
"occurred_at":"2026-06-16T09:00:00+00:00",
"data":{ "user_ref":"1f3c…e9","tg_id":387115382,"tg_username":"tourist","source":"biblioglobus" } }
registration #
Пользователь завершил регистрацию и подтвердил email. Это якорь для сверки — именно по email мы сверяемся с партнёрами.
| user_ref | стабильный id пользователя |
| подтверждённый email пользователя | |
| registered_at | момент подтверждения, ISO-8601 UTC |
{ "event":"registration","partner_code":"biblioglobus",
"occurred_at":"2026-06-16T09:05:00+00:00",
"data":{ "user_ref":"1f3c…e9","email":"tourist@example.com","registered_at":"2026-06-16T09:05:00+00:00" } }
card.issued #
Карта выпущена и активирована (выпуск оплачен и профинансирован).
| user_ref | стабильный id пользователя |
| email пользователя | |
| card_ref | стабильный id карты в ТурКарте |
| masked_number | маскированный номер, например •••• 1234 |
| product | код продукта карты |
| issued_at | момент выпуска, ISO-8601 UTC |
{ "event":"card.issued","partner_code":"biblioglobus",
"occurred_at":"2026-06-16T10:00:00+00:00",
"data":{ "user_ref":"1f3c…e9","email":"tourist@example.com","card_ref":"4d10…aa",
"masked_number":"•••• 1234","product":"visa_universe","issued_at":"2026-06-16T10:00:00+00:00" } }
topup.funded #
Пополнение карты зачислено. Суммы — строки; amount_usd — сумма, легшая на карту.
| user_ref | стабильный id пользователя |
| email пользователя | |
| topup_ref | стабильный id пополнения (для дедупликации) |
| card_ref | id карты, которую пополнили |
| amount_rub | оплачено пользователем, ₽ (строка) |
| amount_usd | зачислено на карту, $ (строка) |
| funded_at | момент зачисления, ISO-8601 UTC |
card.refunded #
Возврат либо блокировка карты. Поле reason различает сценарии,
amount_* = 0, если деньги не возвращались.
| user_ref | стабильный id пользователя |
| email пользователя | |
| card_ref | id карты |
| reason | issuance_refund — возврат стоимости выпуска (карта не использовалась) · fraud_suspend — блокировка за мошенничество/злоупотребление |
| amount_rub | возвращено, ₽ (строка; 0.00 если ничего) |
| amount_usd | возвращено, $ (строка; 0.00 если ничего) |
| refunded_at | момент, ISO-8601 UTC |
{ "event":"card.refunded","partner_code":"biblioglobus",
"occurred_at":"2026-06-16T12:00:00+00:00",
"data":{ "user_ref":"1f3c…e9","email":"tourist@example.com","card_ref":"4d10…aa",
"reason":"issuance_refund","amount_rub":"14990.00","amount_usd":"0.00",
"refunded_at":"2026-06-16T12:00:00+00:00" } }
Повторы и идемпотентность #
Доставка считается успешной при ответе 2xx. Любой другой код,
таймаут или сетевая ошибка → повтор с экспоненциальной задержкой.
| Попыток | до 8 |
| Задержка | экспоненциальная: ~30s, 1m, 2m, 4m … (потолок 1 час) |
| Таймаут | 10 секунд на ответ |
| После 8 неудач | доставка помечается exhausted; менеджер может переотправить вручную |
Так как доставка at-least-once, один и тот же постбэк может прийти дважды.
Делайте обработчик идемпотентным: запоминайте X-Turkarta-Delivery
(или бизнес-id из data) и игнорируйте повтор.
Как отвечать #
- Отвечайте
2xxбыстро (в пределах 10 с). Тяжёлую работу выносите в фон — сначала примите и подтвердите. - Любой не-
2xxвоспринимается как неудача и вызовет повтор. - Тело ответа мы не читаем — важен только статус-код.
X-Turkarta-Delivery,
(4) поставить в свою очередь / записать, (5) вернуть 200.Сверка по email #
Финансовая сверка между вами и ТурКартой идёт по email. Он присутствует
в registration, card.issued, topup.funded
и card.refunded. Событие app.opened приходит раньше,
чем пользователь дал email, поэтому несёт только Telegram-идентификаторы и
user_ref — связывайте его с остальными по user_ref.
Тестирование #
По вашей просьбе менеджер отправит тестовое событие — постбэк
app.opened с data.test = true. Оно подписано тем же
секретом и проходит весь обычный путь (включая повторы), но не относится к
реальному пользователю — удобно проверить приём и валидацию подписи.
{ "event":"app.opened","partner_code":"biblioglobus",
"occurred_at":"…","data":{ "test":true,"note":"Turkarta postback endpoint test" } }
Безопасность #
- Принимайте постбэки только по HTTPS.
- Всегда проверяйте
X-Turkarta-Signatureдо обработки; сравнивайте в постоянном времени. - Храните секрет как пароль; не логируйте его и не кладите в репозиторий.
- Считайте подпись над сырыми байтами тела, а не над пересериализованным JSON.
- При перевыпуске секрета обновите его на своей стороне без задержки — постбэки подписываются текущим секретом.
Версии #
| Версия | Изменения |
|---|---|
v1 | Первый контракт: конверт, HMAC-подпись, 5 событий, повторы. |
Мы можем добавлять новые поля в data и новые
типы событий без смены версии — пишите обработчик так, чтобы незнакомые поля и
типы игнорировались, а не ломали разбор.