# BramaPay — agent Quick Start

Self-contained reference for a merchant backend integration. API v2 and webhook protocol v2 are separate contracts. This is not a ready-made production application or evidence of Live availability.

## Maintained source guides

- [Quick start](https://docs.bramapay.com/en/getting-started/quick-start)
- [Signature verification](https://docs.bramapay.com/en/webhooks/signature-verification)
- [Deduplicate events and orders](https://docs.bramapay.com/en/webhooks/deduplication)
- [Multi-route payment sessions (v2)](https://docs.bramapay.com/en/checkout/payment-sessions-v2)

## Server-side environment example

Example names for your merchant application, not BramaPay deployment settings. Leave secrets empty in .env.example; configure them securely on your server. Do not paste secrets into an AI chat, frontend, repository or logs. Begin with local fixtures; Test setup is separate and must never silently switch to Live. No remote session creation, transfer or deployment is authorized by this guide.

```dotenv
BRAMAPAY_API_ORIGIN=https://api.bramapay.com
BRAMAPAY_TEST_PROJECT_KEY=
BRAMAPAY_WEBHOOK_KEY_ID=
BRAMAPAY_WEBHOOK_SECRET=
```

## Amount and identity matching

Derive prices from the server-side order. Decimal values "25" and "25.00" are numerically equal: use exact decimal arithmetic, not raw string equality or JavaScript Number. Match the saved pricing contract; order value, payable USD and exact token amount are different values. Never infer the expected token amount yourself. After an uncertain create, preserve externalId, exact body and Idempotency-Key; never bypass a 409 with a new key.

# Quick start

Create a **Test v2 payment session**, open checkout and verify one test order. You need a merchant backend: requests, secret storage and payment verification run on your server. This walkthrough is not a complete production application. Implement the [complete webhook verifier](https://docs.bramapay.com/en/webhooks/signature-verification) before processing events; use the [session guide](https://docs.bramapay.com/en/checkout/payment-sessions-v2) for the full contract. Existing [v1 invoices](https://docs.bramapay.com/en/api-reference/invoices) remain supported for older integrations.

## 1. Prepare the project

Register and verify your email, select a project, then select **Test** in the dashboard. Register a Test receiving wallet that you control and explicitly accept USDT on TRON Shasta. A Live wallet registration does not become a Test wallet automatically. BramaPay never needs its private key or recovery phrase.

Configure `successUrl`, `cancelUrl` and exact `allowedRedirectDomains`. Add an active public HTTPS webhook endpoint, securely save its one-time `secret` and `keyId`, and implement signature verification before sending a connection test. Create a Test API secret for this project and keep it on your server. See [project setup](https://docs.bramapay.com/en/projects/readiness) and [return URLs](https://docs.bramapay.com/en/projects/return-urls).

Test requires enabled Test availability. If unavailable, contact support and use local fixtures to test verification; do not silently switch to Live. Test tokens cannot pay real orders or fund the Live service balance.

## 2. Create one Test session

Persist the order identity, expected project/mode, exact body and idempotency key **before** sending. After an uncertain timeout or lost response, retry the same body with the same key. Do not issue another order or bypass a conflict with a new key. Amounts are decimal strings, never JavaScript floating-point numbers.

```bash
curl --request POST 'https://api.bramapay.com/v2/payment-sessions' \
  --header 'X-Api-Key: YOUR_TEST_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"}'
```

The key selects project and mode. Do not send wallet IDs, `projectId`, `mode` or return-URL overrides. Optional fields and buyer-fee contracts are documented in the [session guide](https://docs.bramapay.com/en/checkout/payment-sessions-v2) and [buyer pricing](https://docs.bramapay.com/en/billing/buyer-fee-pricing).

## 3. Save the response

Bind the returned session ID, project, mode, external order ID, currency, amount and immutable pricing snapshot to your order before opening checkout. This is a **partial synthetic response**, with an intentionally fake capability and reserved example domain; it is not the complete schema or payment instructions.

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

`routes: []` means no selectable route is available. Do not invent one or ask for a transfer. New hosted attempts use USDT; registry entries do not establish session availability. Previously issued attempts retain their instructions.

## 4. Open checkout

Use the returned `checkoutUrl` unchanged. Its fragment is a bearer capability: keep the full URL out of logs and analytics. Keep API secrets out of browser code. Never construct the capability URL yourself or put its token in a query string.

The buyer chooses an available network and follows the **current address, token, exact amount and deadline once**. The QR contains the address only. Do not round, split transfers, calculate the buyer's token amount yourself or ask for a difference payment. Changing routes does not extend expiry. Follow [your first Test payment](https://docs.bramapay.com/en/wallets/first-test-payment) for test-wallet operations.

## 5. Verify confirmation

Run the [complete raw-body verifier](https://docs.bramapay.com/en/webhooks/signature-verification) before JSON business processing: enforce the body limit, timestamp freshness within 300 seconds, signed event ID/type, key-ID secret selection, exact protocol v2 envelope and constant-time HMAC comparison. Require `X-Idempotency-Key === X-Event-Id`. Retain uncompromised old secrets for existing delivery snapshots. The namespace is literally `vaultless.webhook` (the historical protocol name; keep it exactly as is); API v2 and webhook protocol v2 are separate versions.

Only `payment_session.paid` is the session fulfillment event. `integration.test`, `webhook.test`, a browser redirect and a child invoice event cannot authorize session fulfillment. A Test event must never pay a Live order. After signature and payload validation, read the session from your backend:

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

Require authenticated `status === "PAID"` and match the saved session, order, project, mode, currency and expected pricing contract. Compare decimal values exactly. Project-managed buyer pricing preserves order value in integration `amountUsd`; explicit `BUYER_FEE_V1` has different semantics. Do not confuse order value, payable USD and exact token amount.

Persist event deduplication and once-only order fulfillment in one database transaction, using unique event and order constraints and an outbox for delivery. Return 2xx only after durable acceptance; transient lookup/storage failures should retry. See [confirm an order](https://docs.bramapay.com/en/security/payment-verification) and [delivery retries](https://docs.bramapay.com/en/webhooks/retries).

## 6. Before Live

Configure separate Live wallets, secrets and webhooks; successfully test **every current Live endpoint/configuration**. Check concrete [readiness blockers](https://docs.bramapay.com/en/projects/readiness), available routes and the effective account fee. New Live creation requires **B + C > 0** (actual service balance B plus approved credit C). Credit defaults to zero. Previously issued payments continue processing and can increase debt beyond that threshold. Fund through [Billing](https://docs.bramapay.com/en/billing/ledger-topups), using its separate treasury instructions.

Exercise invalid signatures, wrong mode/order/event, duplicate events and fulfillment, unavailable routes, expiry and uncertain creation with local fixtures. A bare 503 does not prove refusal: only a validated `PAYMENT_CREATION_DECLINED` receipt with matching identity, `retryable: false` and `sessionCreated: false` establishes terminal rejection. Preserve it.

Do not infer finality from the countdown: a timely transfer may confirm later. Check authoritative status, `monitoringComplete` and `isFinal`. For wrong, late or additional transfers, stop and [investigate](https://docs.bramapay.com/en/invoices/mismatches); do not automatically request more money or promise refunds. Successful tests do not establish production availability.

---

# Signature verification

Capture exact Buffer bytes before JSON middleware, enforcing a 256 KiB limit while reading. Reject duplicate signing headers using the raw HTTP header list; normalize names to lowercase only afterwards. Select the secret from your trusted key-ID map using `X-Key-Id`; reject unknown IDs. The unverified ID is only a lookup hint. Keep old uncompromised secrets for existing delivery snapshots. Synchronize your server clock.

Required headers: `X-Webhook-Version`, `X-Event-Id`, `X-Delivery-Id`, `X-Event-Type`, `X-Key-Id`, `X-Timestamp`, `X-Attempt`, `X-Idempotency-Key`, `X-Signature-SHA256`. The signing namespace remains literally `vaultless.webhook`. Signing input is JSON.stringify of the ordered array below: version is numeric 2, all other envelope values are strings.

The function assumes your HTTP boundary already rejected duplicate headers, bounded raw bytes, and selected the trusted secret.

```ts
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 verification succeeds, parse JSON and validate its schema and signed event type. A valid signature alone does not authorize fulfillment: match the saved session, project, mode, external order, currency and pricing against an authenticated PAID read. Reject Test-to-Live mismatches. A connection test may return 2xx after verification but never fulfills an order. On transient lookup/storage failure return non-2xx so delivery can retry. See [deduplication](https://docs.bramapay.com/en/webhooks/deduplication) and [session reference](https://docs.bramapay.com/en/api-reference/payment-sessions).

---

# Deduplicate events and orders

Retries and manual redelivery can repeat a valid event. `X-Event-Id` is the logical event identity; `X-Delivery-Id` belongs to one endpoint delivery. Deduplicate both logical event and business order. Do not use attempt number or delivery ID as the only business key.

After raw-body verification and authenticated PAID lookup with the saved order/project/mode/currency/pricing binding, perform one database transaction: insert a unique `(merchant_scope, eventId)`, mark the bound order paid under a unique order/session constraint, and enqueue fulfillment in a durable outbox. An already accepted event or already paid order must not enqueue a second fulfillment. Commit before returning a small 2xx. A transient storage/read failure should remain retryable.

```text
verify signature and signed event type
load saved order binding and authenticated PAID session
transaction:
  insert unique event or recognize duplicate
  transition bound order to paid only once
  insert unique fulfillment outbox job
commit
return 2xx
```

Never fulfill once for the session and again for its child invoice. Test events, treasury credits, connection tests, browser redirects and review receipts cannot pay a Live order.

---

# Multi-route payment sessions (v2)

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.

```bash
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
  }'
```

| Field | Contract |
|---|---|
| `amountUsd` | Positive decimal string with at most six fractional digits. Never a floating-point number. |
| `currency` | `USD` only. The fixed stablecoin accounting basis is not a market exchange rate or redemption guarantee. |
| `externalId` | Required, 1–200 characters; the permanent order identifier within the project and mode. |
| `displayOrderNumber` | Optional public text, up to 100 Unicode code points. |
| `description` | Optional public text, up to 1000 Unicode code points. No credentials, fulfillment secrets or customer personal data. |
| `expiresInSeconds` | Optional 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.

> The same example is already shown above.

## 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](https://docs.bramapay.com/en/webhooks/signature-verification) over the raw request body before parsing it for business processing. Signed data order:

```js
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.

> The identical complete verifier is already included above in the signature-verification section. Reuse that single implementation.

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:

```bash
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:

```text
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

| Situation | Integration action |
|---|---|
| Timeout, lost response, generic 5xx | The outcome may be unknown. Keep the original body and key and repeat the same request. Do not create a new order. |
| 409 conflict | Check the key, body, external order and project readiness. Do not bypass the conflict with a new key. |
| 401 | Check the secret key and its project/mode; never publish the key in diagnostics. |
| 429 | Back off and honor any available retry information. |
| `PAYMENT_CREATION_DECLINED` | Only 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 transfer | Never 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](https://docs.bramapay.com/en/webhooks/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.
