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 |
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://justusebag.xyz/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 | 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.
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;
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(rawBody)
.digest("hex");
if (
!signature ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
) {
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
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()
expected = hmac.new(
WEBHOOK_SECRET.encode(),
raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
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",
"txHash": "0xabc123def456...",
"merchantWalletAddress": "0xYourWalletAddress",
"amount": 29.99,
"network": "base_sepolia"
},
"timestamp": "2026-03-01T12:05:00.000Z"
}payment.failed
{
"event": "payment.failed",
"data": {
"sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"reason": "Transaction reverted"
},
"timestamp": "2026-03-01T12: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 |
|---|---|
| 1 | Immediate |
| 2 | ~1 minute |
| 3 | ~5 minutes |
| 4 | ~30 minutes |
| 5 | ~2 hours |
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 (except
localhostfor development). - 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.