Bag Docs
Guides

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

EventWhen it firesWhat to do
payment.completedPayment confirmed on-chainFulfill the order, grant access, send receipt
payment.failedTransaction reverted or timed outNotify the customer, allow retry

Register a webhook endpoint

  1. Go to the Bag dashboardDeveloper SettingsWebhooks.
  2. Enter your server URL (e.g. https://yourapp.com/webhooks/bag).
  3. 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:

HeaderValue
X-Webhook-SignatureHMAC-SHA256 hex digest of the raw request body
X-Webhook-EventEvent 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}), 200

The 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.

AttemptDelay
1Immediate
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 sessionId or txHash as an idempotency key. Bag may deliver the same event more than once.
  • Use HTTPS. Bag rejects webhook URLs that aren't HTTPS (except localhost for 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 4000

Then register the ngrok URL (e.g. https://abc123.ngrok.io/webhooks/bag) as your webhook endpoint in the dashboard.


What's next

On this page