Skip to main content

BettaPay API Documentation

Everything you need to accept USDC on Stellar and settle to fiat — authentication, every endpoint, webhooks, and error handling, with copy-paste examples in cURL, Node.js and Python.

Overview

The BettaPay API lets you accept USDC payments on Stellar, settle to fiat, register merchants and read live FX rates — all over a small, predictable REST surface.

Base URL

Every endpoint in this reference is relative to a single gateway host:

Base URL
https://bettapay-backend.onrender.com

The gateway is a Fastify service that fronts the merchant, payment, settlement and FX engines. You only ever talk to this host — internal service topology is not exposed.

Conventions

  • All requests and responses are JSON. Send Content-Type: application/json on any request with a body.
  • Authenticated routes expect a Authorization: Bearer <jwt> header. See Authentication.
  • Timestamps are ISO 8601 strings in UTC. Monetary amounts use decimal numbers (USDC) — never floats-as-strings.
  • Request bodies are normalized server-side (Unicode NFC, control characters stripped) before validation.
  • The gateway enforces a 30s request timeout and per-route rate limits. See HTTP Status.

Response format

Successful responses wrap their payload in a data envelope. This keeps the shape stable even when an endpoint returns a single object, a list, or paginated results.

200 OK
{
  "data": {
    "id": "9b2f...",
    "amountUsdc": 25.00,
    "status": "pending"
  }
}

Errors return an error object with a machine-readable code, a human message, and optional details (for example, per-field Zod validation issues).

400 Bad Request
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "amountUsdc must be a positive number",
    "details": [
      { "path": "amountUsdc", "message": "Expected positive number" }
    ]
  }
}

Always branch on the envelope, not the HTTP status alone

Check for the presence of error and read error.code — the codes are stable across releases, while messages may be reworded. The full list is in the Error Reference.

Authentication

BettaPay issues a signed JWT that you attach as a Bearer token on every authenticated request. There are two ways to obtain one: Google OAuth and Stellar wallet auth.

Google OAuth

Prerequisite: create an OAuth 2.0 Web Client in the Google Cloud console, add your origin to the authorized JavaScript origins, and expose the client id to the frontend as NEXT_PUBLIC_GOOGLE_CLIENT_ID.

On the client, render <GoogleLogin /> from @react-oauth/google. On success you receive a Google ID token (credential) which you POST to the gateway.

SignInButton.tsx
import { GoogleOAuthProvider, GoogleLogin } from '@react-oauth/google';

export function SignInButton() {
  const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID?.trim();

  // Guard provider initialization — an empty string looks valid to the SDK
  // but triggers runtime errors. Render a disabled fallback when unset.
  if (!googleClientId) {
    return (
      <button disabled title="Google login not configured — set NEXT_PUBLIC_GOOGLE_CLIENT_ID">
        Continue with Google
      </button>
    );
  }

  return (
    <GoogleOAuthProvider clientId={googleClientId}>
      <GoogleLogin
        onSuccess={async ({ credential }) => {
          // Exchange the Google ID token for a BettaPay JWT
          const res = await fetch(
            'https://bettapay-backend.onrender.com/api/auth/google',
            {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ idToken: credential }),
            },
          );
          const { token } = await res.json();
          // persist the session (see JWT Handling below)
        }}
        onError={() => console.error('Google login failed')}
      />
    </GoogleOAuthProvider>
  );
}

Guard the provider — empty string is not empty to the SDK

When NEXT_PUBLIC_GOOGLE_CLIENT_ID is unset, empty or whitespace the app must not mount GoogleOAuthProvider — the SDK treats '' as valid config and logs errors. The reference implementation in app/layout.tsx + app/auth/login/page.tsx guards with trim() and renders a disabled fallback button with the message “Google login not configured — set NEXT_PUBLIC_GOOGLE_CLIENT_ID” (plus a dev-only console.warn).

The gateway verifies the ID token with google-auth-library against your client id, upserts the merchant, and returns a BettaPay JWT.

Exchange Google ID token

POST/api/auth/googlePublic

Verifies a Google ID token with google-auth-library and returns a BettaPay JWT. Called after the React GoogleLogin flow resolves.

Rate limit: 100 requests / minute per IP (auth endpoints).

Request Headers
HeaderValueRequiredDescription
Content-Typeapplication/jsonYesAll request bodies are JSON and NFC-normalized server-side.
Request Parameters

Body

FieldTypeRequiredDescription
idToken
stringYesThe Google ID token (credential) returned by the GoogleLogin component.
eyJhbGciOiJSUzI1NiIsImtpZCI6...
Code Examples
curl -X POST "https://bettapay-backend.onrender.com/api/auth/google" \
  -H "Content-Type: application/json" \
  -d '{"idToken":"eyJhbGciOiJSUzI1NiIsImtpZCI6..."}'
Response
FieldTypeRequiredDescription
token
stringYesSigned BettaPay JWT (base64url encoded).
200OK
{
  "token": "eyJhbGciOiJIUzI1NiJ9.eyJtZXJjaGFudElkIjoi..."
}
Errors
StatusCodeDescription
400VALIDATION_ERRORRequest body failed Zod validation. `details` lists the offending fields.
401UNAUTHORIZEDThe Google ID token could not be verified.
429RATE_LIMITEDToo many auth attempts.

Wallet Auth

Wallet auth proves ownership of a Stellar key without a password. The gateway issues a challenge, the wallet signs it, and the gateway verifies the Ed25519 signature with @stellar/stellar-sdk before issuing a JWT.

  1. Request a challenge for the connected address.
  2. Sign the challenge string with Freighter (signMessage).
  3. POST the address, challenge and signature to verify — receive a JWT.
wallet-auth.ts
import { signMessage } from '@stellar/freighter-api';

const BASE = 'https://bettapay-backend.onrender.com';

// 1. Ask the gateway for a one-time challenge for this address
const { challenge } = await fetch(
  `${BASE}/api/auth/wallet/challenge?address=${address}`,
).then((r) => r.json());

// 2. Sign the challenge with Freighter (Ed25519)
const { signedMessage, signature } = await signMessage(challenge, { address });

// 3. Exchange the signed challenge for a BettaPay JWT
const { token } = await fetch(`${BASE}/api/auth/wallet/verify`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ address, challenge, signature: signature ?? signedMessage }),
}).then((r) => r.json());

Request a wallet challenge

GET/api/auth/wallet/challengePublic

Returns a one-time challenge string for the given Stellar address. The wallet signs this challenge to prove key ownership.

Rate limit: 100 requests / minute per IP (auth endpoints).

Request Parameters

Query

