Docs

Multi-route payment sessions (v2)

Create one USD order and let hosted checkout select its available USDT network.

A session is one order; an attempt is created only after the buyer selects a route. In the dashboard, select the project and mode, then open Payment requests → Create. A signed-in owner can create requests without an API key once the project is ready. Integrations use the project secret key.

Create one session per order

Before sending, persist the request body, order ID, expected project and mode, and the idempotency key. After a timeout or lost response, repeat the same request with the same key.

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
  }'
FieldContract
amountUsdPositive decimal string with at most six fractional digits. Never a floating-point number.
currencyUSD only. The fixed stablecoin accounting basis is not a market exchange rate or redemption guarantee.
externalIdRequired, 1–200 characters; the permanent order identifier within the project and mode.
displayOrderNumberOptional public text, up to 100 Unicode code points.
descriptionOptional public text, up to 1000 Unicode code points. No credentials, fulfillment secrets or customer personal data.
expiresInSecondsOptional integer 1–86400. The default comes from the server invoice-expiry policy. The returned deadline is fixed.

Do not send projectId, mode, wallet IDs, a selected payment method or return-URL overrides to this endpoint. The API key and project settings define the context. For an explicit buyer-paid fee use the separate contract: baseAmountUsd, feePayer: "BUYER", pricingVersion: "BUYER_FEE_V1", without amountUsd. Do not mix the two variants.

A successful response contains sessionId, externalId, mode/paymentMode, amountUsd, currency, status, expiresAt, checkoutUrl, routes, attempts and, where applicable, pricing and monitoring details. Persist the session-to-order binding and the immutable pricing parameters. A new session may have no payment attempts yet.

Handle the create response

After a successful response, store sessionId, projectId, mode, externalId, amountUsd, currency and the fee-calculation snapshot on the order before redirecting to checkoutUrl. Below is a partial synthetic response, not real payment instructions. routes: [] is possible; do not invent a route or request a transfer when no method is available.

{
  "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": []
}

Open checkout

Redirect the buyer to the returned checkoutUrl unchanged. The URL fragment contains the session access token: never write the full URL to logs or analytics. Do not construct the link yourself or move the token into query parameters.

The buyer selects an available method and then receives an address and an exact amount. The QR contains the address only. The amount must be sent in one transfer on the indicated network before expiry. Changing the method or retrying does not extend the deadline. Do not round the amount, split the transfer or ask for a difference payment without review.

Verify the webhook and authoritative status

The session payment event is payment_session.paid; v1 invoices use invoice.paid. integration.test and webhook.test check connectivity, not payment. A Test event must never fulfill a Live order.

Verify the v2 signature over the raw request body before parsing it for business processing. Signed data order:

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

Use HMAC-SHA256, constant-time comparison, a secret selected by X-Key-Id and timestamp freshness checks. The version is the number 2; other envelope values are strings. Require X-Idempotency-Key === X-Event-Id. Keep uncompromised old keys for previously created deliveries: rotation does not re-sign them.

Complete Node.js signature example

Pass the raw HTTP request bytes as rawBody before any JSON middleware. In Node HTTP collect Buffer chunks with a 256 KiB limit; in Express use express.raw({type: "application/json", limit: "256kb"}) on this route before the JSON parser. Lowercase header names and reject repeated signature headers. Select secret from your trusted map by x-key-id; reject unknown keys. Before verification the header is only a lookup hint. Keep the server clock synchronized.

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 };
}

Only after successful verification parse the JSON, validate its structure and event type, then request the authoritative status as shown below. On a transient status-read or storage error return non-2xx so delivery is retried. Reject an invalid signature without processing. A verified connectivity test receives 2xx but never triggers fulfillment.

Request the status from your server with the same project secret key:

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

Require status === "PAID"; match the session ID, externalId, project, mode, currency and expected value from the stored pricing contract. Compare decimal amounts exactly. With project-managed fee pass-through, integration amountUsd keeps the order value while baseAmountUsd/payableAmountUsd expose the components; the explicit buyer contract has different semantics. Order value, fee-inclusive amount and exact token amount are different values.

