Skip to main content
All guides
Intermediatewebhookshmacidempotency

Webhook Handling & Retry Logic

Receive events reliably: verify signatures, deduplicate, and survive retries with a dead-letter queue.

BettaPay TeamUpdated Jul 19, 202616 min read

Webhooks are how BettaPay tells your system that something happened — a payment succeeded, a settlement completed, a refund was issued. Getting them right means three things: proving the request really came from BettaPay (signature verification), never acting on the same event twice (deduplication), and gracefully surviving failures (retries and a dead-letter queue). This guide covers all three, in Node.js, Python, and PHP.

Prerequisite
You should already be creating payments — see Server-to-Server Integration. This guide focuses entirely on the receiving side.

Set up your webhook endpoint

In the dashboard under Settings → Webhooks, add the public URL BettaPay should POST to (for example https://api.yoursite.com/webhooks/bettapay) and select the events you care about. BettaPay generates a signing secret (prefixed whsec_) — copy it into your server's environment. Every request BettaPay sends carries these headers:

HeaderMeaning
X-BettaPay-SignatureHex HMAC-SHA256 of the raw request body, keyed by your signing secret.
X-BettaPay-TimestampUnix seconds when the event was sent. Reject if it's too old.
X-BettaPay-Event-IdStable id for this event — the key you dedupe on.
X-BettaPay-DeliveryUnique per delivery attempt; changes on each retry.

Develop locally with ngrok

BettaPay needs a public URL, but your dev server runs on localhost. ngrok tunnels a public URL to your machine:

terminal
# 1. Start your local server (example: port 3000)
node server.js

# 2. In another terminal, expose it
ngrok http 3000

# ngrok prints a public URL, e.g.:
#   Forwarding  https://a1b2-41-58-0-1.ngrok-free.app -> http://localhost:3000

# 3. Paste that URL + your path into Settings -> Webhooks:
#   https://a1b2-41-58-0-1.ngrok-free.app/webhooks/bettapay
Tip
The free ngrok URL changes each restart. Update the webhook URL in the dashboard when it does, or use a reserved domain to keep it stable.

Verify the signature

Compute the HMAC-SHA256 of the raw request body using your signing secret, and compare it to the X-BettaPay-Signature header with a constant-time comparison. Reject anything that doesn't match, and reject stale timestamps to prevent replay attacks.

Use the raw body
You must HMAC the exact bytes BettaPay sent. If your framework parses the body to JSON and re-serializes it, the bytes change and verification will always fail. Capture the raw body before any JSON middleware.

Node.js (Express)

webhook-node.js
const express = require("express");
const crypto = require("crypto");

const app = express();
const SECRET = process.env.BETTAPAY_WEBHOOK_SECRET;
const MAX_SKEW_SECONDS = 300; // reject events older than 5 minutes

function isVerified(rawBody, signature, timestamp) {
  if (!signature || !timestamp) return false;

  // Replay protection.
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (age > MAX_SKEW_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(rawBody)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post(
  "/webhooks/bettapay",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ok = isVerified(
      req.body, // Buffer of raw bytes
      req.header("X-BettaPay-Signature"),
      req.header("X-BettaPay-Timestamp")
    );
    if (!ok) return res.status(400).send("invalid signature");

    const event = JSON.parse(req.body.toString("utf8"));
    handleEvent(event); // dedupe + process (see below)
    res.status(200).send("ok");
  }
);

app.listen(3000);

Python (Flask)

webhook.py
import hashlib
import hmac
import os
import time

from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["BETTAPAY_WEBHOOK_SECRET"].encode()
MAX_SKEW_SECONDS = 300


def is_verified(raw_body: bytes, signature: str, timestamp: str) -> bool:
    if not signature or not timestamp:
        return False
    if abs(time.time() - float(timestamp)) > MAX_SKEW_SECONDS:
        return False
    expected = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)  # constant-time


@app.post("/webhooks/bettapay")
def webhook():
    raw = request.get_data()  # raw bytes, before JSON parsing
    if not is_verified(
        raw,
        request.headers.get("X-BettaPay-Signature", ""),
        request.headers.get("X-BettaPay-Timestamp", ""),
    ):
        abort(400, "invalid signature")

    event = request.get_json()
    handle_event(event)  # dedupe + process
    return "ok", 200

PHP

webhook.php
<?php
$secret = getenv('BETTAPAY_WEBHOOK_SECRET');
$maxSkew = 300;

$raw = file_get_contents('php://input'); // raw body
$signature = $_SERVER['HTTP_X_BETTAPAY_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_BETTAPAY_TIMESTAMP'] ?? '';

function isVerified($raw, $signature, $timestamp, $secret, $maxSkew) {
    if ($signature === '' || $timestamp === '') return false;
    if (abs(time() - (int)$timestamp) > $maxSkew) return false;
    $expected = hash_hmac('sha256', $raw, $secret);
    return hash_equals($expected, $signature); // constant-time
}

if (!isVerified($raw, $signature, $timestamp, $secret, $maxSkew)) {
    http_response_code(400);
    echo 'invalid signature';
    exit;
}

$event = json_decode($raw, true);
handleEvent($event); // dedupe + process
http_response_code(200);
echo 'ok';

Deduplicate with idempotency

The same event can arrive more than once — because of retries, or because your 200 response was lost in transit. Record every processed event id and skip duplicates. Use a durable store (a unique DB column or Redis), not an in-memory set, so restarts don't reprocess events.

handle-event.js
// Durable dedupe using a unique constraint on event_id.
async function handleEvent(event) {
  try {
    // INSERT fails if the event id already exists — that's our dedupe lock.
    await db.query(
      "INSERT INTO webhook_events (event_id, type, received_at) VALUES ($1, $2, now())",
      [event.id, event.type]
    );
  } catch (err) {
    if (err.code === "23505") return; // unique_violation -> already processed
    throw err;
  }

  switch (event.type) {
    case "payment.succeeded":
      await fulfillOrder(event.data.reference, event.data.settledAmount);
      break;
    case "settlement.completed":
      await markSettled(event.data.settlementId);
      break;
    default:
      // Unknown types are fine — we still recorded and acknowledged them.
      break;
  }
}

The retry schedule

If your endpoint doesn't return a 2xx within the timeout, BettaPay retries with a fixed back-off. There are up to 7 delivery attempts (the initial send plus 6 retries):

AttemptWhenCumulative delay
1Immediate0
2+5 minutes5 min
3+30 minutes35 min
4+2 hours~2.5 h
5+8 hours~10.5 h
6+24 hours~34.5 h
7+24 hours~58.5 h (final)
Return 2xx quickly
Any non-2xx (or a timeout) counts as a failed delivery and triggers a retry. A slow handler that eventually returns 200 can still time out — acknowledge first, then process asynchronously.

Dead-letter queue pattern

After the final retry, BettaPay stops trying. To make sure nothing is silently lost, acknowledge every verified event immediately, push it onto a queue, and process it from a worker. Anything the worker can't process after its own retries goes to a dead-letter queue (DLQ) for manual inspection.

dlq-worker.js
// Producer: the webhook route just verifies, enqueues, and acks.
app.post(
  "/webhooks/bettapay",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    if (!isVerified(req.body, req.header("X-BettaPay-Signature"), req.header("X-BettaPay-Timestamp"))) {
      return res.status(400).send("invalid signature");
    }
    await queue.add("bettapay-event", req.body.toString("utf8"), {
      attempts: 5,
      backoff: { type: "exponential", delay: 2000 },
    });
    res.status(200).send("ok"); // ack fast; worker does the real work
  }
);

