API для разработчиков

API для разработчиков ROOM

Публичный REST API ROOM позволяет программно читать ваш каталог событий и продавать билеты по СБП с вашего сайта или сервера. Ключи создаются самостоятельно в бэкстейдже. Все ответы — JSON, суммы — в копейках (минорных единицах валюты).

Справочник API (OpenAPI 3.1)

Полная машиночитаемая спецификация всех эндпоинтов, схем и вебхуков — для генерации SDK, импорта в Postman/Swagger и автодополнения.

Быстрый старт

1. Создайте ключ в разделе «API и вебхуки» бэкстейджа. Для боевых (live) ключей нужна верификация. Для тестов используйте sk_test — заказы создаются с mock-оплатой.

2. Сделайте первый запрос — список ваших событий:

curl
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_ только на сервере и никогда не коммитьте ключи в репозиторий.

Базовый URL и версия

https://roombackstage.ru/api/public/v1

Версия зафиксирована в пути (/v1). Ломающие изменения выйдут как /v2 — текущая версия продолжит работать.

Ошибки

Ошибки возвращаются со стабильным конвертом и подходящим HTTP-статусом:

json
{ "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
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, тело одинаковое). Это и есть идемпотентный результат — безопасно ретраить.
  • заказ ещё готовится (создаётся СБП-QR) → повтор может вернуть 409 order_pending с заголовком Retry-After. Повторите через секунду — ответ сойдётся, когда QR будет готов.
  • предыдущий заказ по ключу отменён (например, не удалось создать платёж) → повтор возвращает 409 order_cancelled. Начните заново со свежим Idempotency-Key.

Ключ одноразовый-на-заказ и не освобождается со временем (нет «окна» переиспользования). Правило простое: один логический заказ = один новый ключ. Не переиспользуйте старый ключ под новую покупку — генерируйте свежий на каждую попытку оформления (например, crypto.randomUUID()).

Лимиты запросов

По умолчанию: чтение — ~600 запросов/мин на ключ, создание заказов/броней — ~60/мин. При превышении приходит 429 с заголовками Retry-After, X-RateLimit-Remaining, X-RateLimit-Reset.

Доступность (SLA) и /health

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

curl
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% в месяц.
  • Латентность (p95): чтение — < 500 мс, создание заказа — < 1 с (без учёта времени банка на стороне СБП).
  • Совместимость /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:

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 минут. Идеально для интеграционных тестов.

Песочница (test-режим)

Создайте ключ sk_test в бэкстейдже (для test-ключей верификация не нужна). Среда зашита в ключ: test-ключ всегда работает с тестовыми данными, никаких реальных денег. В вебхуках тестовых заказов livemode:false — приёмник обязан игнорировать такие события на проде.

Полный happy-path без реальной оплаты

qr_payload у тестового заказа нефункционален (ничего не списывает). Чтобы довести тестовый заказ до paid и проверить polling + свой webhook-приёмник, используйте симуляцию оплаты (только sk_test):

bash
# 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 эндпоинта сбрасывает подтверждение — пройдите проверку заново.

javascript
// Обработайте проверочный запрос так же, как обычную доставку (подпись валидна),
// но верните полученный nonce в 2xx-ответе:
if (event.type === "endpoint.verify") {
  return res.status(200).json({ nonce: event.data.nonce })
}

Тело — подписанный конверт:

json
{
  "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 (защита от повторной отправки):

javascript
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),
  )
}
Те же проверки на других языках (Python, PHP, Ruby, Go, C#)
python
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)
php
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;
}
ruby
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) }
end
go
func 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
}
csharp
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 не несёт.