Recommended merchant-side handling; this is pseudocode for your application, not BramaPay API methods:

verify the raw signature, timestamp, event type and event ID
load the stored order-to-session binding and check status through the API
in one database transaction:
  insert (merchant_scope, eventId) under a unique constraint
  if the event was already processed: do not fulfill again
  otherwise mark the bound order paid and enqueue fulfillment once
return a small 2xx response after durable storage

Deduplicate both the event and the order fulfillment. A browser return, a transaction lookup, a manual-review record or an unverified client status is not sufficient.

Routes, checkout and the payment event

  • The checkout link has the shape /{locale}/checkout/{sessionId}#token=…. Checkout removes the token from browser history and stores it under a session-specific key so reloads and other tabs can recover it. When browser storage is blocked, the original complete link is required to reopen the page.
  • Public checkout calls GET /v2/checkout/:id and POST /v2/checkout/:id/attempts with the X-Checkout-Token header and a body such as { "asset": "USDT", "network": "TRON" }, without merchant cookies or authorization. Your server integration does not need these endpoints.
  • An attempt is created when a route is first selected; selecting it again returns the same address, exact amount and deadline. Previously issued attempts remain listed even when new route issuance is unavailable.
  • Session status is independent of the route the buyer is viewing: if route A confirms while the buyer views B, the session becomes PAID and winningAttemptId points to A.
  • Fulfill session attempts from payment_session.paid, not from the child invoice.paid. The event body includes sessionId, attemptId, invoiceId (winning attempt), externalId, projectId, mode, paymentMode, status: "PAID", amountUsd, currency, amountPaid, amountExpected, txHash and chain; Test events also identify the test network and test: true. Amounts are strings.
  • displayOrderNumber and description are shown to the buyer as plain text: HTML and Markdown are not interpreted. Without a public order number, checkout does not expose externalId in its place. Prices mentioned in the description never change the payment amount.
  • The dashboard Payment requests list and details are scoped to the selected project and mode and show attempts, monitoring state, the winning attempt and receipt classifications. Previously issued v1 invoices remain in the history of older invoices; their links and integrations keep working.

Handle errors and retries

SituationIntegration action
Timeout, lost response, generic 5xxThe outcome may be unknown. Keep the original body and key and repeat the same request. Do not create a new order.
409 conflictCheck the key, body, external order and project readiness. Do not bypass the conflict with a new key.
401Check the secret key and its project/mode; never publish the key in diagnostics.
429Back off and honor any available retry information.
PAYMENT_CREATION_DECLINEDOnly a validated response with retryable: false, sessionCreated: false, matching request identity and a refusal receipt confirms terminal rejection. A bare HTTP 503 is not enough. Preserve the receipt.
Expiry, late, additional or mismatched transferNever ask for payment again automatically. Check the status and contact support with IDs, network and transaction hash. Automatic refunds and a standing manual-confirmation process are not promised.

Webhooks may be delivered more than once; successful delivery is not guaranteed. A cycle makes up to five attempts. Retryable failures wait 30 seconds, 5 minutes, 30 minutes and 2 hours. Other failures can end the cycle immediately. A failed delivery to an active endpoint can be replayed manually after a pause; replay never authorizes fulfilling the order again. See delivery retries.

The countdown does not determine finality. A transfer included in a block before expiry may confirm later. Use the server status, monitoringComplete and isFinal; do not promise a confirmation time and do not treat every expiry response as a final absence of payment.

The API returns session status as OPEN, PAID or EXPIRED. Closure is reported separately in closedAt; CLOSED and CONFIRMING are computed dashboard filters, not values of this API field. The first qualifying confirmed transfer wins; additional and late receipts never authorize a second fulfillment. reviewCandidates are unattributed transfers at the address and may belong to other orders. A transaction lookup is not a manual confirmation or refund.