FieldTypeRequiredDescription
address
stringYesThe Stellar public key (G...) requesting authentication.
GAXY...7QF
Code Examples
curl -X GET "https://bettapay-backend.onrender.com/api/auth/wallet/challenge?address=GAXY...7QF"
Response
FieldTypeRequiredDescription
challenge
stringYesRandom nonce to sign with Freighter.
200OK
{
  "challenge": "bettapay-auth:GAXY...7QF:1721300000:9f3c2a..."
}
Errors
StatusCodeDescription
400VALIDATION_ERRORMissing or malformed `address` query param.
429RATE_LIMITEDToo many auth attempts.

Verify a signed challenge

POST/api/auth/wallet/verifyPublic

Verifies the Ed25519 signature over the challenge with @stellar/stellar-sdk and issues a BettaPay JWT on success.

Rate limit: 100 requests / minute per IP (auth endpoints).

Request Headers
HeaderValueRequiredDescription
Content-Typeapplication/jsonYesAll request bodies are JSON and NFC-normalized server-side.
Request Parameters

Body

FieldTypeRequiredDescription
address
stringYesStellar public key that signed the challenge.
GAXY...7QF
challenge
stringYesThe exact challenge returned by the challenge endpoint.
signature
stringYesBase64 signature produced by Freighter signMessage.
Code Examples
curl -X POST "https://bettapay-backend.onrender.com/api/auth/wallet/verify" \
  -H "Content-Type: application/json" \
  -d '{"address":"GAXY...7QF","challenge":"bettapay-auth:GAXY...7QF:1721300000:9f3c2a...","signature":"MEUCIQ...=="}'
Response
FieldTypeRequiredDescription
token
stringYesSigned BettaPay JWT (base64url encoded).
200OK
{
  "token": "eyJhbGciOiJIUzI1NiJ9.eyJtZXJjaGFudElkIjoi..."
}
Errors
StatusCodeDescription
400VALIDATION_ERRORRequest body failed Zod validation. `details` lists the offending fields.
401UNAUTHORIZEDSignature did not verify against the address, or the challenge expired.
429RATE_LIMITEDToo many auth attempts.

Challenges are single-use and short-lived

A challenge is bound to the address that requested it and expires quickly. Always sign the exact string returned by the challenge endpoint — do not reconstruct or reuse it.

JWT Handling

Both flows return the same token shape. Decode the payload to read the merchant context — but decode it correctly:

The payload is base64url, not base64

atob() expects standard base64. JWT segments are base64url (- and _ instead of + and /). Convert first, or the decode fails intermittently depending on the payload bytes.
decode-jwt.js
// A BettaPay JWT is three base64url segments: header.payload.signature.
// base64url is NOT base64 — it swaps '+' '/' for '-' '_'. Feeding it straight
// to atob() corrupts any payload that happens to contain those characters.
function decodeJwt(token) {
  const segment = token.split('.')[1];
  const base64 = segment.replace(/-/g, '+').replace(/_/g, '/');
  return JSON.parse(atob(base64));
}

decodeJwt(token);
// → { merchantId: '4c1e...', ownerId: 'ops@acme.com',
//     role: 'merchant', iat: 1721300000, exp: 1721386400 }

The decoded payload contains:

FieldTypeRequiredDescription
merchantId
string (uuid)YesThe merchant this token acts as. Used to scope every authenticated route.
ownerId
stringYesIdentity of the principal that owns the merchant (email for Google, address for wallet).
role
'merchant' | 'admin'YesAuthorization role.
iat
number (unix)YesIssued-at time.
exp
number (unix)YesExpiry time. Re-authenticate or refresh before this passes.

Session persistence & expiry. Keep the raw token out of localStorage. The reference frontend posts it to a same-origin route that sets an HttpOnly, Secure session cookie, so JavaScript never touches it after login. Watch exp and refresh ahead of time.

Refresh flow. When an authenticated request returns 401, call your refresh endpoint once, then replay the original request. Concurrent 401s should queue behind a single in-flight refresh so you never trigger a refresh storm.

  • Attach Authorization: Bearer <jwt> to every authenticated call.
  • On 401, refresh and retry the original request exactly once.
  • On a failed refresh, clear the session and send the user back to sign-in.

Quickstart

From zero to a settled payment in five steps. The snippets use cURL; every endpoint also ships Node.js, Python and React examples in the API Reference.

1. Sign up and get a token

Authenticate with Google OAuth or wallet auth. Both return a JWT you attach as a Bearer token.

Terminal
# After signing in (Google or wallet), you hold a BettaPay JWT.
# Export it so the snippets below can use it:
export BETTAPAY_TOKEN="eyJhbGciOiJIUzI1NiJ9..."

2. Make your first API call

Confirm the token works by reading your merchant profile. The merchantId comes from your JWT payload.

Terminal
# Read your own merchant profile. The merchantId is the
# "merchantId" claim inside your JWT payload.
curl "https://bettapay-backend.onrender.com/api/merchants/$MERCHANT_ID" \
  -H "Authorization: Bearer $BETTAPAY_TOKEN"

3. Create a payment link

Create a payment for 25 USDC. Send an Idempotency-Key so client retries never create duplicate charges.

Terminal
# Create a payment link for 25 USDC. The Idempotency-Key makes
# retries safe — the same key returns the same payment for 24h.
curl -X POST "https://bettapay-backend.onrender.com/api/payments" \
  -H "Authorization: Bearer $BETTAPAY_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "amountUsdc": 25.00, "currency": "USDC", "source": "checkout" }'
# → { "data": { "id": "9b2f...", "status": "pending", "url": "https://betta.pay/pay/9b2f" } }

4. Verify the payment

A new payment starts pending. Once the payer sends USDC and the transaction confirms on Stellar, the status becomes completed and a txHash appears.

Terminal
# Poll the payment until it settles on-chain (status: completed).
curl "https://bettapay-backend.onrender.com/api/payments/9b2f..." \
  -H "Authorization: Bearer $BETTAPAY_TOKEN"
# → { "data": { "id": "9b2f...", "status": "completed", "txHash": "a1b2..." } }

5. Receive a webhook

Rather than poll, register a webhook to be notified the moment a payment settles. See Webhook Integration for the payload shape and Webhook Events for the event catalog.

Test on Testnet first

You can run this entire flow against Stellar Testnet with free test USDC before going live — see Testing with Testnet.

Merchants

A merchant is the account that owns payments and settlements. The merchant your JWT acts as is fixed by the merchantId claim in the token.

All merchant routes require a Bearer JWT and only ever read or mutate the merchant you own. Attempting to access another merchant returns 403 FORBIDDEN.

Create a merchant

POST/api/merchantsAuth required

Registers a merchant profile owned by the authenticated principal.

Rate limit: 30 requests / minute per IP (state-changing routes).

Request Headers
HeaderValueRequiredDescription
AuthorizationBearer <jwt>YesJWT issued by an auth endpoint. Required on every authenticated route.
Content-Typeapplication/jsonYesAll request bodies are JSON and NFC-normalized server-side.
Request Parameters

Body

