Bag Docs
Guides

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:

  1. Get a tax quote — Calculate tax based on the customer's address
  2. Create a session — Lock in the price and get a session ID
  3. Submit the transaction — Customer pays on-chain, you submit the tx hash
  4. 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.completed webhook
  • Sets the session status to complete

Session statuses

StatusMeaning
payment_session_createdSession created, waiting for payment
txn_broadcastTransaction hash submitted
txn_confirmingWaiting for on-chain confirmation
txn_finalizedConfirmed on-chain
completePayment fully processed
failedTransaction reverted or verification failed
expiredSession timed out (30 min)
manual_retry_neededRequires 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);

What's next

On this page