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.
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:
| Header | Meaning |
|---|---|
X-BettaPay-Signature | Hex HMAC-SHA256 of the raw request body, keyed by your signing secret. |
X-BettaPay-Timestamp | Unix seconds when the event was sent. Reject if it's too old. |
X-BettaPay-Event-Id | Stable id for this event — the key you dedupe on. |
X-BettaPay-Delivery | Unique 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:
# 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/bettapayVerify 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.
Node.js (Express)
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)
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", 200PHP
<?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.
// 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):
| Attempt | When | Cumulative delay |
|---|---|---|
| 1 | Immediate | 0 |
| 2 | +5 minutes | 5 min |
| 3 | +30 minutes | 35 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) |
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.
// 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
500on 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.
Next steps
- React to settlement events → Fiat Settlement Configuration.
- Exercise the full flow safely → Testing with Stellar Testnet.
- Event payload schemas → API reference.