FieldTypeRequiredDescription
name
stringYesBusiness display name.
Acme Stores
email
stringYesContact email for settlement notices.
ops@acme.com
country
string (ISO 3166-1 alpha-2)NoMerchant country code.
NG
Code Examples
curl -X POST "https://bettapay-backend.onrender.com/api/merchants" \
  -H "Authorization: Bearer $BETTAPAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Acme Stores","email":"ops@acme.com","country":"NG"}'
Response
FieldTypeRequiredDescription
data
objectYesThe created merchant.
id
string (uuid)YesMerchant id.
name
stringYesBusiness display name.
email
stringYesContact email.
createdAt
string (ISO 8601)YesCreation timestamp.
201Created
{
  "data": {
    "id": "4c1e...",
    "name": "Acme Stores",
    "email": "ops@acme.com",
    "createdAt": "2026-07-18T10:00:00.000Z"
  }
}
Errors
StatusCodeDescription
401UNAUTHORIZEDMissing, malformed or expired JWT.
429RATE_LIMITEDRate limit exceeded — retry after the Retry-After header.
400VALIDATION_ERRORRequest body failed Zod validation. `details` lists the offending fields.

Get a merchant

GET/api/merchants/:idAuth required

Fetches a merchant profile by id. Callers may only read merchants they own.

Rate limit: 1000 requests / minute per IP (global).

Request Headers
HeaderValueRequiredDescription
AuthorizationBearer <jwt>YesJWT issued by an auth endpoint. Required on every authenticated route.
Request Parameters

Path

FieldTypeRequiredDescription
id
string (uuid)YesMerchant id (matches `merchantId` in the JWT).
4c1e...
Code Examples
curl -X GET "https://bettapay-backend.onrender.com/api/merchants/4c1e..." \
  -H "Authorization: Bearer $BETTAPAY_TOKEN"
Response
FieldTypeRequiredDescription
data
objectYesThe merchant profile.
id
string (uuid)YesMerchant id.
name
stringYesBusiness display name.
email
stringYesContact email.
createdAt
string (ISO 8601)YesCreation timestamp.
200OK
{
  "data": {
    "id": "4c1e...",
    "name": "Acme Stores",
    "email": "ops@acme.com",
    "createdAt": "2026-07-18T10:00:00.000Z"
  }
}
Errors
StatusCodeDescription
401UNAUTHORIZEDMissing, malformed or expired JWT.
429RATE_LIMITEDRate limit exceeded — retry after the Retry-After header.
403FORBIDDENThe JWT does not own this merchant.
404NOT_FOUNDNo merchant with that id.

Update a merchant

PATCH/api/merchants/:idAuth required

Partially updates a merchant profile. Only provided fields are changed.

Rate limit: 30 requests / minute per IP (state-changing routes).

Request Headers
HeaderValueRequiredDescription
AuthorizationBearer <jwt>YesJWT issued by an auth endpoint. Required on every authenticated route.
Content-Typeapplication/jsonYesAll request bodies are JSON and NFC-normalized server-side.
Request Parameters

Path

FieldTypeRequiredDescription
id
string (uuid)YesMerchant id.
4c1e...

Body

FieldTypeRequiredDescription
name
stringNoNew business display name.
Acme Global
email
stringNoNew contact email.
billing@acme.com
Code Examples
curl -X PATCH "https://bettapay-backend.onrender.com/api/merchants/4c1e..." \
  -H "Authorization: Bearer $BETTAPAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Acme Global"}'
Response
FieldTypeRequiredDescription
data
objectYesThe updated merchant.
200OK
{
  "data": {
    "id": "4c1e...",
    "name": "Acme Global",
    "email": "ops@acme.com",
    "createdAt": "2026-07-18T10:00:00.000Z"
  }
}
Errors
StatusCodeDescription
401UNAUTHORIZEDMissing, malformed or expired JWT.
429RATE_LIMITEDRate limit exceeded — retry after the Retry-After header.
400VALIDATION_ERRORRequest body failed Zod validation. `details` lists the offending fields.
403FORBIDDENThe JWT does not own this merchant.
404NOT_FOUNDNo merchant with that id.

Payments

Create payments (and hosted payment links), read their status as they settle on Stellar, and advance them through their lifecycle.

Status lifecycle

A payment moves forward through a fixed set of states. Only forward transitions are allowed — the gateway rejects illegal moves (for example completed → pending) with 409 IDEMPOTENCY_CONFLICT.

pendingprocessingcompleted— or —failedexpired

Use idempotency keys when creating payments

Network retries are inevitable. Send an Idempotency-Key on POST /api/payments so a retried create returns the original payment instead of a duplicate charge. See Idempotency Keys.

Create a payment

POST/api/paymentsAuth required

Creates a payment (or hosted payment link). Send an Idempotency-Key so client retries never create duplicates — keys are cached for 24h.

Rate limit: 30 requests / minute per IP (state-changing routes).

Request Headers
HeaderValueRequiredDescription
AuthorizationBearer <jwt>YesJWT issued by an auth endpoint. Required on every authenticated route.
Content-Typeapplication/jsonYesAll request bodies are JSON and NFC-normalized server-side.
Idempotency-Key<unique-string>NoOptional but recommended for POST /api/payments. Max 255 chars, cached for 24h so retries never double-charge.
Request Parameters

Body

FieldTypeRequiredDescription
amountUsdc
numberYesAmount to charge in USDC. Must be positive.
25.00
currency
stringNoDisplay currency for the link. Defaults to USDC.
USDC
source
stringNoFree-form origin label.
checkout
metadata
objectNoArbitrary key/value pairs echoed back on webhooks.
Code Examples
curl -X POST "https://bettapay-backend.onrender.com/api/payments" \
  -H "Authorization: Bearer $BETTAPAY_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"amountUsdc":25,"currency":"USDC","source":"checkout"}'
Response
FieldTypeRequiredDescription
data
objectYesThe created payment.
id
string (uuid)YesPayment id.
merchantId
string (uuid)YesOwning merchant.
amountUsdc
numberYesAmount charged in USDC.
amountNgn
number | nullNoFiat equivalent at capture time.
fxRate
number | nullNoUSDC→NGN rate applied.
status
stringYesOne of pending, processing, completed, failed, expired.
txHash
string | nullNoStellar transaction hash once settled on-chain.
payerAddress
string | nullNoStellar address that paid.
source
string | nullNoOrigin of the payment (e.g. link, api).
url
stringNoHosted payment link URL, when applicable.
createdAt
string (ISO 8601)YesCreation timestamp.
201Created
{
  "data": {
    "id": "9b2f...",
    "merchantId": "4c1e...",
    "amountUsdc": 25,
    "amountNgn": null,
    "fxRate": null,
    "status": "pending",
    "txHash": null,
    "payerAddress": null,
    "source": "checkout",
    "url": "https://betta.pay/pay/9b2f",
    "createdAt": "2026-07-18T10:05:00.000Z"
  }
}
Errors
StatusCodeDescription
401UNAUTHORIZEDMissing, malformed or expired JWT.
429RATE_LIMITEDRate limit exceeded — retry after the Retry-After header.
400VALIDATION_ERRORRequest body failed Zod validation. `details` lists the offending fields.
409IDEMPOTENCY_CONFLICTThe Idempotency-Key was reused with a different body.

