BagsBags Docs
Getting Started

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:


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-Signaturet=<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/webhooks

Install:

npm install express

server.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 hono

src/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 app

Deploy to Cloudflare Workers:

npx wrangler secret put BAG_API_KEY
npx wrangler secret put BAG_WEBHOOK_SECRET
npx wrangler deploy

Install:

pip install flask requests

server.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 seconds

You can also replay past deliveries from the dashboard: Developer → Webhooks → click a delivery → Replay.


Webhook events you can receive

EventFires whenTypical data fields
checkout.completedMerchant-initiated checkout session paidsessionId, productId, externalCustomerId, customerId, metadata, txHash, network
payment.completedLegacy alias of checkout.completedSame as checkout.completed
payment.failedLegacy alias of checkout.failedsessionId, reason, message
checkout.expiredUnpaid after 30 minutessessionId, expiresAt
checkout.cancelledCancelled before broadcastsessionId, 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.

What's next

On this page