Docs

Создание платёжной сессии v2

Один USD-заказ, доступные маршруты и однократное выполнение.

Сессия — один заказ; попытка создаётся только после выбора маршрута. В кабинете выберите проект и режим, откройте Платёжные запросы → Создать. JWT владельца позволяет создание без API-ключа при готовности проекта. Для интеграции используйте секрет проекта.

Создайте одну сессию на заказ

До отправки сохраните тело запроса, ID заказа, ожидаемые проект и режим, а также ключ идемпотентности. После тайм-аута или потери ответа повторяйте тот же запрос с тем же ключом.

curl --request POST 'https://api.bramapay.com/v2/payment-sessions' \
  --header 'X-Api-Key: YOUR_PROJECT_SECRET_KEY' \
  --header 'Idempotency-Key: order-demo-1042-attempt-1' \
  --header 'Content-Type: application/json' \
  --data '{
    "amountUsd": "25.00",
    "currency": "USD",
    "externalId": "order-demo-1042",
    "displayOrderNumber": "1042",
    "description": "Example service order",
    "expiresInSeconds": 1800
  }'
ПолеКонтракт
amountUsdПоложительная десятичная строка, не более шести знаков после точки. Не число с плавающей точкой.
currencyТолько USD. Фиксированное соотношение учёта стейблкоинов не является рыночным курсом или гарантией погашения.
externalIdОбязательное поле, 1–200 символов; постоянный идентификатор заказа в проекте и режиме.
displayOrderNumberНеобязательный публичный текст, до 100 кодовых точек Unicode.
descriptionНеобязательный публичный текст, до 1000 кодовых точек Unicode. Без учётных данных, секретов выдачи и персональных данных клиента.
expiresInSecondsНеобязательное целое число 1–86400. Значение по умолчанию определяется серверной политикой срока счёта. Полученный срок фиксирован.

Не передавайте в этот endpoint projectId, mode, ID кошельков, выбранный способ оплаты и переопределения URL возврата. Контекст задают API-ключ и настройки проекта. Для явного переноса комиссии используйте другой контракт: baseAmountUsd, feePayer: "BUYER", pricingVersion: "BUYER_FEE_V1", без amountUsd. Не смешивайте эти варианты.

Успешный ответ содержит sessionId, externalId, mode/paymentMode, amountUsd, currency, status, expiresAt, checkoutUrl, routes, attempts и, когда применимо, сведения о цене и мониторинге. Сохраните связь сессии с заказом и неизменяемые ценовые параметры. Новая сессия может ещё не иметь платёжных попыток.

Обработайте ответ создания

После успешного ответа сохраните sessionId, projectId, mode, externalId, amountUsd, currency и снимок расчёта комиссии в заказе до перехода на checkoutUrl. Ниже неполный вымышленный ответ, а не реальные платёжные реквизиты. Возможен routes: []; не придумывайте маршрут и не запрашивайте перевод, если доступных способов нет.

{
  "sessionId": "00000000-0000-4000-8000-000000000001",
  "projectId": "prj_00000000000000000000000000000000",
  "externalId": "order-demo-1042",
  "mode": "TEST",
  "paymentMode": "TEST",
  "amountUsd": "25",
  "currency": "USD",
  "status": "OPEN",
  "checkoutUrl": "https://checkout.example/en/checkout/00000000-0000-4000-8000-000000000001#token=EXAMPLE_NOT_A_REAL_CAPABILITY",
  "routes": [],
  "attempts": []
}

Откройте оплату

Направьте покупателя на возвращённый checkoutUrl без изменений. Во фрагменте URL находится токен доступа к сессии: не записывайте полный URL в логи и аналитику. Не конструируйте ссылку самостоятельно и не переносите токен в query-параметры.

Покупатель выбирает доступный способ, после чего получает адрес и точную сумму. QR содержит только адрес. Сумму нужно отправить одним переводом в указанной сети до истечения срока. Смена способа и повторы не продлевают срок. Не округляйте сумму, не разбивайте перевод и не просите доплатить разницу без проверки.

Проверьте webhook и достоверный статус

Событие оплаты сессии — payment_session.paid; для счетов v1 используется invoice.paid. integration.test и webhook.test проверяют соединение, а не оплату. Test-событие не должно выполнять заказ Live.