List payments

GET/api/paymentsAuth required

Returns payments belonging to the authenticated merchant, newest first.

Rate limit: 1000 requests / minute per IP (global).

Request Headers
HeaderValueRequiredDescription
AuthorizationBearer <jwt>YesJWT issued by an auth endpoint. Required on every authenticated route.
Request Parameters

Query

FieldTypeRequiredDescription
status
stringNoFilter by payment status.
completed
limit
numberNoPage size (default 50).
50
Code Examples
curl -X GET "https://bettapay-backend.onrender.com/api/payments?status=completed&limit=50" \
  -H "Authorization: Bearer $BETTAPAY_TOKEN"
Response
FieldTypeRequiredDescription
data
Payment[]YesArray of payments.
id
string (uuid)YesPayment id.
merchantId
string (uuid)YesOwning merchant.
amountUsdc
numberYesAmount charged in USDC.
amountNgn
number | nullNoFiat equivalent at capture time.
fxRate
number | nullNoUSDC→NGN rate applied.
status
stringYesOne of pending, processing, completed, failed, expired.
txHash
string | nullNoStellar transaction hash once settled on-chain.
payerAddress
string | nullNoStellar address that paid.
source
string | nullNoOrigin of the payment (e.g. link, api).
url
stringNoHosted payment link URL, when applicable.
createdAt
string (ISO 8601)YesCreation timestamp.
200OK
{
  "data": [
    {
      "id": "9b2f...",
      "merchantId": "4c1e...",
      "amountUsdc": 25,
      "amountNgn": 41250,
      "fxRate": 1650,
      "status": "completed",
      "txHash": "a1b2...",
      "payerAddress": "GABC...",
      "source": "checkout",
      "createdAt": "2026-07-18T10:05:00.000Z"
    }
  ]
}
Errors
StatusCodeDescription
401UNAUTHORIZEDMissing, malformed or expired JWT.
429RATE_LIMITEDRate limit exceeded — retry after the Retry-After header.

Get a payment

GET/api/payments/:idAuth required

Fetches a single payment by id.

Rate limit: 1000 requests / minute per IP (global).

Request Headers
HeaderValueRequiredDescription
AuthorizationBearer <jwt>YesJWT issued by an auth endpoint. Required on every authenticated route.
Request Parameters

Path

FieldTypeRequiredDescription
id
string (uuid)YesPayment id.
9b2f...
Code Examples
curl -X GET "https://bettapay-backend.onrender.com/api/payments/9b2f..." \
  -H "Authorization: Bearer $BETTAPAY_TOKEN"
Response
FieldTypeRequiredDescription
data
objectYesThe payment.
id
string (uuid)YesPayment id.
merchantId
string (uuid)YesOwning merchant.
amountUsdc
numberYesAmount charged in USDC.
amountNgn
number | nullNoFiat equivalent at capture time.
fxRate
number | nullNoUSDC→NGN rate applied.
status
stringYesOne of pending, processing, completed, failed, expired.
txHash
string | nullNoStellar transaction hash once settled on-chain.
payerAddress
string | nullNoStellar address that paid.
source
string | nullNoOrigin of the payment (e.g. link, api).
url
stringNoHosted payment link URL, when applicable.
createdAt
string (ISO 8601)YesCreation timestamp.
200OK
{
  "data": {
    "id": "9b2f...",
    "merchantId": "4c1e...",
    "amountUsdc": 25,
    "amountNgn": 41250,
    "fxRate": 1650,
    "status": "completed",
    "txHash": "a1b2...",
    "payerAddress": "GABC...",
    "source": "checkout",
    "createdAt": "2026-07-18T10:05:00.000Z"
  }
}
Errors
StatusCodeDescription
401UNAUTHORIZEDMissing, malformed or expired JWT.
429RATE_LIMITEDRate limit exceeded — retry after the Retry-After header.
404NOT_FOUNDNo payment with that id.

Update payment status

PATCH/api/payments/:idAuth required

Transitions a payment between states. Only forward transitions are allowed (pending → processing → completed, or → failed/expired).

Rate limit: 30 requests / minute per IP (state-changing routes).

Request Headers
HeaderValueRequiredDescription
AuthorizationBearer <jwt>YesJWT issued by an auth endpoint. Required on every authenticated route.
Content-Typeapplication/jsonYesAll request bodies are JSON and NFC-normalized server-side.
Request Parameters

Path

FieldTypeRequiredDescription
id
string (uuid)YesPayment id.
9b2f...

Body

FieldTypeRequiredDescription
status
string (enum)YesTarget status: processing | completed | failed | expired.
completed
Code Examples
curl -X PATCH "https://bettapay-backend.onrender.com/api/payments/9b2f..." \
  -H "Authorization: Bearer $BETTAPAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status":"completed"}'
Response
FieldTypeRequiredDescription
data
objectYesThe updated payment.
id
string (uuid)YesPayment id.
merchantId
string (uuid)YesOwning merchant.
amountUsdc
numberYesAmount charged in USDC.
amountNgn
number | nullNoFiat equivalent at capture time.
fxRate
number | nullNoUSDC→NGN rate applied.
status
stringYesOne of pending, processing, completed, failed, expired.
txHash
string | nullNoStellar transaction hash once settled on-chain.
payerAddress
string | nullNoStellar address that paid.
source
string | nullNoOrigin of the payment (e.g. link, api).
url
stringNoHosted payment link URL, when applicable.
createdAt
string (ISO 8601)YesCreation timestamp.
200OK
{
  "data": {
    "id": "9b2f...",
    "merchantId": "4c1e...",
    "amountUsdc": 25,
    "status": "completed",
    "txHash": "a1b2...",
    "createdAt": "2026-07-18T10:05:00.000Z"
  }
}
Errors
StatusCodeDescription
401UNAUTHORIZEDMissing, malformed or expired JWT.
429RATE_LIMITEDRate limit exceeded — retry after the Retry-After header.
400VALIDATION_ERRORRequest body failed Zod validation. `details` lists the offending fields.
404NOT_FOUNDNo payment with that id.
409IDEMPOTENCY_CONFLICTIllegal status transition (e.g. completed → pending).

Settlements

Settlements move a merchant's USDC balance out to fiat or another destination. The settlement engine processes them in batches under per-merchant limits.

