Quickstart
Go from zero to a confirmed test payment in under 10 minutes.
Go from zero to a confirmed test payment in under 10 minutes.
Bag is a Merchant of Record. When your customer pays, they pay Bag — and Bag pays you. In between, we handle tax collection, compliance, invoicing, and cross-border settlement. You build your product. We handle the money stuff. Starting at 1.5% per transaction.
What Bag handles: Tax calculation, compliance, invoicing, payment processing, settlement.
What you handle: Your product, your UI, and a webhook endpoint to know when you get paid.
Before you start
You'll need three things:
- Node.js 18+ (for TypeScript examples) or Python 3.8+ (for Python examples)
- A Bag account — sign up at justusebag.xyz (takes under 60 seconds)
- A test API key — after signing up, go to Developer Settings in the dashboard and generate a test key
Your test key looks like this:
bag_test_sk_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6- Test keys (
bag_test_sk_*) hit the sandbox. No real money moves. Use these while building. - Live keys (
bag_live_sk_*) hit production. You'll need to complete KYB verification first. - Your full key is shown once at creation. Copy it and store it somewhere safe.
Set it as an environment variable so the examples below work as-is:
export BAG_API_KEY="bag_test_sk_your_key_here"Never expose your API key in client-side code, public repos, or browser bundles. Treat it like a password. Use environment variables or a secrets manager.
The quickstart
Install the SDK
Bag has a first-party TypeScript SDK:
npm install @getbagsapp/sdkNo Python SDK yet — use requests to hit the REST API directly. Same endpoints, same behavior:
pip install requestsCreate your first payment link
A payment link is a reusable checkout URL. You create one for each product or plan you sell, and Bag gives you a hosted checkout page your customers can pay through — no frontend work required.
import { Bag } from "@getbagsapp/sdk";
// Initialize the client with your test API key
const bag = new Bag({
apiKey: process.env.BAG_API_KEY!,
});
async function main() {
// Create a payment link for a $29.99 product on Base Sepolia (testnet)
const link = await bag.paymentLinks.create({
name: "Pro Plan", // Display name shown on the checkout page
amount: 29.99, // Price in USD
network: "base_sepolia", // Testnet network — no real money moves
});
console.log("Payment link created:");
console.log(` ID: ${link._id}`);
console.log(` Name: ${link.name}`);
console.log(` Amount: $${link.amount} ${link.currency}`);
console.log(` Token: ${link.token}`);
console.log(` Active: ${link.active}`);
console.log(` URL: https://justusebag.xyz/pay/${link._id}`);
}
main().catch(console.error);Run it:
npx tsx create-link.tsimport os
import requests
API_KEY = os.environ["BAG_API_KEY"]
BASE_URL = "https://justusebag.xyz"
# Create a payment link for a $29.99 product on Base Sepolia (testnet)
response = requests.post(
f"{BASE_URL}/api/payment-links",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
json={
"name": "Pro Plan", # Display name shown on the checkout page
"amount": 29.99, # Price in USD
"network": "base_sepolia", # Testnet network — no real money moves
},
)
result = response.json()
if result["status"] == "success":
link = result["data"]
print("Payment link created:")
print(f" ID: {link['_id']}")
print(f" Name: {link['name']}")
print(f" Amount: ${link['amount']} {link['currency']}")
print(f" Token: {link['token']}")
print(f" Active: {link['active']}")
print(f" URL: https://justusebag.xyz/pay/{link['_id']}")
else:
print(f"Error: {result.get('message', 'Unknown error')}")Run it:
python create_link.pyWhat you get back
{
"_id": "665f1a2b3c4d5e6f7a8b9c0d",
"name": "Pro Plan",
"amount": 29.99,
"currency": "USD",
"network": "base_sepolia",
"token": "USDC",
"active": true,
"merchantWalletAddress": "0xYourWalletAddress",
"totalCollected": 0,
"totalTransactions": 0,
"createdAt": "2026-03-01T12:00:00.000Z",
"updatedAt": "2026-03-01T12:00:00.000Z"
}| Field | What it means |
|---|---|
_id | Unique identifier. Your checkout URL is https://justusebag.xyz/pay/{_id} |
amount | Price in USD |
currency | Always USD (default) |
network | The blockchain network this link accepts payments on |
token | The token customers pay with — defaults to USDC |
active | Whether the link accepts new payments |
merchantWalletAddress | The wallet that receives funds |
totalCollected / totalTransactions | Running totals — both start at 0 |
Save the _id. Your checkout page is live at https://justusebag.xyz/pay/{_id}. Share this URL with customers, embed it in your app, or redirect to it from a "Buy" button.
Handle the webhook
When a customer pays, the transaction confirms on-chain asynchronously. Bag sends a POST request to your server when it's done. Webhooks are the source of truth for payment status — never rely on client-side confirmation to fulfill an order.
Events you need to handle
| Event | When it fires | What to do |
|---|---|---|
payment.completed | Payment confirmed on-chain | Fulfill the order, grant access, send a receipt |
payment.failed | Transaction reverted or timed out | Notify the customer, allow retry |
Get your webhook secret
- Go to the Bag dashboard → Developer Settings → Webhooks
- Enter your server URL (e.g.
https://yourapp.com/webhooks/bag) - Copy the webhook secret — it starts with
whsec_and is shown once
export BAG_WEBHOOK_SECRET="whsec_your_secret_here"Local development
Your webhook endpoint needs to be reachable from the internet. For local development, use ngrok to tunnel traffic to your machine:
ngrok http 4000Then register the ngrok URL (e.g. https://abc123.ngrok.io/webhooks/bag) as your webhook endpoint in the dashboard.
The webhook handler
Every webhook request includes two headers:
| Header | Value |
|---|---|
X-Webhook-Signature | HMAC-SHA256 hex digest of the raw request body |
X-Webhook-Event | Event name (e.g. payment.completed) |
Always verify the signature before processing. If it doesn't match, the request didn't come from Bag — reject it immediately.
import express from "express";
import crypto from "crypto";
const app = express();
const WEBHOOK_SECRET = process.env.BAG_WEBHOOK_SECRET!;
app.post(
"/webhooks/bag",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.headers["x-webhook-signature"] as string;
const event = req.headers["x-webhook-event"] as string;
const rawBody = req.body as Buffer;
// Verify the HMAC-SHA256 signature against your webhook secret
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(rawBody)
.digest("hex");
if (
!signature ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
) {
console.error("Invalid webhook signature — rejecting request");
res.status(401).json({ error: "Invalid signature" });
return;
}
const payload = JSON.parse(rawBody.toString());
console.log(`Received event: ${event}`);
console.log(`Timestamp: ${payload.timestamp}`);
switch (event) {
case "payment.completed": {
const { sessionId, txHash, amount, network } = payload.data;
console.log(`Payment completed: ${amount} USDC on ${network}`);
console.log(` Session: ${sessionId}`);
console.log(` Tx Hash: ${txHash}`);
// TODO: fulfill the order — grant access, send receipt, etc.
break;
}
case "payment.failed": {
const { sessionId, reason } = payload.data;
console.log(`Payment failed: ${reason}`);
console.log(` Session: ${sessionId}`);
// TODO: notify the customer, allow retry
break;
}
default:
console.log(`Unhandled event: ${event}`);
}
res.status(200).json({ received: true });
}
);
app.listen(4000, () => {
console.log("Webhook server listening on http://localhost:4000");
});Install dependencies and run:
npm install express
npx tsx webhook-server.tsimport os
import hmac
import hashlib
import json
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["BAG_WEBHOOK_SECRET"]
@app.route("/webhooks/bag", methods=["POST"])
def handle_webhook():
signature = request.headers.get("X-Webhook-Signature", "")
event = request.headers.get("X-Webhook-Event", "")
raw_body = request.get_data()
# Verify the HMAC-SHA256 signature against your webhook secret
expected = hmac.new(
WEBHOOK_SECRET.encode(),
raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
print("Invalid webhook signature — rejecting request")
return jsonify({"error": "Invalid signature"}), 401
payload = json.loads(raw_body)
print(f"Received event: {event}")
print(f"Timestamp: {payload['timestamp']}")
if event == "payment.completed":
data = payload["data"]
print(f"Payment completed: {data['amount']} USDC on {data['network']}")
print(f" Session: {data['sessionId']}")
print(f" Tx Hash: {data['txHash']}")
# TODO: fulfill the order — grant access, send receipt, etc.
elif event == "payment.failed":
data = payload["data"]
print(f"Payment failed: {data['reason']}")
print(f" Session: {data['sessionId']}")
# TODO: notify the customer, allow retry
else:
print(f"Unhandled event: {event}")
return jsonify({"received": True}), 200
if __name__ == "__main__":
app.run(port=4000)Install dependencies and run:
pip install flask
python webhook_server.pyWebhook payload examples
payment.completed:
{
"event": "payment.completed",
"data": {
"sessionId": "665f1a2b3c4d5e6f7a8b9c0d",
"txHash": "0xabc123def456789...",
"merchantWalletAddress": "0xYourWalletAddress",
"amount": 29.99,
"network": "base_sepolia"
},
"timestamp": "2026-03-01T12:05:00.000Z"
}payment.failed:
{
"event": "payment.failed",
"data": {
"sessionId": "665f1a2b3c4d5e6f7a8b9c0d",
"reason": "TX_REVERTED"
},
"timestamp": "2026-03-01T12:05:00.000Z"
}Handle duplicates. Bag may deliver the same event more than once. Use sessionId or txHash as an idempotency key to avoid fulfilling the same order twice.
Test it end-to-end
Everything you've built so far uses test mode. Here's how to run a full payment without spending real money.
Get testnet tokens
You need testnet USDC to pay with and native tokens for gas fees.
| What | Where to get it |
|---|---|
| Testnet USDC (Base Sepolia / Eth Sepolia) | Circle USDC Faucet |
| Testnet USDC (Solana Devnet) | Use devnet USDC mint 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU |
| Base Sepolia ETH (gas) | Coinbase Faucet |
| Sepolia ETH (gas) | Sepolia Faucet |
| Devnet SOL (gas) | Solana Faucet |
Walk through a payment
- Open the checkout URL in your browser:
https://justusebag.xyz/pay/YOUR_PAYMENT_LINK_ID - Connect a wallet (MetaMask, Coinbase Wallet, or Phantom for Solana) on the correct testnet
- Approve the USDC transfer — the checkout UI walks you through it
- Wait for on-chain confirmation — usually a few seconds on testnets
- Check your webhook server — you should see a
payment.completedevent logged - Check the dashboard — the transaction appears under your payment link with full details
Expected webhook output
When the payment confirms, your webhook server logs something like:
Received event: payment.completed
Timestamp: 2026-03-01T12:05:00.000Z
Payment completed: 29.99 USDC on base_sepolia
Session: 665f1a2b3c4d5e6f7a8b9c0d
Tx Hash: 0xabc123def456789...Verification checklist
- Payment link created successfully
- Webhook server running and reachable (via ngrok or deployed)
- Checkout page loads at
https://justusebag.xyz/pay/YOUR_LINK_ID - Payment completes on testnet
- Webhook received with valid signature
- Transaction visible in dashboard
What's next
Build a Custom Checkout
Control the payment flow from your own UI with the Checkout Sessions API.
Handle Webhooks (Deep Dive)
Retry behavior, best practices, and advanced webhook patterns.
Accept Payments on Multiple Chains
Let customers pay on Base, Ethereum, Polygon, or Solana.
Calculate Tax Before Checkout
Request tax quotes ahead of time for custom checkout flows.
Request a Payout
Withdraw funds from your Bag balance to your wallet.
Go Live Checklist
Complete KYB, generate a live key, and switch to production.
API Reference
Full endpoint documentation for every Bag API.
Quick reference
| Resource | URL |
|---|---|
| Dashboard | justusebag.xyz |
| SDK (npm) | npm install @getbagsapp/sdk |
| API Base URL | https://justusebag.xyz |
| OpenAPI Spec | justusebag.xyz/openapi.yaml |