Sample Integration
The fastest way to accept USDC — a verified checkout + webhook in ~40 lines of plain code. No SDK, just HTTP.
A complete working integration in one copy-paste block. Plain HTTP, inline HMAC verification — no SDKs, no hidden magic. Read every line, own every line.
Prefer a guided 10-minute walkthrough? Start with the Quickstart. This page is pure code.
Prerequisites
Set these two environment variables before running any example below:
export BAG_API_KEY="bag_test_sk_your_key_here"
export BAG_WEBHOOK_SECRET="whsec_your_secret_here"Get them from the dashboard:
- API Key: Developer Settings → Create API Key
- Webhook Secret: Developer Settings → Webhooks → add an endpoint → copy the secret shown once
30-second smoke test
Confirm your API key works before writing any code. Create a product first, then a checkout session:
# 1. Create a catalog product
curl -X POST https://www.getbags.app/api/v1/products \
-H "Authorization: Bearer $BAG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Test Product","amount":9.99,"network":"base_sepolia"}'
# 2. Create a checkout session (use productId from step 1)
curl -X POST https://www.getbags.app/api/v1/checkouts \
-H "Authorization: Bearer $BAG_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"productId": "YOUR_PRODUCT_UUID",
"network": "base_sepolia",
"externalCustomerId": "cus_123",
"metadata": { "orderId": "ord_456" }
}'You should get back { "status": "success", "data": { "url": "...?session=...", "id": "...", ... } }. Open the URL and pay with a testnet wallet.
POST /api/v1/checkout (singular) is deprecated — it creates a reusable payment link, not a session. Prefer POST /api/v1/checkouts for new integrations.
The signature format
Every webhook from Bags arrives with two headers:
X-Webhook-Event— the event name (e.g.payment.completed)X-Webhook-Signature—t=<unix_timestamp>,v1=<hex_hmac_sha256>
To verify, compute HMAC-SHA256 over the exact string "${timestamp}.${rawBody}" using your webhook secret, then compare against v1 in constant time. Reject anything older than 5 minutes.
That's the whole protocol. Every snippet below implements exactly that.
Full integration
Pick your stack. Every tab is a complete, runnable server — one file, copy-paste, zero dependencies beyond the framework itself.
Pure Node, no framework, no dependencies. Uses Node 18+ built-in fetch and node:crypto.
server.mjs
import { createServer } from 'node:http'
import crypto from 'node:crypto'
const API_KEY = process.env.BAG_API_KEY
const WEBHOOK_SECRET = process.env.BAG_WEBHOOK_SECRET
const BASE_URL = 'https://www.getbags.app'
async function ensureProduct({ name, amount, network = 'base_sepolia' }) {
const res = await fetch(`${BASE_URL}/api/v1/products`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, amount, network }),
})
const json = await res.json()
if (!res.ok) throw new Error(json.message ?? 'product create failed')
return json.data
}
async function createCheckout({ productId, network = 'base_sepolia', externalCustomerId, metadata = {} }) {
const res = await fetch(`${BASE_URL}/api/v1/checkouts`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({ productId, network, externalCustomerId, metadata }),
})
const json = await res.json()
if (!res.ok) throw new Error(json.message ?? 'checkout failed')
return json.data
}
function verifyBagSignature({ rawBody, signatureHeader, secret, maxAgeSeconds = 300 }) {
if (!signatureHeader) throw new Error('missing signature header')
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.split('=').map((s) => s.trim())),
)
const timestamp = Number(parts.t)
const received = parts.v1
if (!timestamp || !received) throw new Error('malformed signature')
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > maxAgeSeconds) {
throw new Error('signature expired')
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
if (
received.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))
) {
throw new Error('signature mismatch')
}
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = []
req.on('data', (c) => chunks.push(c))
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
req.on('error', reject)
})
}
const server = createServer(async (req, res) => {
try {
if (req.method === 'POST' && req.url === '/create-checkout') {
const body = JSON.parse(await readBody(req))
const product = body.productId
? { id: body.productId }
: await ensureProduct({ name: body.name ?? 'Product', amount: body.amount ?? 9.99, network: body.network })
const checkout = await createCheckout({
productId: product.id,
network: body.network ?? 'base_sepolia',
externalCustomerId: body.externalCustomerId ?? `cus_${Date.now()}`,
metadata: body.metadata ?? { orderId: `ord_${Date.now()}` },
})
res.writeHead(200, { 'Content-Type': 'application/json' })
return res.end(JSON.stringify({ checkoutUrl: checkout.url, checkoutId: checkout.id }))
}
if (req.method === 'POST' && req.url === '/webhooks/bag') {
const rawBody = await readBody(req)
verifyBagSignature({
rawBody,
signatureHeader: req.headers['x-webhook-signature'],
secret: WEBHOOK_SECRET,
})
const { event, data, webhookDeliveryId } = JSON.parse(rawBody)
if (event === 'payment.completed' || event === 'checkout.completed') {
console.log(`✅ Paid: session=${data.sessionId} external=${data.externalCustomerId} tx=${data.txHash}`)
}
res.writeHead(200, { 'Content-Type': 'application/json' })
return res.end(JSON.stringify({ received: true, webhookDeliveryId }))
}
res.writeHead(404).end()
} catch (err) {
console.error(err)
res.writeHead(err.message?.includes('signature') ? 401 : 500).end(err.message)
}
})
server.listen(4000, () => console.log('→ http://localhost:4000'))Run: node server.mjs
app/api/create-checkout/route.ts
import { NextResponse } from 'next/server'
import crypto from 'node:crypto'
const BASE_URL = 'https://www.getbags.app'
export async function POST(req: Request) {
const body = await req.json()
const network = body.network ?? 'base_sepolia'
const apiKey = process.env.BAG_API_KEY!
let productId = body.productId as string | undefined
if (!productId) {
const productRes = await fetch(`${BASE_URL}/api/v1/products`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: body.name ?? 'Product',
amount: body.amount ?? 9.99,
network,
}),
})
const productJson = await productRes.json()
if (!productRes.ok) {
return NextResponse.json({ error: productJson.message }, { status: 500 })
}
productId = productJson.data.id
}
const res = await fetch(`${BASE_URL}/api/v1/checkouts`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
productId,
network,
externalCustomerId: body.externalCustomerId ?? `cus_${Date.now()}`,
metadata: body.metadata ?? { orderId: `ord_${Date.now()}` },
}),
})
const json = await res.json()
if (!res.ok) return NextResponse.json({ error: json.message }, { status: 500 })
return NextResponse.json({ checkoutId: json.data.id, checkoutUrl: json.data.url })
}app/api/webhooks/bag/route.ts
import crypto from 'node:crypto'
import { NextResponse } from 'next/server'
export const runtime = 'nodejs'
function verifyBagSignature(rawBody: string, sigHeader: string | null, secret: string) {
if (!sigHeader) throw new Error('missing signature')
const parts = Object.fromEntries(
sigHeader.split(',').map((p) => p.split('=').map((s) => s.trim())),
) as { t?: string; v1?: string }
const timestamp = Number(parts.t)
const received = parts.v1
if (!timestamp || !received) throw new Error('malformed signature')
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) throw new Error('expired')
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
if (
received.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))
) {
throw new Error('mismatch')
}
}
export async function POST(req: Request) {
const rawBody = await req.text()
try {
verifyBagSignature(
rawBody,
req.headers.get('x-webhook-signature'),
process.env.BAG_WEBHOOK_SECRET!,
)
} catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 401 })
}
const { event, data, webhookDeliveryId } = JSON.parse(rawBody)
switch (event) {
case 'payment.completed':
case 'checkout.completed':
console.log(
`✅ Paid: session=${data.sessionId} external=${data.externalCustomerId} tx=${data.txHash}`,
)
break
case 'payment.failed':
console.log(`❌ Failed: ${data.reason}`)
break
}
return NextResponse.json({ received: true, webhookDeliveryId })
}Run: next dev. Then tunnel and register the webhook endpoint:
npx ngrok http 3000
# Copy the https URL, register https://<subdomain>.ngrok.io/api/webhooks/bag
# in https://www.getbags.app/dashboard/webhooksInstall:
npm install expressserver.mjs
import express from 'express'
import crypto from 'node:crypto'
const API_KEY = process.env.BAG_API_KEY
const WEBHOOK_SECRET = process.env.BAG_WEBHOOK_SECRET
const BASE_URL = 'https://www.getbags.app'
const app = express()
app.post('/create-checkout', express.json(), async (req, res) => {
const { name, amount, network = 'base_sepolia', productId, externalCustomerId, metadata } = req.body
let resolvedProductId = productId
if (!resolvedProductId) {
const productRes = await fetch(`${BASE_URL}/api/v1/products`, {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: name ?? 'Product', amount: amount ?? 9.99, network }),
})
const productJson = await productRes.json()
if (!productRes.ok) return res.status(500).json({ error: productJson.message })
resolvedProductId = productJson.data.id
}
const r = await fetch(`${BASE_URL}/api/v1/checkouts`, {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
productId: resolvedProductId,
network,
externalCustomerId: externalCustomerId ?? `cus_${Date.now()}`,
metadata: metadata ?? { orderId: `ord_${Date.now()}` },
}),
})
const json = await r.json()
if (!r.ok) return res.status(500).json({ error: json.message })
res.json({ checkoutId: json.data.id, checkoutUrl: json.data.url })
})
// Webhook route MUST receive the raw body — no global json parser before this.
app.post(
'/webhooks/bag',
express.raw({ type: 'application/json' }),
(req, res) => {
const rawBody = req.body.toString('utf8')
const sigHeader = req.headers['x-webhook-signature'] ?? ''
const parts = Object.fromEntries(
sigHeader.split(',').map((p) => p.split('=').map((s) => s.trim())),
)
const timestamp = Number(parts.t)
const received = parts.v1
if (!timestamp || !received) return res.status(401).json({ error: 'malformed' })
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) {
return res.status(401).json({ error: 'expired' })
}
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
if (
received.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))
) {
return res.status(401).json({ error: 'mismatch' })
}
const { event, data, webhookDeliveryId } = JSON.parse(rawBody)
if (event === 'payment.completed' || event === 'checkout.completed') {
console.log(`✅ Paid: ${data.sessionId} external=${data.externalCustomerId} tx=${data.txHash}`)
}
res.json({ received: true, webhookDeliveryId })
},
)
app.listen(4000, () => console.log('→ http://localhost:4000'))Run: node server.mjs
Hono runs identically on Node, Bun, Deno, and Cloudflare Workers. Uses Web Crypto so the same code runs on Workers with zero tweaks.
Install:
npm install honosrc/index.ts
import { Hono } from 'hono'
type Env = { BAG_API_KEY: string; BAG_WEBHOOK_SECRET: string }
const BASE_URL = 'https://www.getbags.app'
const app = new Hono<{ Bindings: Env }>()
app.post('/create-checkout', async (c) => {
const body = await c.req.json()
const network = body.network ?? 'base_sepolia'
let productId = body.productId as string | undefined
if (!productId) {
const productRes = await fetch(`${BASE_URL}/api/v1/products`, {
method: 'POST',
headers: {
Authorization: `Bearer ${c.env.BAG_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: body.name ?? 'Product',
amount: body.amount ?? 9.99,
network,
}),
})
const productJson = await productRes.json()
if (!productRes.ok) return c.json({ error: productJson.message }, 500)
productId = productJson.data.id
}
const r = await fetch(`${BASE_URL}/api/v1/checkouts`, {
method: 'POST',
headers: {
Authorization: `Bearer ${c.env.BAG_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
productId,
network,
externalCustomerId: body.externalCustomerId ?? `cus_${Date.now()}`,
metadata: body.metadata ?? { orderId: `ord_${Date.now()}` },
}),
})
const json = await r.json()
if (!r.ok) return c.json({ error: json.message }, 500)
return c.json({ checkoutId: json.data.id, checkoutUrl: json.data.url })
})
async function verifyBagSignature(
rawBody: string,
sigHeader: string | undefined,
secret: string,
) {
if (!sigHeader) throw new Error('missing')
const parts = Object.fromEntries(
sigHeader.split(',').map((p) => p.split('=').map((s) => s.trim())),
) as { t?: string; v1?: string }
const timestamp = Number(parts.t)
const received = parts.v1
if (!timestamp || !received) throw new Error('malformed')
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) throw new Error('expired')
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
)
const sig = await crypto.subtle.sign(
'HMAC',
key,
new TextEncoder().encode(`${timestamp}.${rawBody}`),
)
const expected = [...new Uint8Array(sig)]
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
if (received.length !== expected.length) throw new Error('mismatch')
let diff = 0
for (let i = 0; i < received.length; i++) {
diff |= received.charCodeAt(i) ^ expected.charCodeAt(i)
}
if (diff !== 0) throw new Error('mismatch')
}
app.post('/webhooks/bag', async (c) => {
const rawBody = await c.req.text()
try {
await verifyBagSignature(
rawBody,
c.req.header('x-webhook-signature'),
c.env.BAG_WEBHOOK_SECRET,
)
} catch (err) {
return c.json({ error: (err as Error).message }, 401)
}
const { event, data, webhookDeliveryId } = JSON.parse(rawBody)
if (event === 'payment.completed' || event === 'checkout.completed') {
console.log(`✅ Paid: ${data.sessionId} external=${data.externalCustomerId} tx=${data.txHash}`)
}
return c.json({ received: true, webhookDeliveryId })
})
export default appDeploy to Cloudflare Workers:
npx wrangler secret put BAG_API_KEY
npx wrangler secret put BAG_WEBHOOK_SECRET
npx wrangler deployInstall:
pip install flask requestsserver.py
import os, hmac, hashlib, json, time, uuid
from flask import Flask, request, jsonify
import requests
API_KEY = os.environ["BAG_API_KEY"]
WEBHOOK_SECRET = os.environ["BAG_WEBHOOK_SECRET"]
BASE_URL = "https://www.getbags.app"
MAX_AGE_S = 300
app = Flask(__name__)
@app.post("/create-checkout")
def create_checkout():
body = request.json or {}
network = body.get("network", "base_sepolia")
product_id = body.get("productId")
if not product_id:
product_res = requests.post(
f"{BASE_URL}/api/v1/products",
headers={"Authorization": f"Bearer {API_KEY}"},
json={
"name": body.get("name", "Product"),
"amount": body.get("amount", 9.99),
"network": network,
},
)
product_res.raise_for_status()
product_id = product_res.json()["data"]["id"]
r = requests.post(
f"{BASE_URL}/api/v1/checkouts",
headers={
"Authorization": f"Bearer {API_KEY}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"productId": product_id,
"network": network,
"externalCustomerId": body.get("externalCustomerId", f"cus_{int(time.time())}"),
"metadata": body.get("metadata", {"orderId": f"ord_{int(time.time())}"}),
},
)
r.raise_for_status()
data = r.json()["data"]
return jsonify(checkoutId=data["id"], checkoutUrl=data["url"])
@app.post("/webhooks/bag")
def webhook():
raw = request.get_data()
sig = request.headers.get("X-Webhook-Signature", "")
parts = dict(p.split("=", 1) for p in sig.split(",") if "=" in p)
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1 or abs(int(time.time()) - int(t)) > MAX_AGE_S:
return jsonify(error="invalid or expired signature"), 401
expected = hmac.new(
WEBHOOK_SECRET.encode(),
f"{t}.{raw.decode()}".encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, v1):
return jsonify(error="signature mismatch"), 401
payload = json.loads(raw)
event = payload.get("event") or request.headers.get("X-Webhook-Event", "")
data = payload.get("data", {})
if event == "payment.completed" or event == "checkout.completed":
print(f"✅ Paid: {data.get('sessionId')} external={data.get('externalCustomerId')} tx={data.get('txHash')}")
elif event == "payment.failed":
print(f"❌ Failed: {data.get('reason')}")
return jsonify(received=True, webhookDeliveryId=payload.get("webhookDeliveryId"))
if __name__ == "__main__":
app.run(port=4000, debug=True)Run: python server.py
Test the whole loop
With your server running and ngrok pointed at /webhooks/bag:
# 1. Create a checkout (uses catalog product if productId omitted)
curl -X POST http://localhost:4000/create-checkout \
-H "Content-Type: application/json" \
-d '{
"name":"Pro Plan",
"amount":29.99,
"network":"base_sepolia",
"externalCustomerId":"cus_smoke_001",
"metadata":{"orderId":"ord_smoke_001"}
}'
# → { "checkoutId": "...", "checkoutUrl": "https://www.getbags.app/pay/...?session=..." }
# 2. Open checkoutUrl in a browser, pay with a sandbox wallet
# 3. Watch your server log — "✅ Paid: ..." fires within secondsYou can also replay past deliveries from the dashboard: Developer → Webhooks → click a delivery → Replay.
Webhook events you can receive
| Event | Fires when | Typical data fields |
|---|---|---|
checkout.completed | Merchant-initiated checkout session paid | sessionId, productId, externalCustomerId, customerId, metadata, txHash, network |
payment.completed | Legacy alias of checkout.completed | Same as checkout.completed |
payment.failed | Legacy alias of checkout.failed | sessionId, reason, message |
checkout.expired | Unpaid after 30 minutes | sessionId, expiresAt |
checkout.cancelled | Cancelled before broadcast | sessionId, reason |
subscription.* events are coming soon — not dispatched in v0 production flows.
Full payloads in the Webhook Events Reference.
Why this is safe in production
- HMAC-SHA256 signature over
${timestamp}.${rawBody}— tamper-proof. - Timestamp window (300s default) — replay attacks are rejected.
- Timing-safe comparison — constant-time, never leaks bytes via early-exit.
- Idempotency — every delivery carries
webhookDeliveryId; store it and skip duplicates. - Raw body only — never parse-then-verify; re-serialized JSON won't match the signature.