Every settlement you create is validated against your merchant limits before it is queued:

  • Minimum — amounts below the merchant minimum are rejected.
  • Maximum — a single settlement cannot exceed the per-transaction maximum.
  • Daily limit — the sum of a day's settlements cannot exceed the daily cap.

Batch processing

Queued settlements are collected and processed by the settlement engine on its own schedule, so a newly created settlement starts as pending and transitions to processing then completed once the batch clears on-chain. Exceeding a limit returns 403 FORBIDDEN.

Create a settlement

POST/api/settlementsAuth required

Queues a settlement for the authenticated merchant. The settlement engine batches these and enforces per-merchant min/max and daily limits.

Rate limit: 30 requests / minute per IP (state-changing routes).

Request Headers
HeaderValueRequiredDescription
AuthorizationBearer <jwt>YesJWT issued by an auth endpoint. Required on every authenticated route.
Content-Typeapplication/jsonYesAll request bodies are JSON and NFC-normalized server-side.
Request Parameters

Body

FieldTypeRequiredDescription
amountUsdc
numberYesAmount to settle. Must respect the merchant min/max.
100.00
destination
stringNoPayout destination (bank ref or Stellar address).
bank_acct_123
Code Examples
curl -X POST "https://bettapay-backend.onrender.com/api/settlements" \
  -H "Authorization: Bearer $BETTAPAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"amountUsdc":100,"destination":"bank_acct_123"}'
Response
FieldTypeRequiredDescription
data
objectYesThe queued settlement.
id
string (uuid)YesSettlement id.
merchantId
string (uuid)YesOwning merchant.
amountUsdc
numberYesAmount settled in USDC.
amountNgn
number | nullNoFiat amount paid out.
status
stringYesOne of pending, processing, completed, failed.
effectiveRule
objectYesThe resolved settlement rule applied to this settlement: merchant, then default, then governance.
feeBps
numberYesSettlement fee in basis points.
autoSettle
booleanYesWhether automatic settlement is enabled.
delay
numberYesSettlement delay in minutes.
source
'merchant' | 'default' | 'governance'YesRule level that supplied the effective values.
txHash
string | nullNoOn-chain settlement transaction hash.
bankName
string | nullNoDestination bank for fiat payout.
accountNumber
string | nullNoMasked destination account number.
createdAt
string (ISO 8601)YesCreation timestamp.
201Created
{
  "data": {
    "id": "7d5a...",
    "merchantId": "4c1e...",
    "amountUsdc": 100,
    "amountNgn": null,
    "status": "pending",
    "txHash": null,
    "bankName": null,
    "accountNumber": null,
    "createdAt": "2026-07-18T11:00:00.000Z"
  }
}
Errors
StatusCodeDescription
401UNAUTHORIZEDMissing, malformed or expired JWT.
429RATE_LIMITEDRate limit exceeded — retry after the Retry-After header.
400VALIDATION_ERRORRequest body failed Zod validation. `details` lists the offending fields.
403FORBIDDENAmount is below the min, above the max, or exceeds the daily limit.

List settlements

GET/api/settlementsAuth required

Returns settlements belonging to the authenticated merchant, newest first.

Rate limit: 1000 requests / minute per IP (global).

Request Headers
HeaderValueRequiredDescription
AuthorizationBearer <jwt>YesJWT issued by an auth endpoint. Required on every authenticated route.
Code Examples
curl -X GET "https://bettapay-backend.onrender.com/api/settlements" \
  -H "Authorization: Bearer $BETTAPAY_TOKEN"
Response
FieldTypeRequiredDescription
data
Settlement[]YesArray of settlements.
id
string (uuid)YesSettlement id.
merchantId
string (uuid)YesOwning merchant.
amountUsdc
numberYesAmount settled in USDC.
amountNgn
number | nullNoFiat amount paid out.
status
stringYesOne of pending, processing, completed, failed.
effectiveRule
objectYesThe resolved settlement rule applied to this settlement: merchant, then default, then governance.
feeBps
numberYesSettlement fee in basis points.
autoSettle
booleanYesWhether automatic settlement is enabled.
delay
numberYesSettlement delay in minutes.
source
'merchant' | 'default' | 'governance'YesRule level that supplied the effective values.
txHash
string | nullNoOn-chain settlement transaction hash.
bankName
string | nullNoDestination bank for fiat payout.
accountNumber
string | nullNoMasked destination account number.
createdAt
string (ISO 8601)YesCreation timestamp.
200OK
{
  "data": [
    {
      "id": "7d5a...",
      "merchantId": "4c1e...",
      "amountUsdc": 100,
      "amountNgn": 165000,
      "status": "completed",
      "txHash": "c3d4...",
      "bankName": "GTBank",
      "accountNumber": "****4321",
      "createdAt": "2026-07-18T11:00:00.000Z"
    }
  ]
}
Errors
StatusCodeDescription
401UNAUTHORIZEDMissing, malformed or expired JWT.
429RATE_LIMITEDRate limit exceeded — retry after the Retry-After header.

Get a settlement

GET/api/settlements/:idAuth required

Fetches a single settlement by id.

Rate limit: 1000 requests / minute per IP (global).

Request Headers
HeaderValueRequiredDescription
AuthorizationBearer <jwt>YesJWT issued by an auth endpoint. Required on every authenticated route.
Request Parameters

Path

FieldTypeRequiredDescription
id
string (uuid)YesSettlement id.
7d5a...
Code Examples
curl -X GET "https://bettapay-backend.onrender.com/api/settlements/7d5a..." \
  -H "Authorization: Bearer $BETTAPAY_TOKEN"
Response
FieldTypeRequiredDescription
data
objectYesThe settlement.
id
string (uuid)YesSettlement id.
merchantId
string (uuid)YesOwning merchant.
amountUsdc
numberYesAmount settled in USDC.
amountNgn
number | nullNoFiat amount paid out.
status
stringYesOne of pending, processing, completed, failed.
effectiveRule
objectYesThe resolved settlement rule applied to this settlement: merchant, then default, then governance.
feeBps
numberYesSettlement fee in basis points.
autoSettle
booleanYesWhether automatic settlement is enabled.
delay
numberYesSettlement delay in minutes.
source
'merchant' | 'default' | 'governance'YesRule level that supplied the effective values.
txHash
string | nullNoOn-chain settlement transaction hash.
bankName
string | nullNoDestination bank for fiat payout.
accountNumber
string | nullNoMasked destination account number.
createdAt
string (ISO 8601)YesCreation timestamp.
200OK
{
  "data": {
    "id": "7d5a...",
    "merchantId": "4c1e...",
    "amountUsdc": 100,
    "amountNgn": 165000,
    "status": "completed",
    "txHash": "c3d4...",
    "bankName": "GTBank",
    "accountNumber": "****4321",
    "createdAt": "2026-07-18T11:00:00.000Z"
  }
}
Errors
StatusCodeDescription
401UNAUTHORIZEDMissing, malformed or expired JWT.
429RATE_LIMITEDRate limit exceeded — retry after the Retry-After header.
404NOT_FOUNDNo settlement with that id.

