This guide shows you how to drive BettaPay from your own backend: authenticate, create payments with idempotency keys, apply live FX conversion, and reconcile the result via polling or webhooks — all with production-grade error handling. We finish with a complete, runnable Express.js server you can copy into a project.
The examples are Node.js (TypeScript-friendly plain JS), but the request shapes map directly to any language. For endpoint-level detail, keep the API reference open alongside this guide.
fetch and crypto APIs).Authentication
Server calls authenticate with the merchant session cookie issued after Google OAuth sign-in (bp_session). Store it as a secret on your server and send it with every request. Never expose it to the browser or commit it to source control.
BETTAPAY_API_BASE=https://api.bettapay.com
BETTAPAY_SESSION=bp_session_value_from_dashboard
BETTAPAY_WEBHOOK_SECRET=whsec_your_signing_secret401 Unauthorized, refresh it from the dashboard. Long-lived machine credentials (API keys) are on the roadmap — see the feature-status note at the end.A resilient API client
Every network call can fail transiently. This small client wraps fetch with timeouts, exponential backoff (with jitter), and correct retry semantics: it retries 429 and 5xx responses and network errors, but never retries 4xx client errors (except 429).
// bettapay-client.js — a dependency-free client with retries + backoff.
const API_BASE = process.env.BETTAPAY_API_BASE || "https://api.bettapay.com";
const SESSION = process.env.BETTAPAY_SESSION;
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
const MAX_RETRIES = 4;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/**
* Perform a BettaPay API request with retries and exponential backoff.
* @param {string} path e.g. "/v1/payments"
* @param {object} [opts] { method, body, idempotencyKey, timeoutMs }
*/
async function apiRequest(path, opts = {}) {
const {
method = "GET",
body,
idempotencyKey,
timeoutMs = 10_000,
} = opts;
let lastError;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(API_BASE + path, {
method,
headers: {
"Content-Type": "application/json",
Cookie: `bp_session=${SESSION}`,
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
},
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
if (res.ok) return await res.json();
// Non-retryable client error — surface it immediately.
if (!RETRYABLE_STATUS.has(res.status)) {
const detail = await res.text();
throw new Error(`BettaPay ${res.status}: ${detail}`);
}
// Retryable: honor Retry-After if present, else back off.
lastError = new Error(`BettaPay ${res.status} (attempt ${attempt + 1})`);
const retryAfter = Number(res.headers.get("Retry-After")) || 0;
const backoff = retryAfter
? retryAfter * 1000
: Math.min(2 ** attempt * 250, 8000) + Math.random() * 250;
if (attempt < MAX_RETRIES) await sleep(backoff);
} catch (err) {
// Network error or timeout — retry with backoff.
lastError = err;
if (attempt < MAX_RETRIES) {
await sleep(Math.min(2 ** attempt * 250, 8000) + Math.random() * 250);
}
} finally {
clearTimeout(timer);
}
}
throw lastError ?? new Error("BettaPay request failed");
}
module.exports = { apiRequest };Handling FX conversion
If you price in a local currency, fetch a quote first so you know the exact USDC amount before creating the payment. The FX quote endpoint is a thin proxy over live rates and returns a short-lived quote id you can attach to the payment to lock the rate.
const { apiRequest } = require("./bettapay-client");
/** Get a locked FX quote converting a fiat amount to USDC. */
async function getQuote(amount, currency) {
const params = new URLSearchParams({
from: currency,
to: "USDC",
amount: String(amount),
});
const quote = await apiRequest(`/v1/fx/quote?${params}`);
// { quoteId, rate, from, to, amount, convertedAmount, expiresAt }
return quote;
}
module.exports = { getQuote };Creating a payment with idempotency
Payment creation is a mutation, so always send an Idempotency-Key. If a response is lost to a timeout and your client retries, BettaPay returns the original payment instead of creating a duplicate. Use a key that is stable for the logical operation — for example your own order id.
const { apiRequest } = require("./bettapay-client");
const { getQuote } = require("./get-quote");
/**
* Create a payment for an order. Idempotent on `orderId`, so retrying this
* function will never double-charge.
*/
async function createPayment({ orderId, amount, currency, customerEmail }) {
const quote = await getQuote(amount, currency);
const payment = await apiRequest("/v1/payments", {
method: "POST",
idempotencyKey: `order-${orderId}`,
body: {
amount: String(amount),
currency,
quoteId: quote.quoteId, // lock the FX rate
reference: orderId, // your id, echoed back in webhooks
customer: { email: customerEmail },
metadata: { source: "checkout-v2" },
},
});
// { id, status: "pending", checkoutUrl, amount, currency, ... }
return payment;
}
module.exports = { createPayment };Date.now().Reconciling the result
After creating a payment its status is pending. You learn the outcome one of two ways. Webhooks are preferred — they're push-based and instant. Polling is a fallback for environments where you can't receive inbound requests.
Option A — Poll status (fallback)
const { apiRequest } = require("./bettapay-client");
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const TERMINAL = new Set(["succeeded", "failed", "expired", "refunded"]);
/**
* Poll a payment until it reaches a terminal state, backing off between checks.
* Returns the final payment object, or throws on timeout.
*/
async function waitForPayment(paymentId, { timeoutMs = 120_000 } = {}) {
const deadline = Date.now() + timeoutMs;
let delay = 2000;
while (Date.now() < deadline) {
const payment = await apiRequest(`/v1/payments/${paymentId}`);
if (TERMINAL.has(payment.status)) return payment;
await sleep(delay);
delay = Math.min(delay * 1.5, 15_000); // gentle backoff
}
throw new Error(`Timed out waiting for payment ${paymentId}`);
}
module.exports = { waitForPayment };Option B — Webhooks (recommended)
The Express server below exposes a /api/webhook endpoint that verifies the signature and reacts to events. This is the production path. For the full retry schedule, deduplication, and dead-letter handling, read Webhook Handling & Retry Logic.
Security best practices
- Keep secrets server-side. The session cookie and webhook secret never belong in frontend code or a repo — load them from environment variables.
- Always verify webhook signatures before trusting a payload. An unverified endpoint will happily accept forged "payment succeeded" events.
- Treat amounts as strings and compare against your own records. Never fulfill an order based on a client-reported amount — use the server-confirmed
settledAmount. - Enforce HTTPS on your webhook URL and reject requests that fail signature verification with
400. - Make handlers idempotent. The same event may arrive more than once; dedupe on the event
id.
Complete Express.js server
Putting it together: a minimal server that creates a payment and receives its webhook. Note the webhook route uses the raw request body for signature verification — parsing it to JSON first would change the bytes and break the HMAC.
// server.js — run with: node server.js
const express = require("express");
const crypto = require("crypto");
const { createPayment } = require("./create-payment");
const app = express();
const WEBHOOK_SECRET = process.env.BETTAPAY_WEBHOOK_SECRET;
// In-memory dedupe store. Use Redis or a DB column in production.
const processedEvents = new Set();
/**
* Timing-safe HMAC-SHA256 verification.
* @param {Buffer} rawBody the exact bytes received
* @param {string} signature value of the X-BettaPay-Signature header
*/
function verifySignature(rawBody, signature) {
if (!signature) return false;
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// --- Create a payment (JSON body) ---
app.post("/api/checkout", express.json(), async (req, res) => {
try {
const { orderId, amount, currency, email } = req.body;
const payment = await createPayment({
orderId,
amount,
currency,
customerEmail: email,
});
res.json({ checkoutUrl: payment.checkoutUrl, paymentId: payment.id });
} catch (err) {
console.error("checkout failed:", err.message);
res.status(502).json({ error: "Could not create payment" });
}
});
// --- Receive webhooks (RAW body — must come before any json parser) ---
app.post(
"/api/webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.header("X-BettaPay-Signature");
if (!verifySignature(req.body, signature)) {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// Idempotent: ignore events we've already handled.
if (processedEvents.has(event.id)) return res.status(200).send("dup");
processedEvents.add(event.id);
switch (event.type) {
case "payment.succeeded":
// Fulfill the order using the SERVER-confirmed amount.
console.log(
`Order ${event.data.reference} paid: ${event.data.settledAmount} USDC`
);
break;
case "payment.failed":
console.warn(`Payment failed for order ${event.data.reference}`);
break;
default:
console.log("Unhandled event:", event.type);
}
// Acknowledge fast so BettaPay doesn't retry. Do slow work async.
res.status(200).send("ok");
}
);
app.listen(3000, () => console.log("Listening on http://localhost:3000"));200 as soon as you've verified and recorded the event. If you do heavy work (emails, provisioning) inline and it's slow, BettaPay may time out and retry — enqueue that work instead.Feature status
Payment creation with idempotency keys, the FX quote proxy, status polling, and webhooks are available now. Session-cookie auth is the current mechanism; dedicated API keys are in development. Batch settlement is covered in Fiat Settlement Configuration.
Next steps
- Harden your webhook endpoint → Webhook Handling & Retry Logic.
- Settle collected USDC to fiat → Fiat Settlement Configuration.
- Full request/response schemas → API reference.