Webhooks
Bitstac POSTs signed JSON events to your HTTPS endpoint when customer KYB status changes.
Setup
- Open Developers → Webhooks in the Bitstac app (workspace owner).
- Create an endpoint URL and subscribe to events.
- Copy the
whsec_signing secret immediately — it is shown once (or after rotate). - Use Send test to receive
merchant.webhook.test.
Headers
Content-Type: application/jsonUser-Agent: Bitstac-MerchantWebhook/1.0X-Bitstac-Event— event typeX-Bitstac-Delivery-Id— unique delivery id (idempotency)X-Bitstac-Signature—t=<unix_timestamp>,v1=<hmac_hex>
Signature scheme
HMAC-SHA256 (lowercase hex) over the raw JSON body:
signed_payload = "{timestamp}.{raw_json_body}"
v1 = HMAC_SHA256_HEX(signing_secret, signed_payload)
X-Bitstac-Signature: t={timestamp},v1={v1}- Verify against the exact raw body — do not re-serialize JSON first.
- Reject timestamps older than ~5 minutes (replay protection).
- Use a constant-time compare for the digest.
Verify in your language
import crypto from "crypto";
import express from "express";
const app = express();
const SECRET = process.env.BITSTAC_WEBHOOK_SECRET; // whsec_...
const MAX_AGE_SECONDS = 300;
app.post(
"/webhooks/bitstac",
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.get("X-Bitstac-Signature") || "";
const match = header.match(/^t=(\d+),v1=([0-9a-f]+)$/i);
if (!match || !SECRET) {
return res.status(401).send("Invalid signature header");
}
const [, timestamp, signature] = match;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (age > MAX_AGE_SECONDS) {
return res.status(401).send("Timestamp too old");
}
const rawBody = req.body.toString("utf8");
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(signature, "utf8");
const b = Buffer.from(expected, "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send("Signature mismatch");
}
const event = JSON.parse(rawBody);
// Idempotency: X-Bitstac-Delivery-Id
res.status(200).json({ received: true, type: event.type });
}
);Payload shape
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "merchant.customer.approved",
"created_at": "2026-07-26T22:00:00.000000Z",
"data": {
"customer_id": "…",
"external_reference": "crm-1001",
"status": "approved",
"business_name": "Northwind Trading Ltd",
"rejection_reason": null,
"reviewed_at": "2026-07-26T22:00:00.000000Z"
}
}Events
merchant.customer.approvedmerchant.customer.rejectedmerchant.webhook.test— console test only
Security checklist
- HTTPS only
- Secret server-side only — never log the full
whsec_ - Verify signature before trusting the body
- Idempotent handling via
X-Bitstac-Delivery-Id - Return
2xxquickly; process asynchronously
Retries
Non-2xx responses are retried with backoff: 1m → 5m → 30m → 2h → 12h, up to the endpoint max retries. Details also live under Errors & rate limits.