// Consumer: process with its own retries; failures land in the DLQ.
worker.process("bettapay-event", async (job) => {
  const event = JSON.parse(job.data);
  await handleEvent(event);
});

worker.on("failed", async (job, err) => {
  if (job.attemptsMade >= job.opts.attempts) {
    await deadLetterQueue.add("bettapay-dlq", {
      payload: job.data,
      error: err.message,
      failedAt: new Date().toISOString(),
    });
    await alertOncall(`Webhook moved to DLQ: ${err.message}`);
  }
});

Test your endpoint

  • webhook.site — paste a webhook.site URL into your webhook settings to see raw deliveries, headers, and the signature without writing any code. Great for confirming BettaPay is sending what you expect.
  • Resend from the dashboard — every event has a Resend button so you can replay it against your endpoint while debugging.
  • Force a failure — return a 500 on purpose and watch the retry schedule kick in via the delivery log.

Monitoring and alerts

  • Alert when the DLQ depth is greater than zero — that means events are failing all retries.
  • Track your webhook 2xx rate; a drop signals an outage or a bad deploy.
  • Watch handler latency — creeping toward the timeout is an early warning that retries are coming.
  • The dashboard's delivery log shows every attempt and response code; reconcile it against your own records periodically.
Feature status
Signed webhooks, the retry schedule, and the delivery log with resend are available now. Configurable per-endpoint retry policies are in development.

Next steps