Handle Webhooks Securely
Register webhook endpoints, verify signatures, and process payment events from Bag.
Handle Webhooks Securely
When a payment completes or fails, Bag 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 |
|---|---|---|
payment.completed | Payment confirmed on-chain | Fulfill the order, grant access, send receipt |
payment.failed | Transaction reverted or timed out | Notify the customer, allow retry |
subscription.created | Recurring payment subscription created | Store subscription ID, set up recurring access |
subscription.updated | Subscription plan or details changed | Update stored plan info |
subscription.renewed | Recurring payment succeeded | Extend access period |
subscription.renewal_due | Upcoming renewal requires action | Notify customer, ensure funds available |
subscription.past_due | Renewal payment failed, grace period active | Warn customer, retry or restrict access |
subscription.canceled | Subscription permanently canceled | Revoke access at period end |
Register a webhook endpoint
- Go to the Bag 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://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. payment.completed) |
Always verify the signature before processing. If it doesn't match, the request didn't come from Bag.
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 === "payment.completed") {
const { sessionId, txHash, amount, network } = payload.data;
// Fulfill the order
}
if (event === "payment.failed") {
const { sessionId, reason } = payload.data;
// Handle the failure
}
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 == "payment.completed":
data = payload["data"]
# Fulfill the order
if event == "payment.failed":
data = payload["data"]
# Handle the failure
return jsonify({"received": True}), 200The webhook payload
payment.completed
{
"event": "payment.completed",
"data": {
"sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"paymentLinkId": "3b9f8f22-7f1f-4c3a-8f5a-2d7a0d3e9b1c",
"txHash": "0xabc123def456...",
"merchantWalletAddress": "0xYourWalletAddress",
"amount": 29.99,
"network": "base_sepolia"
},
"webhookDeliveryId": "d4e5f6a1-b2c3-7890-abcd-ef1234567890",
"timestamp": "2026-03-01T12:05:00.000Z"
}payment.failed
{
"event": "payment.failed",
"data": {
"sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"paymentLinkId": "3b9f8f22-7f1f-4c3a-8f5a-2d7a0d3e9b1c",
"reason": "Transaction reverted"
},
"webhookDeliveryId": "d4e5f6a1-b2c3-7890-abcd-ef1234567890",
"timestamp": "2026-03-01T12:05:00.000Z"
}subscription.created
{
"event": "subscription.created",
"data": {
"subscriptionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"paymentLinkId": "3b9f8f22-7f1f-4c3a-8f5a-2d7a0d3e9b1c",
"customerEmail": "customer@example.com",
"status": "active",
"currentPeriodEnd": "2026-04-17T12:05:00.000Z",
"livemode": true
},
"webhookDeliveryId": "d4e5f6a1-b2c3-7890-abcd-ef1234567890",
"timestamp": "2026-03-17T12:05:00.000Z"
}Retry behavior
If your endpoint returns a non-2xx status code (or doesn't respond), Bag retries up to 5 times with exponential backoff.
| Attempt | Delay | Method |
|---|---|---|
| 1 | Immediate | Inline |
| 2 | +5 seconds | Inline |
| 3 | +10 seconds | Inline |
| 4 | +40 seconds | Background retry |
| 5 | +80 seconds | Background retry |
After 5 failed attempts, the delivery is marked as failed. You can retry manually from the dashboard.
Best practices
- Return 200 quickly. Do your heavy processing asynchronously (queue the event, respond immediately). If your endpoint takes too long, Bag may retry.
- Handle duplicates. Use the
sessionIdortxHashas an idempotency key. Bag may deliver the same event more than once. - Use HTTPS. Bag 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.