Bag is live โ€” accept USDC & card payments globally. Get started โ†’
BagBag 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
subscription.createdRecurring payment subscription createdStore subscription ID, set up recurring access
subscription.updatedSubscription plan or details changedUpdate stored plan info
subscription.renewedRecurring payment succeededExtend access period
subscription.renewal_dueUpcoming renewal requires actionNotify customer, ensure funds available
subscription.past_dueRenewal payment failed, grace period activeWarn customer, retry or restrict access
subscription.canceledSubscription permanently canceledRevoke access at period end

Register a webhook endpoint

  1. Go to the Bag 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://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. 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}), 200

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

AttemptDelayMethod
1ImmediateInline
2+5 secondsInline
3+10 secondsInline
4+40 secondsBackground retry
5+80 secondsBackground 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 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. 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