BagsBags Docs
Guides

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

EventWhen it firesWhat to do
checkout.completedHosted checkout paid and confirmed on-chainFulfill the order, grant access, send receipt
checkout.failedTransaction reverted or verification failedNotify the customer, surface the reason, allow retry
checkout.cancelledCustomer cancelled before broadcastRelease inventory holds, mark order cancelled
checkout.expiredCheckout was not paid within 30 minutesRelease inventory holds, create a new checkout
payment.refundedRefund processed against a completed transactionReverse 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

  1. Go to the Bags dashboard โ†’ Developer Settings โ†’ Webhooks.
  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://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:

HeaderValue
X-Webhook-SignatureFormat: t=UNIX_TIMESTAMP,v1=HMAC_SHA256_HEX. The HMAC is computed over {timestamp}.{rawBody}.
X-Webhook-EventEvent 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}), 200

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

AttemptDelay after failure
1Immediate
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 sessionId or txHash as 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 in X-Webhook-Signature is 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 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