Apply a bulk settlement action

POST/api/settlements/actionsAuth required

Approves, rejects, or holds pending settlements for the authenticated merchant. Each settlement is evaluated independently, so a mixed response is possible.

Rate limit: 30 requests / minute per IP (state-changing routes).

Request Headers
HeaderValueRequiredDescription
AuthorizationBearer <jwt>YesJWT issued by an auth endpoint. Required on every authenticated route.
Content-Typeapplication/jsonYesAll request bodies are JSON and NFC-normalized server-side.
Request Parameters

Body

FieldTypeRequiredDescription
action
'approve' | 'reject' | 'hold'YesAction to apply to every requested settlement.
approve
settlementIds
string[]YesIDs of pending settlements to evaluate.
["7d5a...", "8e6b..."]
Code Examples
curl -X POST "https://bettapay-backend.onrender.com/api/settlements/actions" \
  -H "Authorization: Bearer $BETTAPAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"action":"approve","settlementIds":["7d5a...","8e6b...","9f7c..."]}'
Response
FieldTypeRequiredDescription
summary
objectYesAggregate result for the requested settlements.
requested
numberYesNumber of settlement IDs submitted.
succeeded
numberYesNumber of settlements updated successfully.
failed
numberYesNumber of settlements that could not be updated.
results
SettlementActionResult[]YesIndependent result for every submitted settlement ID.
id
string (uuid)YesSubmitted settlement id.
success
booleanYesWhether the requested action succeeded for this settlement.
status
stringYesResulting settlement status.
error
string | nullYesFailure reason, or null when successful.
200OK
{
  "summary": {
    "requested": 3,
    "succeeded": 2,
    "failed": 1
  },
  "results": [
    {
      "id": "7d5a...",
      "success": true,
      "status": "processing",
      "error": null
    },
    {
      "id": "8e6b...",
      "success": true,
      "status": "processing",
      "error": null
    },
    {
      "id": "9f7c...",
      "success": false,
      "status": "pending",
      "error": "Settlement is no longer actionable."
    }
  ]
}
Errors
StatusCodeDescription
401UNAUTHORIZEDMissing, malformed or expired JWT.
429RATE_LIMITEDRate limit exceeded — retry after the Retry-After header.
400VALIDATION_ERRORRequest body failed Zod validation. `details` lists the offending fields.
409IDEMPOTENCY_CONFLICTThe requested action conflicts with the current settlement state.

FX & Rates

The gateway proxies the FX engine so you can read live USDC/fiat rates and price a conversion. These endpoints are public — no JWT required.

Use /api/rates to render a rates board and /api/fx/quote to price a specific amount before creating a payment or settlement. Rates are indicative and may move between the quote and on-chain settlement.

Upstream timeouts surface as 504

Because these routes proxy a downstream engine, an upstream stall returns 504 GATEWAY_TIMEOUT. Treat it as retryable with backoff.

Get exchange rates

GET/api/ratesPublic

Proxies the FX engine and returns current USDC/fiat rates. The primary USDC→NGN rate is surfaced as `usdcNgn`.

Rate limit: 1000 requests / minute per IP (global).

Code Examples
curl -X GET "https://bettapay-backend.onrender.com/api/rates"
Response
FieldTypeRequiredDescription
usdcNgn
numberNoConvenience primary USDC→NGN rate.
rates
Rate[]YesAll available currency pairs.
from
stringYesSource asset code.
to
stringYesTarget currency code.
rate
numberYesUnits of `to` per 1 unit of `from`.
change
numberYes24h percentage change.
trend
'up' | 'down'YesDirection of the 24h change.
200OK
{
  "usdcNgn": 1650.42,
  "rates": [
    {
      "from": "USDC",
      "to": "NGN",
      "rate": 1650.42,
      "change": 0.35,
      "trend": "up"
    },
    {
      "from": "USDC",
      "to": "USD",
      "rate": 1,
      "change": 0,
      "trend": "up"
    }
  ]
}
Errors
StatusCodeDescription
429RATE_LIMITEDRate limit exceeded.
504GATEWAY_TIMEOUTThe upstream FX engine did not respond in time.

Get an FX quote

GET/api/fx/quotePublic

Returns a converted amount for a specific pair and size, proxied from the FX engine.

Rate limit: 1000 requests / minute per IP (global).

Request Parameters

Query

FieldTypeRequiredDescription
from
stringYesSource asset code.
USDC
to
stringYesTarget currency code.
NGN
amount
numberYesAmount of `from` to convert.
50
Code Examples
curl -X GET "https://bettapay-backend.onrender.com/api/fx/quote?from=USDC&to=NGN&amount=50"
Response
FieldTypeRequiredDescription
from
stringYesSource asset code.
to
stringYesTarget currency code.
rate
numberYesApplied conversion rate.
amount
numberYesInput amount.
converted
numberYesResulting amount in `to`.
200OK
{
  "from": "USDC",
  "to": "NGN",
  "rate": 1650.42,
  "amount": 50,
  "converted": 82521
}
Errors
StatusCodeDescription
400VALIDATION_ERRORMissing or invalid from/to/amount.
504GATEWAY_TIMEOUTThe upstream FX engine did not respond in time.

Webhook Events

Subscribe to webhooks to be notified the moment a payment or settlement changes state, instead of polling. Every delivery is signed so you can trust its origin.

Event types

The gateway emits the following events:

EventEmitted when
payment.completedA payment settled on-chain and funds are confirmed.
payment.failedA payment could not be completed.
payment.expiredA pending payment passed its expiry window.
settlement.completedA settlement batch cleared and the payout was sent.
settlement.failedA settlement could not be processed.

Payload structure

Every event shares the same envelope: an id, a type, a createdAt timestamp and the resource in data.

FieldTypeRequiredDescription
id
stringYesUnique event id — use it to deduplicate retries.
evt_9b2f...
type
string (enum)YesThe event type (see the table above).
payment.completed
createdAt
string (ISO 8601)YesWhen the event was emitted.
data
objectYesThe full resource (payment or settlement) at the time of the event.
payment.completed
{
  "id": "evt_9b2f1c...",
  "type": "payment.completed",
  "createdAt": "2026-07-18T10:05:12.000Z",
  "data": {
    "id": "9b2f...",
    "merchantId": "4c1e...",
    "amountUsdc": 25.00,
    "status": "completed",
    "txHash": "a1b2..."
  }
}

Signature verification

Each request carries an X-BettaPay-Signature header of the form sha256=<hex> — an HMAC-SHA256 of the raw request body keyed with your webhook secret. Recompute it and compare in constant time before trusting the payload.

webhook-handler.js
import crypto from 'node:crypto';

