Build a Custom Checkout
Use the Checkout Sessions API to control the payment flow from your own UI.
Build a Custom Checkout
Payment links give you a hosted checkout page with zero frontend work. But if you need more control — embedding checkout in your app, collecting customer details upfront, or building a custom payment UI — use the Checkout Sessions API.
A checkout session tracks a single payment attempt from tax calculation through on-chain confirmation.
The checkout flow
Every custom checkout follows four steps:
- Get a tax quote — Calculate tax based on the customer's address
- Create a session — Lock in the price and get a session ID
- Submit the transaction — Customer pays on-chain, you submit the tx hash
- Finalize — Poll until on-chain confirmation, Bag fires the webhook
Your Server Bag API Blockchain
| | |
|-- POST /api/tax/quote ----->| |
|<-- quoteToken, totals ------| |
| | |
|-- POST /api/checkout/session -->| |
|<-- sessionId, expiresAt --------| |
| | |
| (customer signs tx) | |
| | tx confirmed
|-- POST /api/checkout/submit --->| |
|<-- status: txn_broadcast -------| |
| | |
|-- POST /api/checkout/finalize ->|--- verify on-chain --->|
|<-- status: complete ------------| |
| | |
| (webhook: payment.completed) | |Step 1: Get a tax quote
Before creating a session, request a tax quote. Bag uses Numeral to calculate VAT, GST, and sales tax based on the customer's address.
import { Bag } from "@getbagsapp/sdk";
const bag = new Bag({ apiKey: process.env.BAG_API_KEY! });
const quote = await bag.checkout.getTaxQuote({
paymentLinkId: "665f1a2b3c4d5e6f7a8b9c0d",
customerAddress: {
address_line_1: "100 Main St",
address_city: "San Francisco",
address_province: "CA",
address_postal_code: "94105",
address_country: "US",
},
});
console.log(`Subtotal: $${(quote.subtotalCents / 100).toFixed(2)}`);
console.log(`Tax: $${(quote.taxCents / 100).toFixed(2)}`);
console.log(`Total: $${(quote.totalCents / 100).toFixed(2)}`);import os
import requests
API_KEY = os.environ["BAG_API_KEY"]
response = requests.post(
"https://justusebag.xyz/api/tax/quote",
headers={"Content-Type": "application/json"},
json={
"paymentLinkId": "665f1a2b3c4d5e6f7a8b9c0d",
"customerAddress": {
"address_line_1": "100 Main St",
"address_city": "San Francisco",
"address_province": "CA",
"address_postal_code": "94105",
"address_country": "US",
},
},
)
quote = response.json()["data"]
print(f"Subtotal: ${quote['subtotalCents'] / 100:.2f}")
print(f"Tax: ${quote['taxCents'] / 100:.2f}")
print(f"Total: ${quote['totalCents'] / 100:.2f}")Tax quote response
{
"status": "success",
"data": {
"subtotalCents": 2999,
"taxCents": 262,
"totalCents": 3261,
"calculationId": "calc_abc123",
"quoteToken": "eyJhbGciOiJIUzI1NiJ9..."
}
}Save the quoteToken — you'll need it to create the session. The token is signed and expires, so create the session promptly.
Step 2: Create a checkout session
const session = await bag.checkout.createSession({
linkId: "665f1a2b3c4d5e6f7a8b9c0d",
quoteToken: quote.quoteToken,
walletAddress: "0xCustomerWalletAddress",
walletType: "evm",
network: "base_sepolia",
customer: {
name: "Jane Doe",
email: "jane@example.com",
address: "100 Main St, San Francisco, CA 94105",
country: "US",
},
totalsSnapshot: {
subtotalCents: quote.subtotalCents,
taxCents: quote.taxCents,
totalCents: quote.totalCents,
calculationId: quote.calculationId,
},
});
console.log(`Session ID: ${session.sessionId}`);
console.log(`Expires at: ${session.expiresAt}`);response = requests.post(
"https://justusebag.xyz/api/checkout/session",
headers={"Content-Type": "application/json"},
json={
"linkId": "665f1a2b3c4d5e6f7a8b9c0d",
"quoteToken": quote["quoteToken"],
"walletAddress": "0xCustomerWalletAddress",
"walletType": "evm",
"network": "base_sepolia",
"customer": {
"name": "Jane Doe",
"email": "jane@example.com",
"address": "100 Main St, San Francisco, CA 94105",
"country": "US",
},
"totalsSnapshot": {
"subtotalCents": quote["subtotalCents"],
"taxCents": quote["taxCents"],
"totalCents": quote["totalCents"],
"calculationId": quote["calculationId"],
},
},
)
session = response.json()["data"]
print(f"Session ID: {session['sessionId']}")
print(f"Expires at: {session['expiresAt']}")Session response
{
"status": "success",
"data": {
"sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "payment_session_created",
"quote": {
"subtotalCents": 2999,
"taxCents": 262,
"totalCents": 3261,
"calculationId": "calc_abc123"
},
"expiresAt": "2026-03-01T12:30:00.000Z"
}
}Sessions expire after 30 minutes. If the customer doesn't pay in time, create a new session.
Sessions are idempotent — if you create a session with the same payment link, wallet, network, and calculation ID, Bag returns the existing session instead of creating a duplicate.
Step 3: Submit the transaction hash
After the customer signs and broadcasts the on-chain transaction, submit the tx hash to Bag:
await bag.checkout.submit(session.sessionId, "0xabc123def456...");requests.post(
"https://justusebag.xyz/api/checkout/submit",
headers={"Content-Type": "application/json"},
json={
"sessionId": session["sessionId"],
"txHash": "0xabc123def456...",
},
)Bag deduplicates by txHash — submitting the same hash twice is safe.
Step 4: Finalize
Poll the finalize endpoint until the transaction is confirmed on-chain:
let confirmed = false;
while (!confirmed) {
const result = await fetch("https://justusebag.xyz/api/checkout/finalize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId: session.sessionId }),
}).then((r) => r.json());
if (result.data.confirmed) {
confirmed = true;
console.log("Payment confirmed!");
} else {
await new Promise((r) => setTimeout(r, 3000));
}
}import time
confirmed = False
while not confirmed:
result = requests.post(
"https://justusebag.xyz/api/checkout/finalize",
headers={"Content-Type": "application/json"},
json={"sessionId": session["sessionId"]},
).json()
if result["data"].get("confirmed"):
confirmed = True
print("Payment confirmed!")
else:
time.sleep(3)When finalization succeeds, Bag:
- Marks the transaction as
completed - Records the tax with Numeral
- Fires a
payment.completedwebhook - Sets the session status to
complete
Session statuses
| Status | Meaning |
|---|---|
payment_session_created | Session created, waiting for payment |
txn_broadcast | Transaction hash submitted |
txn_confirming | Waiting for on-chain confirmation |
txn_finalized | Confirmed on-chain |
complete | Payment fully processed |
failed | Transaction reverted or verification failed |
expired | Session timed out (30 min) |
manual_retry_needed | Requires manual intervention |
Check session status
You can poll a session's status at any time:
const status = await bag.checkout.getSession("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
console.log(status.status);