Проверьте подпись v2 на исходном теле запроса до его разбора для бизнес-обработки. Порядок подписываемых данных:

JSON.stringify([
  "vaultless.webhook", 2, eventId, deliveryId, eventType,
  timestamp, keyId, attempt, rawBodyAsUtf8String
])

Используйте HMAC-SHA256, сравнение с постоянным временем, секрет по X-Key-Id и проверку свежести времени. Версия — число 2, остальные значения конверта — строки. Проверяйте X-Idempotency-Key === X-Event-Id. Храните старые нескомпрометированные ключи для ранее созданных доставок: ротация не переподписывает их.

Полный пример проверки подписи на Node.js

Передавайте исходные байты HTTP-запроса как rawBody до JSON middleware. В Node HTTP собирайте Buffer-фрагменты с ограничением 256 КиБ; в Express для этого маршрута установите express.raw({type: "application/json", limit: "256kb"}) до JSON-парсера. Приведите имена заголовков к нижнему регистру и отклоняйте повторяющиеся заголовки подписи. Выбирайте secret из своего доверенного списка по x-key-id; неизвестный ключ отклоняйте. До проверки подписи заголовок — только подсказка для поиска ключа. Синхронизируйте часы сервера.

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyBramaPayWebhook(
  rawBody: Buffer,
  headers: Record<string, string | undefined>,
  secret: string,
) {
  const required = (name: string) => {
    const value = headers[name.toLowerCase()];
    if (!value) throw new Error(`Missing ${name}`);
    return value;
  };

  const version = required("x-webhook-version");
  const eventId = required("x-event-id");
  const deliveryId = required("x-delivery-id");
  const eventType = required("x-event-type");
  const timestamp = required("x-timestamp");
  const keyId = required("x-key-id");
  const attempt = required("x-attempt");
  const signature = required("x-signature-sha256");

  if (version !== "2" || required("x-idempotency-key") !== eventId)
    throw new Error("Invalid webhook envelope");
  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300)
    throw new Error("Stale webhook");
  if (!/^[0-9a-f]{64}$/.test(signature) || !/^[1-9]\d*$/.test(attempt))
    throw new Error("Invalid signature encoding or attempt");

  const input = JSON.stringify([
    "vaultless.webhook",
    2,
    eventId,
    deliveryId,
    eventType,
    timestamp,
    keyId,
    attempt,
    rawBody.toString("utf8"),
  ]);
  const expected = Buffer.from(createHmac("sha256", secret).update(input).digest("hex"), "hex");
  const actual = Buffer.from(signature, "hex");
  if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
    throw new Error("Invalid webhook signature");
  return { eventId, eventType, keyId };
}

Только после успешной проверки разберите JSON, проверьте его структуру и тип события, затем запросите авторизованный статус, как показано ниже. При временной ошибке чтения статуса или хранилища возвращайте не 2xx, чтобы доставка повторилась. Неверную подпись отклоняйте без обработки. Проверенный тест соединения получает 2xx, но не запускает выдачу товара.

Запрашивайте статус с сервера тем же секретным ключом проекта:

curl 'https://api.bramapay.com/v2/payment-sessions/SESSION_ID' \
  --header 'X-Api-Key: YOUR_PROJECT_SECRET_KEY'

Требуйте status === "PAID"; сверьте ID сессии, externalId, проект, режим, валюту и ожидаемую стоимость по сохранённому ценовому контракту. Сравнивайте десятичные суммы точно. При переносе комиссии настройкой проекта amountUsd интеграции сохраняет стоимость заказа, а baseAmountUsd/payableAmountUsd раскрывают составляющие; у явного buyer-контракта другая семантика. Стоимость заказа, сумма с комиссией и точная сумма токенов — разные величины.

Рекомендуемая обработка на стороне магазина; это псевдокод вашего приложения, а не методы API BramaPay:

проверить raw-подпись, время, тип и идентификатор события
найти сохранённую связь заказа с сессией и проверить статус через API
в одной транзакции базы данных:
  вставить (merchant_scope, eventId) с уникальным ограничением
  если событие уже обработано: ничего не выполнять повторно
  иначе отметить связанный заказ оплаченным и поставить выдачу в очередь один раз
вернуть небольшой ответ 2xx после надёжного сохранения