// Express example. Read the RAW body — HMAC is computed over the exact bytes.
app.post('/webhooks/bettapay', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.header('X-BettaPay-Signature') ?? '';           // "sha256=<hex>"
  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.BETTAPAY_WEBHOOK_SECRET)
    .update(req.body)                                                    // Buffer of raw bytes
    .digest('hex');

  // Constant-time compare to avoid leaking timing information.
  const ok =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));

  if (!ok) return res.status(400).send('invalid signature');

  const event = JSON.parse(req.body.toString('utf8'));
  // ... handle event.type, dedupe on event.id ...
  res.sendStatus(200);
});

Verify against the raw body

Compute the HMAC over the exact received bytes — parsing and re-serializing the JSON first will change the bytes and break verification.

Retry policy

Respond with a 2xx quickly (do heavy work asynchronously). Any non-2xx or a timeout is retried with exponential backoff for up to 24 hours. Because a delivery may arrive more than once, handlers must be idempotent — dedupe on event.id.

Error Codes

Every failure returns the same envelope with a stable, machine-readable code. Branch on error.code — messages may be reworded, codes will not.

Error envelope
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "amountUsdc must be a positive number",
    "details": [
      { "path": "amountUsdc", "message": "Expected positive number" }
    ]
  }
}
CodeHTTPMeaning
VALIDATION_ERROR400The request body or query failed schema validation.
UNAUTHORIZED401The request carried no valid identity.
FORBIDDEN403You are authenticated, but not allowed to perform this action.
NOT_FOUND404No such resource is visible to you.
REQUEST_TIMEOUT408The request exceeded the gateway request timeout (30s).
IDEMPOTENCY_CONFLICT409The request conflicts with existing state.
RATE_LIMITED429You exceeded the rate limit for this route.
GATEWAY_TIMEOUT504An upstream service did not respond in time.

VALIDATION_ERROR

400

The request body or query failed schema validation.

Common causes

  • A required field is missing or null.
  • A field has the wrong type (e.g. a string where a number is expected).
  • A value is out of range — a non-positive amount, or a string past its max length.
  • An enum field received a value outside the allowed set.

How to resolve

Read error.details — it lists the offending path and reason per field. Fix the payload and retry; retrying unchanged will always fail.

UNAUTHORIZED

401

The request carried no valid identity.

Common causes

  • The Authorization header is missing or is not in the form "Bearer <jwt>".
  • The JWT has expired (exp is in the past).
  • The JWT signature does not verify against the gateway secret.
  • For auth routes: a Google ID token or wallet signature failed verification.

How to resolve

Refresh the token and replay the request once. If the refresh also fails, clear the session and re-authenticate.

FORBIDDEN

403

You are authenticated, but not allowed to perform this action.

Common causes

  • Reading or mutating a merchant, payment or settlement you do not own.
  • A settlement amount below the merchant minimum or above the maximum.
  • A settlement that would exceed the daily limit.

How to resolve

Check the merchantId claim in your JWT matches the resource, and that settlement amounts sit inside your configured limits.

NOT_FOUND

404

No such resource is visible to you.

Common causes

  • The id does not exist.
  • The resource belongs to another merchant, so it is hidden rather than disclosed.
  • The path is misspelled.

How to resolve

Verify the id and the route. Treat 404 as terminal — do not retry.

REQUEST_TIMEOUT

408

The request exceeded the gateway request timeout (30s).

Common causes

  • An unusually large payload or a slow client connection.
  • The gateway was saturated and could not finish in time.

How to resolve

Retry with exponential backoff. Shrink the payload or page the request if it is large.

IDEMPOTENCY_CONFLICT

409

The request conflicts with existing state.

Common causes

  • An Idempotency-Key was reused with a different request body.
  • An illegal payment status transition (for example completed → pending).

How to resolve

Use a fresh Idempotency-Key for a genuinely new request, and reuse the exact original body when retrying. For transitions, re-read the resource and move only forward.

RATE_LIMITED

429

You exceeded the rate limit for this route.

Common causes

  • More than 1000 requests/minute globally from one IP.
  • More than 100 requests/minute against auth endpoints.
  • More than 30 requests/minute against state-changing routes.

How to resolve

Honour the Retry-After response header, then retry with exponential backoff and jitter. Cache reads instead of polling tightly.

GATEWAY_TIMEOUT

504

An upstream service did not respond in time.

Common causes

  • The FX engine was slow or unavailable when pricing rates or quotes.
  • The settlement engine was unreachable while queueing a settlement.

How to resolve

Retry with backoff. This is transient — the request may or may not have been applied, so pair it with an Idempotency-Key where supported.

Retry only what is retryable

408, 429, 500 and 504 are transient — retry with exponential backoff and jitter. 400, 403, 404 and 409 will fail identically until you change the request.

HTTP Status

How the gateway maps outcomes onto HTTP status codes, plus the rate limits and timeouts that govern every route.

StatusNameMeaning
200OKThe request succeeded; the payload is in `data`.
201CreatedA resource was created; the new resource is in `data`.
400Bad RequestSchema validation failed — see `VALIDATION_ERROR`.
401UnauthorizedMissing, malformed or expired credentials.
403ForbiddenAuthenticated but not permitted, or a limit was exceeded.
404Not FoundNo such resource is visible to the caller.
408Request TimeoutThe 30s gateway request timeout elapsed.
409ConflictIdempotency conflict or an illegal state transition.
429Too Many RequestsRate limit exceeded; check `Retry-After`.
500Internal Server ErrorUnexpected server fault. Safe to retry with backoff.
504Gateway TimeoutAn upstream engine did not respond in time.

Rate limits

