Публичный REST API ROOM позволяет программно читать ваш каталог событий и продавать билеты по СБП с вашего сайта или сервера. Ключи создаются самостоятельно в бэкстейдже. Все ответы — JSON, суммы — в копейках (минорных единицах валюты).
Справочник API (OpenAPI 3.1)
Полная машиночитаемая спецификация всех эндпоинтов, схем и вебхуков — для генерации SDK, импорта в Postman/Swagger и автодополнения.
1. Создайте ключ в разделе «API и вебхуки» бэкстейджа. Для боевых (live) ключей нужна верификация. Для тестов используйте sk_test — заказы создаются с mock-оплатой.
2. Сделайте первый запрос — список ваших событий:
curl https://roombackstage.ru/api/public/v1/events \
-H "Authorization: Bearer sk_test_ВАШ_КЛЮЧ"Ключ передаётся в заголовке Authorization: Bearer <ключ>. Существует два типа ключей:
sk_ — секретный, для сервера. Полный доступ (включая список заказов и персональные данные покупателей). Никогда не публикуйте его в браузере.pk_ — публикуемый, для браузера. Работает только с разрешённых доменов (origin-binding) и не отдаёт персональные данные. Подходит для чекаута на лендинге. Бронирование мест с pk_ ограничено по скорости на один IP и по числу одновременных броней (анти-абьюз — ключ виден в коде сайта). Для массовых серверных сценариев используйте sk_ — он без IP-ограничений.Каждый ключ привязан к среде: test (mock-оплата, без реального СБП) или live (реальные платежи). Ключ видит и продаёт билеты только на события своего организатора.
Защита от утечек: если ваш ключ попадёт в публичный источник (например, в код на GitHub), он будет автоматически отозван, а вам придёт уведомление. Храните sk_ только на сервере и никогда не коммитьте ключи в репозиторий.
https://roombackstage.ru/api/public/v1Версия зафиксирована в пути (/v1). Ломающие изменения выйдут как /v2 — текущая версия продолжит работать.
Ошибки возвращаются со стабильным конвертом и подходящим HTTP-статусом:
{ "error": { "code": "insufficient_scope", "message": "...", "param": "scope" } }401 unauthorized — нет ключа, ключ невалиден/отозван/истёк.403 forbidden / insufficient_scope / origin_not_allowed — ключу не хватает прав или origin не разрешён.404 not_found — событие/заказ не принадлежит вашему ключу (намеренно не различаем «нет» и «чужое»).409 conflict / seat_conflict — место или тариф уже заняты.410 sales_closed — продажи по событию закрыты.422 — недопустимая операция (например, попытка бесплатного билета или не-СБП оплаты).429 rate_limited — превышен лимит. Смотрите заголовок Retry-After.502 payment_provider_error — провайдер оплаты временно недоступен, повторите.Создание заказа (POST /orders) требует заголовок Idempotency-Key (без него — 400). Повтор с тем же ключом вернёт тот же заказ — защита от двойного списания при ретраях сети:
curl https://roombackstage.ru/api/public/v1/orders \
-H "Authorization: Bearer sk_live_ВАШ_КЛЮЧ" \
-H "Idempotency-Key: order-2026-06-19-abc123" \
-H "Content-Type: application/json" \
-d '{ "tier_id": "...", "quantity": 2, "buyer": { "email": "fan@example.ru" } }'Жизненный цикл ключа
Ключ привязан к заказу на всю его жизнь (отдельного TTL нет) и изолирован по организатору. Поведение повтора зависит от состояния заказа:
paid или refunded → повтор возвращает тот же заказ в его текущем (терминальном) состоянии (HTTP 200; новый заказ создаётся с 201, тело одинаковое). Это и есть идемпотентный результат — безопасно ретраить.409 order_pending с заголовком Retry-After. Повторите через секунду — ответ сойдётся, когда QR будет готов.409 order_cancelled. Начните заново со свежим Idempotency-Key.Ключ одноразовый-на-заказ и не освобождается со временем (нет «окна» переиспользования). Правило простое: один логический заказ = один новый ключ. Не переиспользуйте старый ключ под новую покупку — генерируйте свежий на каждую попытку оформления (например, crypto.randomUUID()).
По умолчанию: чтение — ~600 запросов/мин на ключ, создание заказов/броней — ~60/мин. При превышении приходит 429 с заголовками Retry-After, X-RateLimit-Remaining, X-RateLimit-Reset.
Для аптайм-мониторинга есть лёгкий эндпоинт здоровья — без аутентификации, без побочных эффектов и без персональных данных. Он проверяет, что приложение и база данных отвечают:
curl -i https://roombackstage.ru/api/public/v1/health
# 200 OK → {"status":"ok","db":"up","version":"2026-06-01","response_time_ms":3,...}
# 503 → {"status":"degraded","db":"down",...} (база недоступна)Это операционная проба (не часть OpenAPI-контракта интеграции): она не несёт заголовков ROOM-Request-Id / X-RateLimit-* и не требует ключа. Опрашивайте её из своего мониторинга не чаще раза в ~30 секунд.
Целевые показатели (ориентиры)
/v1: цель 99.9% в месяц./v1: ломающие изменения — только новым мажором с окном 12 месяцев (см. «Версионирование»).Это ориентиры, к которым мы стремимся, а не договорной SLA. Плановые работы и инциденты анонсируются в Telegram-канале. Для надёжной обработки оплат всегда дублируйте вебхуки опросом GET /orders/{id} — это устойчиво к кратким перебоям доставки.
GET /events — список опубликованных событий (cursor-пагинация).GET /events/{id} — детали события, тарифы, лайнап.GET /events/{id}/tiers — публичные тарифы.GET /events/{id}/seating — схема зала и статус мест.GET /events/{id}/availability — лёгкое наличие (счётчики + ценовой диапазон).POST /orders/preview — предпросмотр цены и сбора без создания заказа.POST /orders — создать заказ TICKET по СБП (вернёт QR + poll_token).GET /orders/{id} — статус заказа (по poll_token или ключу).POST /orders/{id}/check-status — свежая проверка статуса.POST /seat-holds / DELETE /seat-holds — бронь мест для seating-событий.Пример создания заказа из JavaScript:
const res = await fetch("https://roombackstage.ru/api/public/v1/orders", {
method: "POST",
headers: {
"Authorization": "Bearer sk_live_ВАШ_КЛЮЧ",
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
tier_id: "TIER_ID",
quantity: 2,
buyer: { email: "fan@example.ru" },
return_url: "https://ваш-сайт.ru/spasibo", // опционально, только https
}),
})
const order = await res.json()
// order.payment.qr_image / qr_payload — покажите СБП QR покупателю
// order.poll_token — опрашивайте статус GET /orders/{id} с этим токеномВыбор билетов: tier_id (обычный тариф), либо seat_reservation_ids (места после POST /seat-holds), либо standing_items. Опрашивайте GET /orders/{id} с poll_token до status: "paid" — надёжный фолбэк к вебхукам.
Заказы через API оплачиваются только по СБП (рубли). Бесплатные билеты и Telegram Stars через API недоступны (422). Покупатель платит ровно цену из каталога — комиссию платформы платит организатор из своей выручки.
Ключи test используют mock-оплату: заказ создаётся, но реального СБП QR и списания нет — он сам гаснет через ~15 минут. Идеально для интеграционных тестов.
Создайте ключ sk_test в бэкстейдже (для test-ключей верификация не нужна). Среда зашита в ключ: test-ключ всегда работает с тестовыми данными, никаких реальных денег. В вебхуках тестовых заказов livemode:false — приёмник обязан игнорировать такие события на проде.
qr_payload у тестового заказа нефункционален (ничего не списывает). Чтобы довести тестовый заказ до paid и проверить polling + свой webhook-приёмник, используйте симуляцию оплаты (только sk_test):
# 1) создать заказ (sk_test) → status: "pending"
curl -X POST https://roombackstage.ru/api/public/v1/orders \
-H "Authorization: Bearer sk_test_ВАШ_КЛЮЧ" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"ticket_tier_id":"...","quantity":1}'
# 2) довести до оплаты (или отмены) — выстрелит order.paid / order.cancelled
curl -X POST https://roombackstage.ru/api/public/v1/orders/ORDER_ID/simulate-payment \
-H "Authorization: Bearer sk_test_ВАШ_КЛЮЧ" \
-H "Content-Type: application/json" \
-d '{"outcome":"paid"}' # outcome: "paid" (дефолт) | "cancelled"
# 3) опросить статус
curl https://roombackstage.ru/api/public/v1/orders/ORDER_ID \
-H "Authorization: Bearer sk_test_ВАШ_КЛЮЧ"Симуляция доступна только для заказов test-окружения (боевой заказ → 422). Прогоняется тот же confirmOrder/cancelOrder, что и боевой путь, поэтому ваш вебхук-приёмник тестируется end-to-end.
Кнопка «Тест» в бэкстейдже шлёт подписанный конверт webhook.test на ваш URL немедленно (вне очереди, не влияет на счётчик неудач) — удобно проверить, что приёмник принимает и верифицирует подпись до первой реальной продажи.
ROOM может отправлять POST на ваш сервер при изменении статуса заказа. Настройте эндпоинт в разделе «API и вебхуки». Каталог событий (только билеты):
order.paid — заказ оплачен.order.cancelled — заказ отменён/истёк.order.refunded — по заказу прошёл возврат.⚠️ Подтверждение владения эндпоинтом (обязательно)
Новый эндпоинт не получает события, пока вы не подтвердите владение. В разделе «API и вебхуки» нажмите «Подтвердить владение» — ROOM пришлёт на ваш URL подписанный конверт с type: endpoint.verify и полем data.nonce. Ваш сервер должен ответить 2xx и вернуть этот nonce в теле ответа (достаточно, чтобы строка nonce встречалась в теле — например, эхо строки или JSON {"nonce":"..."}). Это доказывает, что URL действительно ваш, и не даёт использовать ROOM для нежелательных запросов по чужим серверам. Смена URL эндпоинта сбрасывает подтверждение — пройдите проверку заново.
// Обработайте проверочный запрос так же, как обычную доставку (подпись валидна),
// но верните полученный nonce в 2xx-ответе:
if (event.type === "endpoint.verify") {
return res.status(200).json({ nonce: event.data.nonce })
}Тело — подписанный конверт:
{
"id": "clz8a3k9b0001", // cuid, стабилен между ретраями → дедуп
"type": "order.paid",
"api_version": "2026-06-01",
"created_at": "2026-06-19T10:00:00.000Z",
"data": { "order": { "id": "...", "status": "paid", "total_price": 150000, ... } }
}Порядок доставок не гарантируется. Ретраящийся order.paid может прийти после order.refunded того же заказа. Трактуйте события как состояние (читайте data.order.status / is_paid), а не как переходы; реконсайльте по status + created_at (более свежий created_at = более актуальное состояние) и дедуплицируйте по id конверта.
Каждый запрос подписан заголовком ROOM-Signature: t=<ts>,v1=<hex>, где hex = HMAC-SHA256 от строки `${t}.${rawBody}` с вашим секретом whsec_. Заголовок может нести несколько v1= (через запятую) — это происходит во время окна ротации секрета: принимайте запрос, если совпал любой. Также проверяйте свежесть t (защита от повторной отправки):
import crypto from "crypto"
function verify(rawBody, header, secret) {
// header: "t=<ts>,v1=<hex>[,v1=<hex>]" — во время ротации секрета v1 может быть несколько
const parts = header.split(",").map((p) => p.trim())
const t = Number(parts.find((p) => p.startsWith("t="))?.slice(2))
const sigs = parts.filter((p) => p.startsWith("v1=")).map((p) => p.slice(3))
// 1) Свежесть: отклоняем, если метка старше 5 минут (анти-replay)
if (!Number.isFinite(t) || Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false
// 2) Подпись: достаточно совпадения ЛЮБОГО v1 (поддержка dual-secret при ротации)
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")
const eb = Buffer.from(expected)
return sigs.some(
(s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), eb),
)
}import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = [p.strip() for p in header.split(",")]
t = next((p[2:] for p in parts if p.startswith("t=")), None)
sigs = [p[3:] for p in parts if p.startswith("v1=")]
if t is None or not sigs:
return False
# 1) freshness — reject if older than 5 minutes (anti-replay)
if abs(int(time.time()) - int(t)) > 300:
return False
# 2) signature — ANY v1 may match (dual-secret during rotation)
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(s, expected) for s in sigs)function verify(string $rawBody, string $header, string $secret): bool {
$parts = array_map('trim', explode(',', $header));
$t = null; $sigs = [];
foreach ($parts as $p) {
if (str_starts_with($p, 't=')) $t = substr($p, 2);
if (str_starts_with($p, 'v1=')) $sigs[] = substr($p, 3);
}
if ($t === null || !$sigs) return false;
// 1) freshness — reject if older than 5 minutes (anti-replay)
if (abs(time() - (int)$t) > 300) return false;
// 2) signature — ANY v1 may match (dual-secret during rotation)
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($sigs as $s) { if (hash_equals($expected, $s)) return true; }
return false;
}require "openssl"
def verify(raw_body, header, secret)
parts = header.split(",").map(&:strip)
t = parts.find { |p| p.start_with?("t=") }&.slice(2..)
sigs = parts.select { |p| p.start_with?("v1=") }.map { |p| p[3..] }
return false if t.nil? || sigs.empty?
# 1) freshness — reject if older than 5 minutes (anti-replay)
return false if (Time.now.to_i - t.to_i).abs > 300
# 2) signature — ANY v1 may match (dual-secret during rotation)
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}")
sigs.any? { |s| OpenSSL.secure_compare(s, expected) }
endfunc verify(rawBody []byte, header, secret string) bool {
var t string
var sigs []string
for _, p := range strings.Split(header, ",") {
p = strings.TrimSpace(p)
if strings.HasPrefix(p, "t=") {
t = p[2:]
} else if strings.HasPrefix(p, "v1=") {
sigs = append(sigs, p[3:])
}
}
ts, err := strconv.ParseInt(t, 10, 64)
if err != nil || len(sigs) == 0 {
return false
}
// 1) freshness — reject if older than 5 minutes (anti-replay)
if d := time.Now().Unix() - ts; d > 300 || d < -300 {
return false
}
// 2) signature — ANY v1 may match (dual-secret during rotation)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
for _, s := range sigs {
if hmac.Equal([]byte(s), []byte(expected)) {
return true
}
}
return false
}static bool Verify(byte[] rawBody, string header, string secret)
{
var parts = header.Split(',').Select(p => p.Trim()).ToArray();
var t = parts.FirstOrDefault(p => p.StartsWith("t="))?.Substring(2);
var sigs = parts.Where(p => p.StartsWith("v1=")).Select(p => p.Substring(3)).ToArray();
if (t == null || sigs.Length == 0 || !long.TryParse(t, out var ts)) return false;
// 1) freshness — reject if older than 5 minutes (anti-replay)
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300) return false;
// 2) signature — ANY v1 may match (dual-secret during rotation)
var signed = Encoding.UTF8.GetBytes(t + ".").Concat(rawBody).ToArray();
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Convert.ToHexString(hmac.ComputeHash(signed)).ToLowerInvariant();
return sigs.Any(s => CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(s), Encoding.UTF8.GetBytes(expected)));
}Во всех вариантах: подписываемая строка — `${t}.${rawBody}` (сырое тело до парсинга JSON), сравнение — в постоянном времени, метка t старше 5 минут отклоняется, достаточно совпадения любого v1=.
Доставка с ретраями (0с / 1м / 5м / 30м / 2ч / 5ч / 10ч / 24ч). После серии неудач эндпоинт автоматически отключается — мы пришлём уведомление. Дедуплицируйте по id конверта (он стабилен между повторами) — порядок доставок не гарантирован, трактуйте события как состояние (status / is_paid), а не как переходы. Отвечайте 2xx для подтверждения; за деталями (включая персональные данные покупателя) обращайтесь к GET /orders/{id} с ключом sk_.
Ротация секрета. Кнопка «Сменить секрет» выдаёт новый whsec_, а старый остаётся валидным ещё 24 часа — в это время мы подписываем доставки обоими (несколько v1=). Переключите проверку на новый секрет в течение окна — без даунтайма и пропущенных событий.
Путь /v1 — мажорная версия. Любое ломающее изменение выйдет под новым путём (/v2), а /v1 продолжит работать минимум 12 месяцев после анонса.
Каждый ответ несёт заголовок ROOM-Version: 2026-06-01 — дата ревизии внутри /v1. Та же дата — в поле api_version конверта вебхука. Аддитивные изменения (новое опциональное поле, новый эндпоинт, новое значение enum, новый тип вебхука) бумпают эту дату и не ломают существующих клиентов.
Чтобы оставаться совместимым: игнорируйте незнакомые поля, ветвитесь по документированным значениям enum (с дефолтной веткой) и не считайте набор полей или значений закрытым. О будущих удалениях узнавайте по заголовкам Deprecation / Sunset на устаревающем ответе — не хардкодьте даты. Сейчас /v1 не устаревает и заголовка Sunset не несёт.