Устраняйте дубли и по событию, и по выполнению заказа. Возврат браузера, поиск транзакции, запись для ручной проверки и непроверенный клиентский статус недостаточны.

Маршруты, страница оплаты и событие оплаты

  • Ссылка оплаты имеет вид /{locale}/checkout/{sessionId}#token=…. Страница оплаты убирает токен из истории браузера и хранит его под ключом этой сессии, чтобы перезагрузка и другие вкладки могли его восстановить. Если хранилище браузера заблокировано, для повторного открытия нужна исходная полная ссылка.
  • Публичная страница оплаты вызывает GET /v2/checkout/:id и POST /v2/checkout/:id/attempts с заголовком X-Checkout-Token и телом вида { "asset": "USDT", "network": "TRON" }, без cookie и авторизации магазина. Серверной интеграции магазина эти endpoints не нужны.
  • Попытка создаётся при первом выборе маршрута; повторный выбор возвращает те же адрес, точную сумму и срок. Ранее выданные попытки остаются в списке, даже если выдача новых маршрутов недоступна.
  • Статус сессии не зависит от маршрута, открытого у покупателя: если подтвердился маршрут A, а покупатель смотрит B, сессия становится PAID, а winningAttemptId указывает на A.
  • Для попыток сессии выдачу заказа выполняют по payment_session.paid, а не по дочернему invoice.paid. Тело события содержит sessionId, attemptId, invoiceId (выигравшая попытка), externalId, projectId, mode, paymentMode, status: "PAID", amountUsd, currency, amountPaid, amountExpected, txHash и chain; в Test также тестовую сеть и test: true. Суммы передаются строками.
  • displayOrderNumber и description показываются покупателю как обычный текст: HTML и Markdown не интерпретируются. Без публичного номера заказа страница оплаты не подставляет вместо него externalId. Цены в описании не влияют на сумму платежа.
  • Раздел Платёжные запросы в кабинете ограничен выбранными проектом и режимом и показывает попытки, состояние мониторинга, выигравшую попытку и классификацию поступлений. Ранее выданные счета v1 остаются в истории старых счетов; их ссылки и интеграции продолжают работать.

Обработайте ошибки и повторы

СитуацияДействие интеграции
Тайм-аут, потеря ответа, общий 5xxРезультат может быть неизвестен. Сохраните исходные тело и ключ, повторяйте тот же запрос. Не создавайте новый заказ.
Конфликт 409Проверьте ключ, тело, внешний заказ и готовность проекта. Не обходите конфликт новым ключом.
401Проверьте секретный ключ и его проект/режим; не публикуйте ключ в диагностике.
429Увеличьте интервал и учитывайте доступные сведения о повторе.
PAYMENT_CREATION_DECLINEDТолько проверенный ответ с retryable: false, sessionCreated: false, совпадающей идентичностью запроса и квитанцией отказа подтверждает окончательный отказ. Одного HTTP 503 недостаточно. Сохраните квитанцию.
Истечение срока, поздний, лишний или несовпадающий переводНе просите платить повторно автоматически. Проверьте статус и обратитесь в поддержку с ID, сетью и хешем транзакции. Автовозврат и постоянный процесс ручного подтверждения не обещаются.

Webhooks могут доставляться повторно; успешная доставка не гарантируется. За цикл выполняется до пяти попыток. Для повторяемых ошибок интервалы составляют 30 секунд, 5 минут, 30 минут и 2 часа. Другие ошибки могут завершить цикл сразу. Неудачную доставку на активный endpoint можно повторить вручную после паузы; повтор не разрешает выдавать заказ заново. См. повторы доставки.

Таймер не определяет окончательность результата. Перевод, включённый в блок в пределах срока, может подтвердиться позже. Используйте серверные status, monitoringComplete, isFinal; не обещайте время подтверждения и не считайте каждый ответ об истечении срока окончательным отсутствием оплаты.

API возвращает status сессии как OPEN, PAID или EXPIRED. Закрытие передаётся отдельно в closedAt; CLOSED и CONFIRMING — вычисляемые фильтры кабинета, а не значения этого поля API. Первый подходящий подтверждённый перевод выигрывает; дополнительные и поздние поступления не разрешают вторую выдачу. reviewCandidates — неатрибутированные переводы на адрес, которые могут принадлежать другим заказам. Поиск транзакции не означает ручное подтверждение или возврат.