Limits are applied per IP, per route class:

  • 1000 req/min — global default across the gateway.
  • 100 req/min — auth endpoints (/api/auth/*).
  • 30 req/min — state-changing routes (POST, PATCH, DELETE).

When you exceed a limit the response is 429 with a Retry-After header in seconds. Wait at least that long before retrying.

429 response
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json

{ "error": { "code": "RATE_LIMITED", "message": "Too many requests" } }

Timeouts

The gateway enforces a 30 second request timeout and a 31 second connection timeout. A request that exceeds these returns 408 REQUEST_TIMEOUT; a slow downstream engine surfaces as 504 GATEWAY_TIMEOUT. Set your client timeout slightly above 30s so you observe the gateway's own error rather than aborting first.

Webhook Integration

A practical walkthrough for receiving BettaPay events reliably: verify the signature, acknowledge fast, and make your handler idempotent.

Setting up an endpoint

  1. Expose a public HTTPS route (for example POST /webhooks/bettapay) that accepts JSON.
  2. Register the URL with BettaPay and store the webhook secret as BETTAPAY_WEBHOOK_SECRET — never commit it.
  3. Verify every delivery's signature before acting on it.

Your endpoint must be publicly reachable and unauthenticated

Do not put the webhook route behind your normal session auth — the gateway has no session. The HMAC signature is the authentication.

A production-ready handler

The handler below verifies the signature over the raw bytes, acknowledges immediately, deduplicates on event.id, and ignores unknown event types so new events never break your integration.

webhooks.js
import express from 'express';
import crypto from 'node:crypto';

const app = express();
const seen = new Set(); // use Redis/DB in production

app.post(
  '/webhooks/bettapay',
  express.raw({ type: 'application/json' }),   // keep the raw bytes
  (req, res) => {
    const signature = req.header('X-BettaPay-Signature') ?? '';
    const expected =
      'sha256=' +
      crypto
        .createHmac('sha256', process.env.BETTAPAY_WEBHOOK_SECRET)
        .update(req.body)
        .digest('hex');

    if (
      signature.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
    ) {
      return res.status(400).send('invalid signature');
    }

    const event = JSON.parse(req.body.toString('utf8'));

    // 1. Acknowledge immediately — do slow work out of band.
    res.sendStatus(200);

    // 2. Deduplicate: the same event may be delivered more than once.
    if (seen.has(event.id)) return;
    seen.add(event.id);

    // 3. Handle it.
    switch (event.type) {
      case 'payment.completed':
        void fulfillOrder(event.data);
        break;
      case 'settlement.completed':
        void recordPayout(event.data);
        break;
      default:
        break; // ignore unknown types rather than failing
    }
  },
);

Reliability rules

  • Acknowledge within seconds. Return 2xx first, then process. Slow handlers get retried and duplicated.
  • Be idempotent. Deliveries can repeat — key your side effects on event.id.
  • Tolerate out-of-order delivery. Compare against the resource's current status rather than assuming sequence.
  • Ignore unknown types. New event types may be added; unknown ones should be a no-op, not a 500.
  • Never trust the body before verifying. Parse only after the HMAC check passes.

Testing locally

Point the gateway at a tunnel so you can iterate against your machine, then trigger a real event by completing a testnet payment (see Testing with Testnet).

Terminal
# Expose your local handler so the gateway can reach it while developing
npx localtunnel --port 3000
# or
ngrok http 3000

The full event catalog and payload schema live in Webhook Events.

Idempotency Keys

An idempotency key makes a create request safe to retry. Without one, a dropped response can turn a single checkout into two charges.

How they work

Send an Idempotency-Key header on POST /api/payments. The gateway stores the key alongside the result of the first successful request. Any later request with the same key returns that original result instead of creating a new payment.

  • Scope: payment creation. Reads are naturally idempotent and need no key.
  • Retention: keys are remembered for 24 hours, then expire.
  • Length: up to 255 characters.
  • Format: any unique string — a UUID v4 is the safest default.

Conflicts

Reusing a key with a different body returns 409 IDEMPOTENCY_CONFLICT. That is a guard, not a bug: it means two different operations were given the same identity.

Generate the key per operation, not per attempt

Create the key once for a logical operation and reuse it across retries. Generating a new key inside the retry loop defeats the entire mechanism — each retry becomes a new payment.

Safe retry pattern

The key is generated once, the body never changes between attempts, and only transient statuses are retried with exponential backoff plus jitter.

create-payment.js
import crypto from 'node:crypto';

async function createPaymentSafely(body, token) {
  // Generate the key ONCE, outside the retry loop, so every attempt
  // of this logical operation carries the same key.
  const idempotencyKey = crypto.randomUUID();

  for (let attempt = 0; attempt < 4; attempt++) {
    const res = await fetch('https://bettapay-backend.onrender.com/api/payments', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
        'Idempotency-Key': idempotencyKey,
      },
      body: JSON.stringify(body),   // identical body on every attempt
    });

    if (res.ok) return (await res.json()).data;

    // Only transient failures are worth retrying.
    if (![408, 429, 500, 502, 503, 504].includes(res.status)) {
      throw new Error(`Create failed: ${res.status}`);
    }

    const backoff = 2 ** attempt * 250 + Math.random() * 250; // jitter
    await new Promise((r) => setTimeout(r, backoff));
  }

  throw new Error('Create payment: retries exhausted');
}

For the full list of which statuses are safe to retry, see Error Codes.

Testing with Testnet

Run the entire payment flow against Stellar Testnet with free test assets before you touch real funds.

1. Point Freighter at Testnet

  1. Install the Freighter browser extension and create or import an account.
  2. Open Freighter → Settings → Network and switch to Test Net.
  3. Make sure your app is configured for testnet too, so the network passphrase you sign with matches (Test SDF Network ; September 2015).

Network mismatch is the most common testnet bug

If the wallet is on Testnet and the app expects Mainnet (or vice versa), signatures verify against the wrong passphrase and auth fails with a confusing error. Keep both on Testnet.

2. Fund the account and get test USDC

Friendbot funds any new testnet account with test XLM so it can pay transaction fees:

Terminal
# Fund a brand-new testnet account with test XLM (for fees)
curl "https://friendbot.stellar.org?addr=$STELLAR_ADDRESS"

To hold test USDC the account needs a trustline to the test issuer, after which the issuer (or a testnet faucet) can send you a balance:

add-trustline.ts
import {
  Horizon, TransactionBuilder, Operation, Asset, Networks,
} from '@stellar/stellar-sdk';

const server = new Horizon.Server('https://horizon-testnet.stellar.org');

// Test USDC on testnet is issued by a test issuer account.
const USDC = new Asset('USDC', process.env.TESTNET_USDC_ISSUER);

const account = await server.loadAccount(address);
const tx = new TransactionBuilder(account, {
  fee: await server.fetchBaseFee().then(String),
  networkPassphrase: Networks.TESTNET,
})
  .addOperation(Operation.changeTrust({ asset: USDC }))
  .setTimeout(60)
  .build();

// Sign with Freighter, then submit
const signedXdr = await signTransaction(tx.toXDR(), {
  networkPassphrase: Networks.TESTNET,
});
await server.submitTransaction(
  TransactionBuilder.fromXDR(signedXdr, Networks.TESTNET),
);

3. Create a test payment

Authenticate as usual (wallet auth signs with the same testnet passphrase), then create a payment exactly as you would in production — see Quickstart. Open the returned url and pay it from your Freighter testnet account.

4. Verify on Horizon

Once the payment reports completed, it carries a txHash. Confirm it independently against the public testnet Horizon instance:

Terminal
# Look up the settling transaction returned on the payment
curl "https://horizon-testnet.stellar.org/transactions/$TX_HASH"

# Inspect the individual payment operations it contains
curl "https://horizon-testnet.stellar.org/transactions/$TX_HASH/operations"

The operations response shows the USDC transfer, its amount, and the source and destination accounts — the ground truth behind the payment record.

Testnet is periodically reset

Stellar Testnet data is wiped from time to time. Treat testnet accounts, balances and history as disposable, and never reuse a testnet key on Mainnet.