Developer guide
Embeddable Checkout
Drop a BAM checkout into your own page in two lines. The iframe runs on bamnft.com, so PCI scope, fraud, and disputes stay with us — your page just listens for events and updates its own UI.
Quick start
Two lines of HTML. No npm install, no build step, no API call from the browser. Replace lnk_… with a CheckoutLink id from your dashboard.
<!-- 1. Drop the loader -->
<script async defer src="https://bamnft.com/embed/loader.js"></script>
<!-- 2. Drop the mount point -->
<div data-bam-checkout-link-id="lnk_…"></div>That's the whole integration for the happy path. The iframe renders a buyer-facing checkout, captures payment, kicks off the mint, and emits a bam-checkout:complete event when the order has been paid. If you want to show a thank-you page or send a confirmation email, listen for that event — see Listen for events.
Don't have a CheckoutLink yet? Open the dashboard and run the four-step onboarding wizard — it gives you the snippet ready to paste, registers an allowed origin for your site, and lets you preview the iframe in a sandbox tab.
Get a checkout link
A CheckoutLink is a long-lived, reusable handle pointing at a collection or a specific NFT. Each iframe load mints a fresh CheckoutSession from the link and binds it to the buyer.
Option A — From the dashboard
Sign in, open Checkout Links, click Create link, and copy the snippet from the row that appears. The dashboard shows the snippet, lets you set price overrides, and exposes a one-click sandbox preview.
Option B — From the API
Mint a key under Settings → API Keys with the checkout:write scope, then:
curl -X POST https://bamnft.com/api/v1/checkout/links \
-H "Authorization: Bearer bam_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: cart_4821_v1" \
-d '{
"collectionId": "clxyz123…",
"currency": "EUR",
"walletRequired": true,
"successUrl": "https://merchant.example.com/thanks",
"cancelUrl": "https://merchant.example.com/cart",
"metadata": { "internalCartId": "cart_4821" }
}'
# Response includes the snippet you drop on your page:
# {
# "id": "lnk_…",
# "embedSnippet": "<script async defer src=…></script>\n<div data-bam-checkout-link-id=\"lnk_…\"></div>",
# ...
# }
#
# Retrying the same request with the same Idempotency-Key returns the
# same row (no duplicate inserted).For one-shot dynamic carts (a different bundle of NFTs per buyer), use POST /v1/checkout/sessions instead. It returns a session URL that you can either redirect to or pin into a data-bam-checkout-session-id attribute on the loader div.
Per-link options
Both CheckoutLink and one-shot CheckoutSession accept the same configuration knobs. Anything you don't set falls back to the merchant default.
| Field | Type | Notes |
|---|---|---|
| currency | EUR | USD | GBP | NOK | SEK | AUD | Defaults to your merchant's configured currency. Buyer pays the subtotal in this currency; FX is your problem if you mix. |
| forcedProvider | stripe | upaywise | null | Pin a card acquirer. null shows the buyer the picker (Stripe / uPayWise). |
| walletRequired | boolean (default true) | When true the iframe gates submit on a valid Polygon 0x address. Set to false for custodial flows where BAM holds the NFT post-mint. |
| priceOverride | number | Force a fixed total instead of summing the items' mint prices. Useful for promo / bundle pricing. |
| successUrl / cancelUrl | URL | Surfaced to the iframe — useful for buyers who close the tab and return later. The iframe stays in-flow; these are not auto-redirected. |
| metadata | JSON | Arbitrary object echoed back inside every outbound webhook payload's data.metadata. |
Idempotency
Both POST /v1/checkout/links and POST /v1/checkout/sessions honor the Idempotency-Key request header. Repeating the same request with the same key returns the original row — the second call is a no-op, not a 409.
Pick a stable identifier on your side (your cart id, your order attempt id, etc.). The uniqueness scope is (merchantId, key) so you only need it stable within your own merchant. Keys persist forever — they're cheap on our side; you don't need to scrub them.
Listen for events
The iframe communicates with your page via postMessage. The loader script subscribes to the iframe's window, validates the origin, and re-dispatches each message as a bubbling CustomEvent on the mount element — pick whichever surface fits your code.
| Event | Fires when | Payload |
|---|---|---|
| bam-checkout:ready | iframe has mounted and the session is loaded | { type, sessionId } |
| bam-checkout:resize | iframe content height changes | { type, height } |
| bam-checkout:complete | order is paid and entered the mint pipeline | { type, sessionId, orderId, status: 'success' } |
| bam-checkout:error | submit failed or session entered a non-recoverable error state | { type, sessionId, code, message } |
| bam-checkout:cancel | buyer cancelled out of the flow | { type, sessionId } |
The contract is one-way: BAM emits, your page reads. The only message your page sends back is the bam-checkout:init handshake — and the loader sends that for you.
<script async defer src="https://bamnft.com/embed/loader.js"></script>
<div data-bam-checkout-link-id="lnk_…"></div>
<script>
// Receive every BAM event on the host element. The loader bubbles
// each postMessage as a CustomEvent on the <div>, so you don't have
// to manage event.source / event.origin yourself.
document
.querySelector('[data-bam-checkout-link-id]')
.addEventListener('bam-checkout:complete', (e) => {
window.location.href = '/thanks?order=' + e.detail.orderId;
});
// Or subscribe to raw window messages for finer control:
window.addEventListener('message', (e) => {
if (e.origin !== 'https://bamnft.com') return;
if (!e.data?.type?.startsWith('bam-checkout:')) return;
console.log(e.data);
});
</script>The state-change events (complete, error, cancel) all carry the sessionId so you can correlate against your own analytics. For post-checkout fulfilment — order entitlements, email receipts, accounting — use webhooks instead. The iframe events are best-effort and require the buyer's tab to stay open; webhooks are at-least-once and retried for ~24 hours.
Webhooks
Register an HTTPS endpoint under Checkout Links and BAM will POST signed JSON to it whenever a session crosses a terminal state. The signing scheme matches Stripe's — if you already verify Stripe webhooks, you can copy that code with one rename.
Headers BAM sends
bam-signature: t=<unix>,v1=<hex>— HMAC-SHA256 of`${t}.${rawBody}`using the secret BAM showed you once on creation.bam-event-id: evt_<hex>— stable across retries; use it as the idempotency key on your side.bam-event-type: <event>— same value as the body's top-leveleventfield, for routing without parsing JSON.
Replay window
The t in the signature is included inside the HMAC payload, so a captured signature can't be reused outside its window. BAM's reference verifier rejects any t more than 5 minutes from your wall clock — copy the same tolerance on your side.
Retry policy
Up to 13 attempts, exponential backoff starting at 10 seconds. The last retry fires roughly 11 hours after the first; the cumulative window is about 23 hours. After 50 consecutive failed deliveries the endpoint is auto-disabled and you'll need to re-enable it from the dashboard. To pause deliveries on purpose, set the endpoint to inactive — disabled endpoints don't count toward the failure threshold.
Events you can subscribe to
| Event | Fires when |
|---|---|
| checkout.session.completed | order paid and items entered MINTING state |
| order.minted | all items in the order have minted on-chain |
| checkout.session.failed | payment capture failed, refund triggered, or mint failed terminally |
| checkout.session.expired | session aged out of its 30-minute TTL without ever creating an order |
New events are additive — your endpoint will keep receiving exactly the set you subscribed to. To start receiving a new event type, edit the endpoint and check the box.
Verifying a signature (TypeScript / Node.js)
Drop this into your handler. It uses Node's built-in crypto module — no extra dependencies. Pass the raw request body, not a re-stringified one; key order and whitespace must match exactly what BAM signed.
import { createHmac, timingSafeEqual } from 'crypto';
export interface VerifyResult {
valid: boolean;
reason?: 'malformed' | 'stale' | 'mismatch';
}
/** Replay window the BAM signer enforces — 5 minutes either side. */
export const BAM_REPLAY_WINDOW_SECONDS = 300;
/**
* Verify a `bam-signature` header on an inbound webhook from BAM.
*
* bam-signature: t=<unix>,v1=<hex(hmac_sha256(secret, `${t}.${rawBody}`))>
*
* Use the *raw* request body — JSON.stringify of the parsed body will
* not match because key order, whitespace, and number formatting can
* differ from what BAM signed.
*/
export function verifyBamSignature(
secret: string,
header: string | undefined | null,
rawBody: string,
nowSec: number = Math.floor(Date.now() / 1000),
toleranceSec: number = BAM_REPLAY_WINDOW_SECONDS,
): VerifyResult {
if (!header) return { valid: false, reason: 'malformed' };
const parts = header.split(',').map((p) => p.trim());
let t: number | null = null;
let v1: string | null = null;
for (const part of parts) {
const eq = part.indexOf('=');
if (eq <= 0) return { valid: false, reason: 'malformed' };
const key = part.slice(0, eq);
const value = part.slice(eq + 1);
if (key === 't') {
const n = Number.parseInt(value, 10);
if (!Number.isFinite(n) || n <= 0) {
return { valid: false, reason: 'malformed' };
}
t = n;
} else if (key === 'v1') {
if (!/^[0-9a-fA-F]+$/.test(value)) {
return { valid: false, reason: 'malformed' };
}
v1 = value;
}
}
if (t === null || v1 === null) return { valid: false, reason: 'malformed' };
if (Math.abs(nowSec - t) > toleranceSec) {
return { valid: false, reason: 'stale' };
}
const expected = createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest();
const provided = Buffer.from(v1, 'hex');
if (provided.length !== expected.length) {
return { valid: false, reason: 'mismatch' };
}
return timingSafeEqual(expected, provided)
? { valid: true }
: { valid: false, reason: 'mismatch' };
}
Body shape
Every event has the same envelope; the per-event details live in data:
{
"id": "evt_8b2…",
"event": "checkout.session.completed",
"createdAt": "2026-05-12T13:47:21.044Z",
"data": {
"sessionId": "cm…",
"merchantId": "cm…",
"linkId": "lnk_…",
"orderId": "ord_…",
"state": "completed",
"nftIds": ["nft_…"],
"currency": "EUR",
"metadata": { "internalCartId": "cart_4821" }
}
}Rotate webhook secret
Rotating a webhook secret used to be a coordinated cutover — rotate on BAM, then immediately update the secret in your verifier, hoping no deliveries land in between. Not anymore.
When you POST /v1/merchants/:id/webhook-endpoints/:id/rotate-secret we park the previous secret on the row for 30 days. During that window the delivery worker sends bam-signature-old alongside bam-signature:
POST /your-webhook HTTP/1.1
bam-signature: t=1716290400,v1=… ← new secret
bam-signature-old: t=1716290400,v1=… ← old secret (only during grace)
bam-event-id: evt_…
bam-event-type: checkout.session.completed
Content-Type: application/json
{ "id": "evt_…", "event": "…", "data": { … } }Your verifier accepts a delivery if either signature matches. Roll your side over to the new secret at your own pace within the 30 days. After expiry the second header stops being sent and the parked secret stays in the DB for forensic reference only.
Sessions API
For audit, debugging, or backfill, list and read sessions directly. The session token is never returned on these responses — leaking dashboard logs can't back-walk into a usable session URL.
# List recent sessions (filter by state, link, or page size)
curl "https://bamnft.com/api/v1/checkout/sessions?state=completed&limit=20" \
-H "Authorization: Bearer bam_live_…"
# Get a single session
curl "https://bamnft.com/api/v1/checkout/sessions/cm…" \
-H "Authorization: Bearer bam_live_…"Filters: state (open | submitting | completed | failed | cancelled | expired | refunded), linkId, limit (1–100, defaults to 50). The list is ordered createdAt DESC.
Security
You must register every origin you embed from
Before the iframe will accept a postMessage handshake from your page, the page's origin must be on the merchant's allowed-origins list. Add origins under Checkout Links → Allowed origins before going live.
Origins must be exact — https://merchant.example.com does not cover https://www.merchant.example.com. Add both. Wildcards are not supported on purpose: an open list defeats the per-merchant CSP frame-ancestors protection that prevents your iframe from being clickjacked elsewhere.
frame-ancestors is per-session
Each iframe response carries a Content-Security-Policy header derived live from your registered origins. A browser will refuse to render the iframe inside any origin not on the list, even if a rogue page tries to mount it. This is your defence against an attacker proxying your storefront through their own page.
Session ids are unguessable
Each session id is a server-issued cuid paired with a 32-byte hex token. The first parent origin to handshake binds the session — subsequent attempts from a different origin are rejected. Don't treat the session id as a secret you have to hide, but don't pass it through user input either.
Limits
- Session lifetime — 30 minutes from creation. The iframe surfaces a fresh-load message to your page when a session has expired; create a new one (or reload the iframe, if you used a CheckoutLink) and the buyer is back where they were.
- Idempotent submit — one Order per session. A second
submiton the same session returns the bound order id rather than creating a duplicate. Concurrent double-submit is caught at the database level. - Rate limit — generous, per-API-key. Bursts of session creation are throttled to keep one runaway merchant from impacting another. If you're building a high-volume integration and need a higher ceiling, contact help@bamnft.com.
Need help? Email help@bamnft.com or open the onboarding wizard for an interactive walk-through.