Documentation

Webhooks

Bitstac POSTs signed JSON events to your HTTPS endpoint when customer KYB status changes.

Setup

  1. Open Developers → Webhooks in the Bitstac app (workspace owner).
  2. Create an endpoint URL and subscribe to events.
  3. Copy the whsec_ signing secret immediately — it is shown once (or after rotate).
  4. Use Send test to receive merchant.webhook.test.

Headers

  • Content-Type: application/json
  • User-Agent: Bitstac-MerchantWebhook/1.0
  • X-Bitstac-Event — event type
  • X-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.approved
  • merchant.customer.rejected
  • merchant.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 2xx quickly; 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.