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:
https://bettapay-backend.onrender.comThe 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/jsonon 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.
{
"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).
{
"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 oferror 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.
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
WhenNEXT_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
/api/auth/googlePublicVerifies 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
| Header | Value | Required | Description |
|---|---|---|---|
Content-Type | application/json | Yes | All request bodies are JSON and NFC-normalized server-side. |
Request Parameters
Body
| Field | Type | Required | Description |
|---|---|---|---|
idToken | string | Yes | The 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..."}'const res = await fetch('https://bettapay-backend.onrender.com/api/auth/google', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
"idToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6..."
}),
});
const { data } = await res.json();
console.log(data);import axios from 'axios';
const { data } = await axios.post('https://bettapay-backend.onrender.com/api/auth/google', {
"idToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6..."
});
console.log(data.data);import requests
res = requests.post(
"https://bettapay-backend.onrender.com/api/auth/google",
json={
"idToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6..."
},
)
res.raise_for_status()
print(res.json()["data"])import { useEffect, useState } from 'react';
function useBettaPay(token) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch('https://bettapay-backend.onrender.com/api/auth/google', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({"idToken":"eyJhbGciOiJSUzI1NiIsImtpZCI6..."}),
signal: controller.signal,
})
.then((r) => r.json())
.then((json) => setData(json.data))
.catch(() => {});
return () => controller.abort();
}, [token]);
return data;
}Response
| Field | Type | Required | Description |
|---|---|---|---|
token | string | Yes | Signed BettaPay JWT (base64url encoded). |
{
"token": "eyJhbGciOiJIUzI1NiJ9.eyJtZXJjaGFudElkIjoi..."
}Errors
| Status | Code | Description |
|---|---|---|
400 | VALIDATION_ERROR | Request body failed Zod validation. `details` lists the offending fields. |
401 | UNAUTHORIZED | The Google ID token could not be verified. |
429 | RATE_LIMITED | Too 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.
- Request a challenge for the connected address.
- Sign the challenge string with Freighter (
signMessage). - POST the address, challenge and signature to verify — receive a JWT.
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
/api/auth/wallet/challengePublicReturns 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
| Field | Type | Required | Description |
|---|---|---|---|
address | string | Yes | The 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"const res = await fetch('https://bettapay-backend.onrender.com/api/auth/wallet/challenge?address=GAXY...7QF', {
method: 'GET',
});
const { data } = await res.json();
console.log(data);import axios from 'axios';
const { data } = await axios.get('https://bettapay-backend.onrender.com/api/auth/wallet/challenge?address=GAXY...7QF');
console.log(data.data);import requests
res = requests.get(
"https://bettapay-backend.onrender.com/api/auth/wallet/challenge?address=GAXY...7QF",
)
res.raise_for_status()
print(res.json()["data"])import { useEffect, useState } from 'react';
function useBettaPay(token) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch('https://bettapay-backend.onrender.com/api/auth/wallet/challenge?address=GAXY...7QF', {
method: 'GET',
signal: controller.signal,
})
.then((r) => r.json())
.then((json) => setData(json.data))
.catch(() => {});
return () => controller.abort();
}, [token]);
return data;
}Response
| Field | Type | Required | Description |
|---|---|---|---|
challenge | string | Yes | Random nonce to sign with Freighter. |
{
"challenge": "bettapay-auth:GAXY...7QF:1721300000:9f3c2a..."
}Errors
| Status | Code | Description |
|---|---|---|
400 | VALIDATION_ERROR | Missing or malformed `address` query param. |
429 | RATE_LIMITED | Too many auth attempts. |
Verify a signed challenge
/api/auth/wallet/verifyPublicVerifies 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
| Header | Value | Required | Description |
|---|---|---|---|
Content-Type | application/json | Yes | All request bodies are JSON and NFC-normalized server-side. |
Request Parameters
Body
| Field | Type | Required | Description |
|---|---|---|---|
address | string | Yes | Stellar public key that signed the challenge.GAXY...7QF |
challenge | string | Yes | The exact challenge returned by the challenge endpoint. |
signature | string | Yes | Base64 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...=="}'const res = await fetch('https://bettapay-backend.onrender.com/api/auth/wallet/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
"address": "GAXY...7QF",
"challenge": "bettapay-auth:GAXY...7QF:1721300000:9f3c2a...",
"signature": "MEUCIQ...=="
}),
});
const { data } = await res.json();
console.log(data);import axios from 'axios';
const { data } = await axios.post('https://bettapay-backend.onrender.com/api/auth/wallet/verify', {
"address": "GAXY...7QF",
"challenge": "bettapay-auth:GAXY...7QF:1721300000:9f3c2a...",
"signature": "MEUCIQ...=="
});
console.log(data.data);import requests
res = requests.post(
"https://bettapay-backend.onrender.com/api/auth/wallet/verify",
json={
"address": "GAXY...7QF",
"challenge": "bettapay-auth:GAXY...7QF:1721300000:9f3c2a...",
"signature": "MEUCIQ...=="
},
)
res.raise_for_status()
print(res.json()["data"])import { useEffect, useState } from 'react';
function useBettaPay(token) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch('https://bettapay-backend.onrender.com/api/auth/wallet/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({"address":"GAXY...7QF","challenge":"bettapay-auth:GAXY...7QF:1721300000:9f3c2a...","signature":"MEUCIQ...=="}),
signal: controller.signal,
})
.then((r) => r.json())
.then((json) => setData(json.data))
.catch(() => {});
return () => controller.abort();
}, [token]);
return data;
}Response
| Field | Type | Required | Description |
|---|---|---|---|
token | string | Yes | Signed BettaPay JWT (base64url encoded). |
{
"token": "eyJhbGciOiJIUzI1NiJ9.eyJtZXJjaGFudElkIjoi..."
}Errors
| Status | Code | Description |
|---|---|---|
400 | VALIDATION_ERROR | Request body failed Zod validation. `details` lists the offending fields. |
401 | UNAUTHORIZED | Signature did not verify against the address, or the challenge expired. |
429 | RATE_LIMITED | Too 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.// 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:
| Field | Type | Required | Description |
|---|---|---|---|
merchantId | string (uuid) | Yes | The merchant this token acts as. Used to scope every authenticated route. |
ownerId | string | Yes | Identity of the principal that owns the merchant (email for Google, address for wallet). |
role | 'merchant' | 'admin' | Yes | Authorization role. |
iat | number (unix) | Yes | Issued-at time. |
exp | number (unix) | Yes | Expiry 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.
# 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.
# 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.
# 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.
# 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
/api/merchantsAuth requiredRegisters a merchant profile owned by the authenticated principal.
Rate limit: 30 requests / minute per IP (state-changing routes).
Request Headers
| Header | Value | Required | Description |
|---|---|---|---|
Authorization | Bearer <jwt> | Yes | JWT issued by an auth endpoint. Required on every authenticated route. |
Content-Type | application/json | Yes | All request bodies are JSON and NFC-normalized server-side. |
Request Parameters
Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Business display name.Acme Stores |
email | string | Yes | Contact email for settlement notices.ops@acme.com |
country | string (ISO 3166-1 alpha-2) | No | Merchant 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"}'const res = await fetch('https://bettapay-backend.onrender.com/api/merchants', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.BETTAPAY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"name": "Acme Stores",
"email": "ops@acme.com",
"country": "NG"
}),
});
const { data } = await res.json();
console.log(data);import axios from 'axios';
const { data } = await axios.post('https://bettapay-backend.onrender.com/api/merchants', {
"name": "Acme Stores",
"email": "ops@acme.com",
"country": "NG"
}, {
headers: {
Authorization: `Bearer ${process.env.BETTAPAY_TOKEN}`,
},
});
console.log(data.data);import requests
res = requests.post(
"https://bettapay-backend.onrender.com/api/merchants",
headers={
"Authorization": f"Bearer {token}",
},
json={
"name": "Acme Stores",
"email": "ops@acme.com",
"country": "NG"
},
)
res.raise_for_status()
print(res.json()["data"])import { useEffect, useState } from 'react';
function useBettaPay(token) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch('https://bettapay-backend.onrender.com/api/merchants', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({"name":"Acme Stores","email":"ops@acme.com","country":"NG"}),
signal: controller.signal,
})
.then((r) => r.json())
.then((json) => setData(json.data))
.catch(() => {});
return () => controller.abort();
}, [token]);
return data;
}Response
| Field | Type | Required | Description |
|---|---|---|---|
data | object | Yes | The created merchant. |
id | string (uuid) | Yes | Merchant id. |
name | string | Yes | Business display name. |
email | string | Yes | Contact email. |
createdAt | string (ISO 8601) | Yes | Creation timestamp. |
{
"data": {
"id": "4c1e...",
"name": "Acme Stores",
"email": "ops@acme.com",
"createdAt": "2026-07-18T10:00:00.000Z"
}
}Errors
| Status | Code | Description |
|---|---|---|
401 | UNAUTHORIZED | Missing, malformed or expired JWT. |
429 | RATE_LIMITED | Rate limit exceeded — retry after the Retry-After header. |
400 | VALIDATION_ERROR | Request body failed Zod validation. `details` lists the offending fields. |
Get a merchant
/api/merchants/:idAuth requiredFetches a merchant profile by id. Callers may only read merchants they own.
Rate limit: 1000 requests / minute per IP (global).
Request Headers
| Header | Value | Required | Description |
|---|---|---|---|
Authorization | Bearer <jwt> | Yes | JWT issued by an auth endpoint. Required on every authenticated route. |
Request Parameters
Path
| Field | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes | Merchant 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"const res = await fetch('https://bettapay-backend.onrender.com/api/merchants/4c1e...', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.BETTAPAY_TOKEN}`,
},
});
const { data } = await res.json();
console.log(data);import axios from 'axios';
const { data } = await axios.get('https://bettapay-backend.onrender.com/api/merchants/4c1e...', {
headers: {
Authorization: `Bearer ${process.env.BETTAPAY_TOKEN}`,
},
});
console.log(data.data);import requests
res = requests.get(
"https://bettapay-backend.onrender.com/api/merchants/4c1e...",
headers={
"Authorization": f"Bearer {token}",
},
)
res.raise_for_status()
print(res.json()["data"])import { useEffect, useState } from 'react';
function useBettaPay(token) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch('https://bettapay-backend.onrender.com/api/merchants/4c1e...', {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
signal: controller.signal,
})
.then((r) => r.json())
.then((json) => setData(json.data))
.catch(() => {});
return () => controller.abort();
}, [token]);
return data;
}Response
| Field | Type | Required | Description |
|---|---|---|---|
data | object | Yes | The merchant profile. |
id | string (uuid) | Yes | Merchant id. |
name | string | Yes | Business display name. |
email | string | Yes | Contact email. |
createdAt | string (ISO 8601) | Yes | Creation timestamp. |
{
"data": {
"id": "4c1e...",
"name": "Acme Stores",
"email": "ops@acme.com",
"createdAt": "2026-07-18T10:00:00.000Z"
}
}Errors
| Status | Code | Description |
|---|---|---|
401 | UNAUTHORIZED | Missing, malformed or expired JWT. |
429 | RATE_LIMITED | Rate limit exceeded — retry after the Retry-After header. |
403 | FORBIDDEN | The JWT does not own this merchant. |
404 | NOT_FOUND | No merchant with that id. |
Update a merchant
/api/merchants/:idAuth requiredPartially updates a merchant profile. Only provided fields are changed.
Rate limit: 30 requests / minute per IP (state-changing routes).
Request Headers
| Header | Value | Required | Description |
|---|---|---|---|
Authorization | Bearer <jwt> | Yes | JWT issued by an auth endpoint. Required on every authenticated route. |
Content-Type | application/json | Yes | All request bodies are JSON and NFC-normalized server-side. |
Request Parameters
Path
| Field | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes | Merchant id.4c1e... |
Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | No | New business display name.Acme Global |
email | string | No | New 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"}'const res = await fetch('https://bettapay-backend.onrender.com/api/merchants/4c1e...', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.BETTAPAY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"name": "Acme Global"
}),
});
const { data } = await res.json();
console.log(data);import axios from 'axios';
const { data } = await axios.patch('https://bettapay-backend.onrender.com/api/merchants/4c1e...', {
"name": "Acme Global"
}, {
headers: {
Authorization: `Bearer ${process.env.BETTAPAY_TOKEN}`,
},
});
console.log(data.data);import requests
res = requests.patch(
"https://bettapay-backend.onrender.com/api/merchants/4c1e...",
headers={
"Authorization": f"Bearer {token}",
},
json={
"name": "Acme Global"
},
)
res.raise_for_status()
print(res.json()["data"])import { useEffect, useState } from 'react';
function useBettaPay(token) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch('https://bettapay-backend.onrender.com/api/merchants/4c1e...', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({"name":"Acme Global"}),
signal: controller.signal,
})
.then((r) => r.json())
.then((json) => setData(json.data))
.catch(() => {});
return () => controller.abort();
}, [token]);
return data;
}Response
| Field | Type | Required | Description |
|---|---|---|---|
data | object | Yes | The updated merchant. |
{
"data": {
"id": "4c1e...",
"name": "Acme Global",
"email": "ops@acme.com",
"createdAt": "2026-07-18T10:00:00.000Z"
}
}Errors
| Status | Code | Description |
|---|---|---|
401 | UNAUTHORIZED | Missing, malformed or expired JWT. |
429 | RATE_LIMITED | Rate limit exceeded — retry after the Retry-After header. |
400 | VALIDATION_ERROR | Request body failed Zod validation. `details` lists the offending fields. |
403 | FORBIDDEN | The JWT does not own this merchant. |
404 | NOT_FOUND | No 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.
Use idempotency keys when creating payments
Network retries are inevitable. Send anIdempotency-Key on POST /api/payments so a retried create returns the original payment instead of a duplicate charge. See Idempotency Keys.Create a payment
/api/paymentsAuth requiredCreates 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
| Header | Value | Required | Description |
|---|---|---|---|
Authorization | Bearer <jwt> | Yes | JWT issued by an auth endpoint. Required on every authenticated route. |
Content-Type | application/json | Yes | All request bodies are JSON and NFC-normalized server-side. |
Idempotency-Key | <unique-string> | No | Optional but recommended for POST /api/payments. Max 255 chars, cached for 24h so retries never double-charge. |
Request Parameters
Body
| Field | Type | Required | Description |
|---|---|---|---|
amountUsdc | number | Yes | Amount to charge in USDC. Must be positive.25.00 |
currency | string | No | Display currency for the link. Defaults to USDC.USDC |
source | string | No | Free-form origin label.checkout |
metadata | object | No | Arbitrary 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"}'const res = await fetch('https://bettapay-backend.onrender.com/api/payments', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.BETTAPAY_TOKEN}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
"amountUsdc": 25,
"currency": "USDC",
"source": "checkout"
}),
});
const { data } = await res.json();
console.log(data);import axios from 'axios';
const { data } = await axios.post('https://bettapay-backend.onrender.com/api/payments', {
"amountUsdc": 25,
"currency": "USDC",
"source": "checkout"
}, {
headers: {
Authorization: `Bearer ${process.env.BETTAPAY_TOKEN}`,
'Idempotency-Key': crypto.randomUUID(),
},
});
console.log(data.data);import requests
import uuid
res = requests.post(
"https://bettapay-backend.onrender.com/api/payments",
headers={
"Authorization": f"Bearer {token}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"amountUsdc": 25,
"currency": "USDC",
"source": "checkout"
},
)
res.raise_for_status()
print(res.json()["data"])import { useEffect, useState } from 'react';
function useBettaPay(token) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch('https://bettapay-backend.onrender.com/api/payments', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({"amountUsdc":25,"currency":"USDC","source":"checkout"}),
signal: controller.signal,
})
.then((r) => r.json())
.then((json) => setData(json.data))
.catch(() => {});
return () => controller.abort();
}, [token]);
return data;
}Response
| Field | Type | Required | Description |
|---|---|---|---|
data | object | Yes | The created payment. |
id | string (uuid) | Yes | Payment id. |
merchantId | string (uuid) | Yes | Owning merchant. |
amountUsdc | number | Yes | Amount charged in USDC. |
amountNgn | number | null | No | Fiat equivalent at capture time. |
fxRate | number | null | No | USDC→NGN rate applied. |
status | string | Yes | One of pending, processing, completed, failed, expired. |
txHash | string | null | No | Stellar transaction hash once settled on-chain. |
payerAddress | string | null | No | Stellar address that paid. |
source | string | null | No | Origin of the payment (e.g. link, api). |
url | string | No | Hosted payment link URL, when applicable. |
createdAt | string (ISO 8601) | Yes | Creation timestamp. |
{
"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
| Status | Code | Description |
|---|---|---|
401 | UNAUTHORIZED | Missing, malformed or expired JWT. |
429 | RATE_LIMITED | Rate limit exceeded — retry after the Retry-After header. |
400 | VALIDATION_ERROR | Request body failed Zod validation. `details` lists the offending fields. |
409 | IDEMPOTENCY_CONFLICT | The Idempotency-Key was reused with a different body. |
List payments
/api/paymentsAuth requiredReturns payments belonging to the authenticated merchant, newest first.
Rate limit: 1000 requests / minute per IP (global).
Request Headers
| Header | Value | Required | Description |
|---|---|---|---|
Authorization | Bearer <jwt> | Yes | JWT issued by an auth endpoint. Required on every authenticated route. |
Request Parameters
Query
| Field | Type | Required | Description |
|---|---|---|---|
status | string | No | Filter by payment status.completed |
limit | number | No | Page 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"const res = await fetch('https://bettapay-backend.onrender.com/api/payments?status=completed&limit=50', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.BETTAPAY_TOKEN}`,
},
});
const { data } = await res.json();
console.log(data);import axios from 'axios';
const { data } = await axios.get('https://bettapay-backend.onrender.com/api/payments?status=completed&limit=50', {
headers: {
Authorization: `Bearer ${process.env.BETTAPAY_TOKEN}`,
},
});
console.log(data.data);import requests
res = requests.get(
"https://bettapay-backend.onrender.com/api/payments?status=completed&limit=50",
headers={
"Authorization": f"Bearer {token}",
},
)
res.raise_for_status()
print(res.json()["data"])import { useEffect, useState } from 'react';
function useBettaPay(token) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch('https://bettapay-backend.onrender.com/api/payments?status=completed&limit=50', {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
signal: controller.signal,
})
.then((r) => r.json())
.then((json) => setData(json.data))
.catch(() => {});
return () => controller.abort();
}, [token]);
return data;
}Response
| Field | Type | Required | Description |
|---|---|---|---|
data | Payment[] | Yes | Array of payments. |
id | string (uuid) | Yes | Payment id. |
merchantId | string (uuid) | Yes | Owning merchant. |
amountUsdc | number | Yes | Amount charged in USDC. |
amountNgn | number | null | No | Fiat equivalent at capture time. |
fxRate | number | null | No | USDC→NGN rate applied. |
status | string | Yes | One of pending, processing, completed, failed, expired. |
txHash | string | null | No | Stellar transaction hash once settled on-chain. |
payerAddress | string | null | No | Stellar address that paid. |
source | string | null | No | Origin of the payment (e.g. link, api). |
url | string | No | Hosted payment link URL, when applicable. |
createdAt | string (ISO 8601) | Yes | Creation timestamp. |
{
"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
| Status | Code | Description |
|---|---|---|
401 | UNAUTHORIZED | Missing, malformed or expired JWT. |
429 | RATE_LIMITED | Rate limit exceeded — retry after the Retry-After header. |
Get a payment
/api/payments/:idAuth requiredFetches a single payment by id.
Rate limit: 1000 requests / minute per IP (global).
Request Headers
| Header | Value | Required | Description |
|---|---|---|---|
Authorization | Bearer <jwt> | Yes | JWT issued by an auth endpoint. Required on every authenticated route. |
Request Parameters
Path
| Field | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes | Payment id.9b2f... |
Code Examples
curl -X GET "https://bettapay-backend.onrender.com/api/payments/9b2f..." \
-H "Authorization: Bearer $BETTAPAY_TOKEN"const res = await fetch('https://bettapay-backend.onrender.com/api/payments/9b2f...', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.BETTAPAY_TOKEN}`,
},
});
const { data } = await res.json();
console.log(data);import axios from 'axios';
const { data } = await axios.get('https://bettapay-backend.onrender.com/api/payments/9b2f...', {
headers: {
Authorization: `Bearer ${process.env.BETTAPAY_TOKEN}`,
},
});
console.log(data.data);import requests
res = requests.get(
"https://bettapay-backend.onrender.com/api/payments/9b2f...",
headers={
"Authorization": f"Bearer {token}",
},
)
res.raise_for_status()
print(res.json()["data"])import { useEffect, useState } from 'react';
function useBettaPay(token) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch('https://bettapay-backend.onrender.com/api/payments/9b2f...', {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
signal: controller.signal,
})
.then((r) => r.json())
.then((json) => setData(json.data))
.catch(() => {});
return () => controller.abort();
}, [token]);
return data;
}Response
| Field | Type | Required | Description |
|---|---|---|---|
data | object | Yes | The payment. |
id | string (uuid) | Yes | Payment id. |
merchantId | string (uuid) | Yes | Owning merchant. |
amountUsdc | number | Yes | Amount charged in USDC. |
amountNgn | number | null | No | Fiat equivalent at capture time. |
fxRate | number | null | No | USDC→NGN rate applied. |
status | string | Yes | One of pending, processing, completed, failed, expired. |
txHash | string | null | No | Stellar transaction hash once settled on-chain. |
payerAddress | string | null | No | Stellar address that paid. |
source | string | null | No | Origin of the payment (e.g. link, api). |
url | string | No | Hosted payment link URL, when applicable. |
createdAt | string (ISO 8601) | Yes | Creation timestamp. |
{
"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
| Status | Code | Description |
|---|---|---|
401 | UNAUTHORIZED | Missing, malformed or expired JWT. |
429 | RATE_LIMITED | Rate limit exceeded — retry after the Retry-After header. |
404 | NOT_FOUND | No payment with that id. |
Update payment status
/api/payments/:idAuth requiredTransitions 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
| Header | Value | Required | Description |
|---|---|---|---|
Authorization | Bearer <jwt> | Yes | JWT issued by an auth endpoint. Required on every authenticated route. |
Content-Type | application/json | Yes | All request bodies are JSON and NFC-normalized server-side. |
Request Parameters
Path
| Field | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes | Payment id.9b2f... |
Body
| Field | Type | Required | Description |
|---|---|---|---|
status | string (enum) | Yes | Target 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"}'const res = await fetch('https://bettapay-backend.onrender.com/api/payments/9b2f...', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.BETTAPAY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"status": "completed"
}),
});
const { data } = await res.json();
console.log(data);import axios from 'axios';
const { data } = await axios.patch('https://bettapay-backend.onrender.com/api/payments/9b2f...', {
"status": "completed"
}, {
headers: {
Authorization: `Bearer ${process.env.BETTAPAY_TOKEN}`,
},
});
console.log(data.data);import requests
res = requests.patch(
"https://bettapay-backend.onrender.com/api/payments/9b2f...",
headers={
"Authorization": f"Bearer {token}",
},
json={
"status": "completed"
},
)
res.raise_for_status()
print(res.json()["data"])import { useEffect, useState } from 'react';
function useBettaPay(token) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch('https://bettapay-backend.onrender.com/api/payments/9b2f...', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({"status":"completed"}),
signal: controller.signal,
})
.then((r) => r.json())
.then((json) => setData(json.data))
.catch(() => {});
return () => controller.abort();
}, [token]);
return data;
}Response
| Field | Type | Required | Description |
|---|---|---|---|
data | object | Yes | The updated payment. |
id | string (uuid) | Yes | Payment id. |
merchantId | string (uuid) | Yes | Owning merchant. |
amountUsdc | number | Yes | Amount charged in USDC. |
amountNgn | number | null | No | Fiat equivalent at capture time. |
fxRate | number | null | No | USDC→NGN rate applied. |
status | string | Yes | One of pending, processing, completed, failed, expired. |
txHash | string | null | No | Stellar transaction hash once settled on-chain. |
payerAddress | string | null | No | Stellar address that paid. |
source | string | null | No | Origin of the payment (e.g. link, api). |
url | string | No | Hosted payment link URL, when applicable. |
createdAt | string (ISO 8601) | Yes | Creation timestamp. |
{
"data": {
"id": "9b2f...",
"merchantId": "4c1e...",
"amountUsdc": 25,
"status": "completed",
"txHash": "a1b2...",
"createdAt": "2026-07-18T10:05:00.000Z"
}
}Errors
| Status | Code | Description |
|---|---|---|
401 | UNAUTHORIZED | Missing, malformed or expired JWT. |
429 | RATE_LIMITED | Rate limit exceeded — retry after the Retry-After header. |
400 | VALIDATION_ERROR | Request body failed Zod validation. `details` lists the offending fields. |
404 | NOT_FOUND | No payment with that id. |
409 | IDEMPOTENCY_CONFLICT | Illegal 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 aspending and transitions to processing then completed once the batch clears on-chain. Exceeding a limit returns 403 FORBIDDEN.Create a settlement
/api/settlementsAuth requiredQueues 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
| Header | Value | Required | Description |
|---|---|---|---|
Authorization | Bearer <jwt> | Yes | JWT issued by an auth endpoint. Required on every authenticated route. |
Content-Type | application/json | Yes | All request bodies are JSON and NFC-normalized server-side. |
Request Parameters
Body
| Field | Type | Required | Description |
|---|---|---|---|
amountUsdc | number | Yes | Amount to settle. Must respect the merchant min/max.100.00 |
destination | string | No | Payout 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"}'const res = await fetch('https://bettapay-backend.onrender.com/api/settlements', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.BETTAPAY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"amountUsdc": 100,
"destination": "bank_acct_123"
}),
});
const { data } = await res.json();
console.log(data);import axios from 'axios';
const { data } = await axios.post('https://bettapay-backend.onrender.com/api/settlements', {
"amountUsdc": 100,
"destination": "bank_acct_123"
}, {
headers: {
Authorization: `Bearer ${process.env.BETTAPAY_TOKEN}`,
},
});
console.log(data.data);import requests
res = requests.post(
"https://bettapay-backend.onrender.com/api/settlements",
headers={
"Authorization": f"Bearer {token}",
},
json={
"amountUsdc": 100,
"destination": "bank_acct_123"
},
)
res.raise_for_status()
print(res.json()["data"])import { useEffect, useState } from 'react';
function useBettaPay(token) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch('https://bettapay-backend.onrender.com/api/settlements', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({"amountUsdc":100,"destination":"bank_acct_123"}),
signal: controller.signal,
})
.then((r) => r.json())
.then((json) => setData(json.data))
.catch(() => {});
return () => controller.abort();
}, [token]);
return data;
}Response
| Field | Type | Required | Description |
|---|---|---|---|
data | object | Yes | The queued settlement. |
id | string (uuid) | Yes | Settlement id. |
merchantId | string (uuid) | Yes | Owning merchant. |
amountUsdc | number | Yes | Amount settled in USDC. |
amountNgn | number | null | No | Fiat amount paid out. |
status | string | Yes | One of pending, processing, completed, failed. |
effectiveRule | object | Yes | The resolved settlement rule applied to this settlement: merchant, then default, then governance. |
feeBps | number | Yes | Settlement fee in basis points. |
autoSettle | boolean | Yes | Whether automatic settlement is enabled. |
delay | number | Yes | Settlement delay in minutes. |
source | 'merchant' | 'default' | 'governance' | Yes | Rule level that supplied the effective values. |
txHash | string | null | No | On-chain settlement transaction hash. |
bankName | string | null | No | Destination bank for fiat payout. |
accountNumber | string | null | No | Masked destination account number. |
createdAt | string (ISO 8601) | Yes | Creation timestamp. |
{
"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
| Status | Code | Description |
|---|---|---|
401 | UNAUTHORIZED | Missing, malformed or expired JWT. |
429 | RATE_LIMITED | Rate limit exceeded — retry after the Retry-After header. |
400 | VALIDATION_ERROR | Request body failed Zod validation. `details` lists the offending fields. |
403 | FORBIDDEN | Amount is below the min, above the max, or exceeds the daily limit. |
List settlements
/api/settlementsAuth requiredReturns settlements belonging to the authenticated merchant, newest first.
Rate limit: 1000 requests / minute per IP (global).
Request Headers
| Header | Value | Required | Description |
|---|---|---|---|
Authorization | Bearer <jwt> | Yes | JWT 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"const res = await fetch('https://bettapay-backend.onrender.com/api/settlements', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.BETTAPAY_TOKEN}`,
},
});
const { data } = await res.json();
console.log(data);import axios from 'axios';
const { data } = await axios.get('https://bettapay-backend.onrender.com/api/settlements', {
headers: {
Authorization: `Bearer ${process.env.BETTAPAY_TOKEN}`,
},
});
console.log(data.data);import requests
res = requests.get(
"https://bettapay-backend.onrender.com/api/settlements",
headers={
"Authorization": f"Bearer {token}",
},
)
res.raise_for_status()
print(res.json()["data"])import { useEffect, useState } from 'react';
function useBettaPay(token) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch('https://bettapay-backend.onrender.com/api/settlements', {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
signal: controller.signal,
})
.then((r) => r.json())
.then((json) => setData(json.data))
.catch(() => {});
return () => controller.abort();
}, [token]);
return data;
}Response
| Field | Type | Required | Description |
|---|---|---|---|
data | Settlement[] | Yes | Array of settlements. |
id | string (uuid) | Yes | Settlement id. |
merchantId | string (uuid) | Yes | Owning merchant. |
amountUsdc | number | Yes | Amount settled in USDC. |
amountNgn | number | null | No | Fiat amount paid out. |
status | string | Yes | One of pending, processing, completed, failed. |
effectiveRule | object | Yes | The resolved settlement rule applied to this settlement: merchant, then default, then governance. |
feeBps | number | Yes | Settlement fee in basis points. |
autoSettle | boolean | Yes | Whether automatic settlement is enabled. |
delay | number | Yes | Settlement delay in minutes. |
source | 'merchant' | 'default' | 'governance' | Yes | Rule level that supplied the effective values. |
txHash | string | null | No | On-chain settlement transaction hash. |
bankName | string | null | No | Destination bank for fiat payout. |
accountNumber | string | null | No | Masked destination account number. |
createdAt | string (ISO 8601) | Yes | Creation timestamp. |
{
"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
| Status | Code | Description |
|---|---|---|
401 | UNAUTHORIZED | Missing, malformed or expired JWT. |
429 | RATE_LIMITED | Rate limit exceeded — retry after the Retry-After header. |
Get a settlement
/api/settlements/:idAuth requiredFetches a single settlement by id.
Rate limit: 1000 requests / minute per IP (global).
Request Headers
| Header | Value | Required | Description |
|---|---|---|---|
Authorization | Bearer <jwt> | Yes | JWT issued by an auth endpoint. Required on every authenticated route. |
Request Parameters
Path
| Field | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes | Settlement id.7d5a... |
Code Examples
curl -X GET "https://bettapay-backend.onrender.com/api/settlements/7d5a..." \
-H "Authorization: Bearer $BETTAPAY_TOKEN"const res = await fetch('https://bettapay-backend.onrender.com/api/settlements/7d5a...', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.BETTAPAY_TOKEN}`,
},
});
const { data } = await res.json();
console.log(data);import axios from 'axios';
const { data } = await axios.get('https://bettapay-backend.onrender.com/api/settlements/7d5a...', {
headers: {
Authorization: `Bearer ${process.env.BETTAPAY_TOKEN}`,
},
});
console.log(data.data);import requests
res = requests.get(
"https://bettapay-backend.onrender.com/api/settlements/7d5a...",
headers={
"Authorization": f"Bearer {token}",
},
)
res.raise_for_status()
print(res.json()["data"])import { useEffect, useState } from 'react';
function useBettaPay(token) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch('https://bettapay-backend.onrender.com/api/settlements/7d5a...', {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
signal: controller.signal,
})
.then((r) => r.json())
.then((json) => setData(json.data))
.catch(() => {});
return () => controller.abort();
}, [token]);
return data;
}Response
| Field | Type | Required | Description |
|---|---|---|---|
data | object | Yes | The settlement. |
id | string (uuid) | Yes | Settlement id. |
merchantId | string (uuid) | Yes | Owning merchant. |
amountUsdc | number | Yes | Amount settled in USDC. |
amountNgn | number | null | No | Fiat amount paid out. |
status | string | Yes | One of pending, processing, completed, failed. |
effectiveRule | object | Yes | The resolved settlement rule applied to this settlement: merchant, then default, then governance. |
feeBps | number | Yes | Settlement fee in basis points. |
autoSettle | boolean | Yes | Whether automatic settlement is enabled. |
delay | number | Yes | Settlement delay in minutes. |
source | 'merchant' | 'default' | 'governance' | Yes | Rule level that supplied the effective values. |
txHash | string | null | No | On-chain settlement transaction hash. |
bankName | string | null | No | Destination bank for fiat payout. |
accountNumber | string | null | No | Masked destination account number. |
createdAt | string (ISO 8601) | Yes | Creation timestamp. |
{
"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
| Status | Code | Description |
|---|---|---|
401 | UNAUTHORIZED | Missing, malformed or expired JWT. |
429 | RATE_LIMITED | Rate limit exceeded — retry after the Retry-After header. |
404 | NOT_FOUND | No settlement with that id. |
Apply a bulk settlement action
/api/settlements/actionsAuth requiredApproves, 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
| Header | Value | Required | Description |
|---|---|---|---|
Authorization | Bearer <jwt> | Yes | JWT issued by an auth endpoint. Required on every authenticated route. |
Content-Type | application/json | Yes | All request bodies are JSON and NFC-normalized server-side. |
Request Parameters
Body
| Field | Type | Required | Description |
|---|---|---|---|
action | 'approve' | 'reject' | 'hold' | Yes | Action to apply to every requested settlement.approve |
settlementIds | string[] | Yes | IDs 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..."]}'const res = await fetch('https://bettapay-backend.onrender.com/api/settlements/actions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.BETTAPAY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"action": "approve",
"settlementIds": [
"7d5a...",
"8e6b...",
"9f7c..."
]
}),
});
const { data } = await res.json();
console.log(data);import axios from 'axios';
const { data } = await axios.post('https://bettapay-backend.onrender.com/api/settlements/actions', {
"action": "approve",
"settlementIds": [
"7d5a...",
"8e6b...",
"9f7c..."
]
}, {
headers: {
Authorization: `Bearer ${process.env.BETTAPAY_TOKEN}`,
},
});
console.log(data.data);import requests
res = requests.post(
"https://bettapay-backend.onrender.com/api/settlements/actions",
headers={
"Authorization": f"Bearer {token}",
},
json={
"action": "approve",
"settlementIds": [
"7d5a...",
"8e6b...",
"9f7c..."
]
},
)
res.raise_for_status()
print(res.json()["data"])import { useEffect, useState } from 'react';
function useBettaPay(token) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch('https://bettapay-backend.onrender.com/api/settlements/actions', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({"action":"approve","settlementIds":["7d5a...","8e6b...","9f7c..."]}),
signal: controller.signal,
})
.then((r) => r.json())
.then((json) => setData(json.data))
.catch(() => {});
return () => controller.abort();
}, [token]);
return data;
}Response
| Field | Type | Required | Description |
|---|---|---|---|
summary | object | Yes | Aggregate result for the requested settlements. |
requested | number | Yes | Number of settlement IDs submitted. |
succeeded | number | Yes | Number of settlements updated successfully. |
failed | number | Yes | Number of settlements that could not be updated. |
results | SettlementActionResult[] | Yes | Independent result for every submitted settlement ID. |
id | string (uuid) | Yes | Submitted settlement id. |
success | boolean | Yes | Whether the requested action succeeded for this settlement. |
status | string | Yes | Resulting settlement status. |
error | string | null | Yes | Failure reason, or null when successful. |
{
"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
| Status | Code | Description |
|---|---|---|
401 | UNAUTHORIZED | Missing, malformed or expired JWT. |
429 | RATE_LIMITED | Rate limit exceeded — retry after the Retry-After header. |
400 | VALIDATION_ERROR | Request body failed Zod validation. `details` lists the offending fields. |
409 | IDEMPOTENCY_CONFLICT | The 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 returns504 GATEWAY_TIMEOUT. Treat it as retryable with backoff.Get exchange rates
/api/ratesPublicProxies 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"const res = await fetch('https://bettapay-backend.onrender.com/api/rates', {
method: 'GET',
});
const { data } = await res.json();
console.log(data);import axios from 'axios';
const { data } = await axios.get('https://bettapay-backend.onrender.com/api/rates');
console.log(data.data);import requests
res = requests.get(
"https://bettapay-backend.onrender.com/api/rates",
)
res.raise_for_status()
print(res.json()["data"])import { useEffect, useState } from 'react';
function useBettaPay(token) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch('https://bettapay-backend.onrender.com/api/rates', {
method: 'GET',
signal: controller.signal,
})
.then((r) => r.json())
.then((json) => setData(json.data))
.catch(() => {});
return () => controller.abort();
}, [token]);
return data;
}Response
| Field | Type | Required | Description |
|---|---|---|---|
usdcNgn | number | No | Convenience primary USDC→NGN rate. |
rates | Rate[] | Yes | All available currency pairs. |
from | string | Yes | Source asset code. |
to | string | Yes | Target currency code. |
rate | number | Yes | Units of `to` per 1 unit of `from`. |
change | number | Yes | 24h percentage change. |
trend | 'up' | 'down' | Yes | Direction of the 24h change. |
{
"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
| Status | Code | Description |
|---|---|---|
429 | RATE_LIMITED | Rate limit exceeded. |
504 | GATEWAY_TIMEOUT | The upstream FX engine did not respond in time. |
Get an FX quote
/api/fx/quotePublicReturns 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
| Field | Type | Required | Description |
|---|---|---|---|
from | string | Yes | Source asset code.USDC |
to | string | Yes | Target currency code.NGN |
amount | number | Yes | Amount of `from` to convert.50 |
Code Examples
curl -X GET "https://bettapay-backend.onrender.com/api/fx/quote?from=USDC&to=NGN&amount=50"const res = await fetch('https://bettapay-backend.onrender.com/api/fx/quote?from=USDC&to=NGN&amount=50', {
method: 'GET',
});
const { data } = await res.json();
console.log(data);import axios from 'axios';
const { data } = await axios.get('https://bettapay-backend.onrender.com/api/fx/quote?from=USDC&to=NGN&amount=50');
console.log(data.data);import requests
res = requests.get(
"https://bettapay-backend.onrender.com/api/fx/quote?from=USDC&to=NGN&amount=50",
)
res.raise_for_status()
print(res.json()["data"])import { useEffect, useState } from 'react';
function useBettaPay(token) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch('https://bettapay-backend.onrender.com/api/fx/quote?from=USDC&to=NGN&amount=50', {
method: 'GET',
signal: controller.signal,
})
.then((r) => r.json())
.then((json) => setData(json.data))
.catch(() => {});
return () => controller.abort();
}, [token]);
return data;
}Response
| Field | Type | Required | Description |
|---|---|---|---|
from | string | Yes | Source asset code. |
to | string | Yes | Target currency code. |
rate | number | Yes | Applied conversion rate. |
amount | number | Yes | Input amount. |
converted | number | Yes | Resulting amount in `to`. |
{
"from": "USDC",
"to": "NGN",
"rate": 1650.42,
"amount": 50,
"converted": 82521
}Errors
| Status | Code | Description |
|---|---|---|
400 | VALIDATION_ERROR | Missing or invalid from/to/amount. |
504 | GATEWAY_TIMEOUT | The 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:
| Event | Emitted when |
|---|---|
payment.completed | A payment settled on-chain and funds are confirmed. |
payment.failed | A payment could not be completed. |
payment.expired | A pending payment passed its expiry window. |
settlement.completed | A settlement batch cleared and the payout was sent. |
settlement.failed | A 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.
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique event id — use it to deduplicate retries.evt_9b2f... |
type | string (enum) | Yes | The event type (see the table above).payment.completed |
createdAt | string (ISO 8601) | Yes | When the event was emitted. |
data | object | Yes | The full resource (payment or settlement) at the time of the event. |
{
"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.
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": {
"code": "VALIDATION_ERROR",
"message": "amountUsdc must be a positive number",
"details": [
{ "path": "amountUsdc", "message": "Expected positive number" }
]
}
}| Code | HTTP | Meaning |
|---|---|---|
| VALIDATION_ERROR | 400 | The request body or query failed schema validation. |
| UNAUTHORIZED | 401 | The request carried no valid identity. |
| FORBIDDEN | 403 | You are authenticated, but not allowed to perform this action. |
| NOT_FOUND | 404 | No such resource is visible to you. |
| REQUEST_TIMEOUT | 408 | The request exceeded the gateway request timeout (30s). |
| IDEMPOTENCY_CONFLICT | 409 | The request conflicts with existing state. |
| RATE_LIMITED | 429 | You exceeded the rate limit for this route. |
| GATEWAY_TIMEOUT | 504 | An upstream service did not respond in time. |
VALIDATION_ERROR
400The 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.
FORBIDDEN
403You 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
404No 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
408The 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
409The 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
429You 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
504An 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.
| Status | Name | Meaning |
|---|---|---|
| 200 | OK | The request succeeded; the payload is in `data`. |
| 201 | Created | A resource was created; the new resource is in `data`. |
| 400 | Bad Request | Schema validation failed — see `VALIDATION_ERROR`. |
| 401 | Unauthorized | Missing, malformed or expired credentials. |
| 403 | Forbidden | Authenticated but not permitted, or a limit was exceeded. |
| 404 | Not Found | No such resource is visible to the caller. |
| 408 | Request Timeout | The 30s gateway request timeout elapsed. |
| 409 | Conflict | Idempotency conflict or an illegal state transition. |
| 429 | Too Many Requests | Rate limit exceeded; check `Retry-After`. |
| 500 | Internal Server Error | Unexpected server fault. Safe to retry with backoff. |
| 504 | Gateway Timeout | An 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.
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
- Expose a public HTTPS route (for example
POST /webhooks/bettapay) that accepts JSON. - Register the URL with BettaPay and store the webhook secret as
BETTAPAY_WEBHOOK_SECRET— never commit it. - 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.
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
2xxfirst, 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).
# Expose your local handler so the gateway can reach it while developing
npx localtunnel --port 3000
# or
ngrok http 3000The 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.
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
- Install the Freighter browser extension and create or import an account.
- Open Freighter → Settings → Network and switch to
Test Net. - 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:
# 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:
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:
# 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.