Handle Webhooks Securely
Register webhook endpoints, verify signatures, and process payment events from Bags.
When a payment completes or fails, Bags sends a POST request to your server. Webhooks are the source of truth for payment status โ don't rely on client-side confirmation.
Webhook events
| Event | When it fires | What to do |
|---|---|---|
checkout.completed | Hosted checkout paid and confirmed on-chain | Fulfill the order, grant access, send receipt |
checkout.failed | Transaction reverted or verification failed | Notify the customer, surface the reason, allow retry |
checkout.cancelled | Customer cancelled before broadcast | Release inventory holds, mark order cancelled |
checkout.expired | Checkout was not paid within 30 minutes | Release inventory holds, create a new checkout |
payment.refunded | Refund processed against a completed transaction | Reverse fulfillment, credit the customer, mark order refunded |
Use checkout.* events. They cover the full lifecycle and are keyed on
sessionId, which matches the id returned by
POST /api/v1/checkouts.
Full payload schemas are in the
Webhook Events reference.
Register a webhook endpoint
- Go to the Bags dashboard โ Developer Settings โ Webhooks.
- Enter your server URL (e.g.
https://yourapp.com/webhooks/bag). - Copy the webhook secret (
whsec_*) โ it's shown once.
Or register via the API:
curl -X POST https://www.getbags.app/api/webhooks \
-H "Authorization: Bearer $BAG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://yourapp.com/webhooks/bag"}'Store the secret as an environment variable:
export BAG_WEBHOOK_SECRET="whsec_your_secret_here"Verify the signature
Every webhook request includes two headers:
| Header | Value |
|---|---|
X-Webhook-Signature | Format: t=UNIX_TIMESTAMP,v1=HMAC_SHA256_HEX. The HMAC is computed over {timestamp}.{rawBody}. |
X-Webhook-Event | Event name (e.g. checkout.completed) |
Always verify the signature before processing. If it doesn't match, the request didn't come from Bags.
import express from "express";
import crypto from "crypto";
const app = express();
const WEBHOOK_SECRET = process.env.BAG_WEBHOOK_SECRET!;
const MAX_SIGNATURE_AGE_S = 300;
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;
const parts = (signature || "").split(",").reduce((acc, part) => {
const [k, v] = part.split("=");
if (k && v) acc[k.trim()] = v.trim();
return acc;
}, {} as Record<string, string>);
const t = parts["t"];
const v1 = parts["v1"];
const age = Math.floor(Date.now() / 1000) - Number(t);
if (!t || !v1 || age > MAX_SIGNATURE_AGE_S) {
res.status(401).json({ error: "Invalid or expired signature" });
return;
}
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(`${t}.${rawBody.toString("utf8")}`)
.digest("hex");
if (
v1.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
) {
res.status(401).json({ error: "Invalid signature" });
return;
}
const payload = JSON.parse(rawBody.toString());
if (event === "checkout.completed") {
const { sessionId, txHash, amount, network } = payload.data;
// Fulfill the order โ this is your source of truth
}
if (event === "checkout.failed") {
const { sessionId, reason, message } = payload.data;
// Notify the customer, surface the reason, allow retry
}
if (event === "checkout.cancelled") {
const { sessionId } = payload.data;
// Release inventory holds, mark the order cancelled
}
if (event === "checkout.expired") {
const { sessionId, expiresAt } = payload.data;
// Release inventory holds, optionally email a fresh checkout link
}
res.status(200).json({ received: true });
}
);
app.listen(4000);import os
import hmac
import hashlib
import json
import time
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["BAG_WEBHOOK_SECRET"]
MAX_SIGNATURE_AGE_S = 300
@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()
parts = {}
for part in signature.split(","):
if "=" in part:
k, v = part.split("=", 1)
parts[k.strip()] = v.strip()
t = parts.get("t", "")
v1 = parts.get("v1", "")
age = int(time.time()) - int(t or "0")
if not t or not v1 or age > MAX_SIGNATURE_AGE_S:
return jsonify({"error": "Invalid or expired signature"}), 401
expected = hmac.new(
WEBHOOK_SECRET.encode(),
f"{t}.{raw_body.decode('utf-8')}".encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, v1):
return jsonify({"error": "Invalid signature"}), 401
payload = json.loads(raw_body)
if event == "checkout.completed":
data = payload["data"]
# Fulfill the order โ this is your source of truth
if event == "checkout.failed":
data = payload["data"]
# Notify the customer, surface data["reason"] / data["message"]
if event == "checkout.cancelled":
data = payload["data"]
# Release inventory holds, mark the order cancelled
if event == "checkout.expired":
data = payload["data"]
# Release inventory holds, optionally email a fresh checkout link
return jsonify({"received": True}), 200The webhook payload
See the Webhook Events reference for the full schema of every event. A quick tour of the most common ones:
checkout.completed
{
"event": "checkout.completed",
"data": {
"sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"paymentLinkId": "3b9f8f22-7f1f-4c3a-8f5a-2d7a0d3e9b1c",
"txHash": "0xabc123def456...",
"merchantWalletAddress": "0xYourWalletAddress",
"amount": "29.99",
"network": "base_sepolia",
"livemode": false
},
"webhookDeliveryId": "d4e5f6a1-b2c3-7890-abcd-ef1234567890",
"timestamp": "2026-03-01T12:05:00.000Z"
}checkout.failed
{
"event": "checkout.failed",
"data": {
"sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"paymentLinkId": "3b9f8f22-7f1f-4c3a-8f5a-2d7a0d3e9b1c",
"txHash": "0xabc123def456...",
"network": "base_sepolia",
"amount": "29.99",
"reason": "INVALID_TRANSFER",
"message": "Transferred amount (10.00) does not match expected (29.99)",
"livemode": false
},
"webhookDeliveryId": "d4e5f6a1-b2c3-7890-abcd-ef1234567890",
"timestamp": "2026-03-01T12:05:00.000Z"
}checkout.cancelled
{
"event": "checkout.cancelled",
"data": {
"sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"paymentLinkId": "3b9f8f22-7f1f-4c3a-8f5a-2d7a0d3e9b1c",
"network": "base_sepolia",
"amount": "29.99",
"reason": "customer_cancelled",
"livemode": false
},
"webhookDeliveryId": "d4e5f6a1-b2c3-7890-abcd-ef1234567890",
"timestamp": "2026-03-01T12:07:00.000Z"
}checkout.expired
{
"event": "checkout.expired",
"data": {
"sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"paymentLinkId": "3b9f8f22-7f1f-4c3a-8f5a-2d7a0d3e9b1c",
"network": "base_sepolia",
"amount": "29.99",
"expiresAt": "2026-03-01T13:00:00.000Z",
"livemode": false
},
"webhookDeliveryId": "d4e5f6a1-b2c3-7890-abcd-ef1234567890",
"timestamp": "2026-03-01T13:00:12.000Z"
}payment.refunded
{
"event": "payment.refunded",
"data": {
"refundId": "rfd_2f9b...",
"transactionId": "8c7e6d5f-1234-4abc-9def-0123456789ab",
"txHash": "0xrefund789...",
"amount": "29.99",
"token": "USDC",
"network": "base_sepolia",
"reason": "customer_requested",
"fullyRefunded": true,
"initiatedBy": "merchant",
"livemode": false
},
"webhookDeliveryId": "d4e5f6a1-b2c3-7890-abcd-ef1234567890",
"timestamp": "2026-03-02T09:15:00.000Z"
}Retry behavior
If your endpoint returns a non-2xx status code (or doesn't respond), Bags retries up to 8 times with exponential backoff over ~24 hours.
| Attempt | Delay after failure |
|---|---|
| 1 | Immediate |
| 2 | ~1 minute |
| 3 | ~5 minutes |
| 4 | ~30 minutes |
| 5 | ~2 hours |
| 6 | ~5 hours |
| 7 | ~10 hours |
| 8 | ~24 hours |
After 8 failed attempts, the delivery is marked as failed. You can retry manually from Developer Settings > Webhooks.
Best practices
- Return 200 quickly. Do your heavy processing asynchronously (queue the event, respond immediately). If your endpoint takes too long, Bags may retry.
- Handle duplicates. Use the
sessionIdortxHashas an idempotency key. Bags may deliver the same event more than once. - Use HTTPS. Bags rejects webhook URLs that aren't HTTPS. For local development, use a tunnel like ngrok to expose your localhost.
- Check signature age. Reject signatures older than 5 minutes to prevent replay attacks. The
t=value inX-Webhook-Signatureis a Unix timestamp โ compare it to the current time. - Log everything. Store the raw payload for debugging. You'll thank yourself later.
Local development
For local development, use a tunnel to expose your localhost:
ngrok http 4000Then register the ngrok URL (e.g. https://abc123.ngrok.io/webhooks/bag) as your webhook endpoint in the dashboard.