Webhooks

Webhooks

Send ledger events to your app as they happen.

Blnk sends webhook events to your HTTP endpoints in real time. Your code reacts when a transaction is created, committed, or voided, when metadata is updated, or when a reconciliation completes.

This is critical in financial systems where timing matters. Polling risks gaps: you might miss a window between checks, or waste resources on empty requests.

Webhooks push the exact event to you the moment it happens, so your balances, notifications, and downstream workflows stay accurate and in sync.

Diagram showing payment provider, your app, and Blnk connected by webhook events to downstream tools

Event-driven flow: Blnk pushes ledger events to your app so you can sync payments, notifications, and other tools without polling

Instead of asking Blnk “did anything change?” on a timer, your app listens once and reacts when the ledger moves, keeping your product and tools like payments, notifications, etc. aligned.

Webhook types

Blnk offers two types of webhooks:

  • Global webhooks: A single endpoint for ledger-wide event notifications: resource lifecycle changes, metadata updates, reconciliation outcomes, and system errors.
  • Transaction hooks: Registered endpoints tied to transactions for workflows that run before or after a transaction is applied.

Here’s how they differ:

Global webhooksTransaction hooks
ConfigurationIn webhook configurationWith the Hooks API
When they fireOn all named lifecycle eventsOnly on transaction events
ManagementEdit Blnk configurationUpdate, list, delete via Hooks API
Typical useGlobal notifications, reconciliation results, system errorsRelated to transaction workflows (validation, enrichment, pipeline side effects)

Webhook security

Blnk signs outbound webhook requests so you can verify they came from your Blnk Core and were not tampered with.

Both global webhooks and transaction hooks use the same signing scheme.

HeaderDescription
X-Blnk-SignatureHex-encoded HMAC-SHA256 of the signed payload.
X-Blnk-TimestampUnix timestamp in seconds (string), used in the signed payload and for replay protection.
X-Hook-IDThe hook identifier (transaction hooks only).
X-Hook-TypePRE_TRANSACTION or POST_TRANSACTION (transaction hooks only).

Blnk also sends any custom headers you configure in webhook configuration. Set BLNK_WEBHOOK_HEADERS to a JSON-encoded object in environment variables, or set notification.webhook.headers to a JSON object in blnk.json. Use these headers for bearer tokens, API keys, or other values your endpoint expects on every delivery.

BLNK_WEBHOOK_HEADERS={"Authorization":"Bearer <token>","Content-Type":"application/json"}
{
  "notification": {
    "webhook": {
      "headers": {
        "Authorization": "Bearer <token>",
        "Content-Type": "application/json"
      }
    }
  }
}

To verify a webhook:

Extract headers and raw body

Read X-Blnk-Signature and X-Blnk-Timestamp from the request. Reject requests missing either header.

Build signed payload

Concatenate the timestamp and raw body:

signed = timestamp + "." + rawRequestBody

Compute expected signature

Compute HMAC-SHA256 using server.secret_key from your Blnk configuration, then hex-encode:

expected = hex( HMAC-SHA256(secret_key, signed) )

Compare signatures

Compare expected to X-Blnk-Signature using a constant-time comparison (e.g. crypto.timingSafeEqual in Node.js). If they match, the webhook is authentic.

import express from "express";
import crypto from "crypto";

const app = express();
const SECRET = process.env.BLNK_SECRET; // must match server.secret_key

app.use(express.json({ verify: (req, _, buf) => { req.rawBody = buf; } }));

app.post("/webhook", (req, res) => {
  const sig = req.header("x-blnk-signature");
  const ts = req.header("x-blnk-timestamp");
  if (!sig || !ts) return res.sendStatus(400);

  const expected = crypto.createHmac("sha256", SECRET)
    .update(`${ts}.${req.rawBody}`)
    .digest("hex");

  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)))
    return res.sendStatus(401);

  res.sendStatus(200);
});

app.listen(3000);
package main

import (
  "crypto/hmac"
  "crypto/sha256"
  "encoding/hex"
  "io"
  "net/http"
  "os"
)

var secret = []byte(os.Getenv("BLNK_SECRET")) // must match server.secret_key

func webhook(w http.ResponseWriter, r *http.Request) {
  sig := r.Header.Get("X-Blnk-Signature")
  ts := r.Header.Get("X-Blnk-Timestamp")
  if sig == "" || ts == "" {
    http.Error(w, "missing headers", http.StatusBadRequest)
    return
  }

  body, _ := io.ReadAll(r.Body)
  mac := hmac.New(sha256.New, secret)
  mac.Write([]byte(ts + "." + string(body)))
  expected := hex.EncodeToString(mac.Sum(nil))

  if !hmac.Equal([]byte(sig), []byte(expected)) {
    http.Error(w, "invalid signature", http.StatusUnauthorized)
    return
  }
  w.WriteHeader(http.StatusOK)
}

func main() {
  http.HandleFunc("/webhook", webhook)
  http.ListenAndServe(":3000", nil)
}
import hmac, hashlib, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ.get("BLNK_SECRET").encode()  # must match server.secret_key

@app.route("/webhook", methods=["POST"])
def webhook():
    sig = request.headers.get("X-Blnk-Signature")
    ts = request.headers.get("X-Blnk-Timestamp")
    if not sig or not ts:
        abort(400)

    signed = f"{ts}.".encode() + request.get_data()
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(sig, expected):
        abort(401)
    return "", 200

if __name__ == "__main__":
    app.run(port=3000)
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;

// Must match server.secret_key
byte[] secret = System.getenv("BLNK_SECRET").getBytes(StandardCharsets.UTF_8);

// Inside your HTTP handler:
String sig = request.getHeader("X-Blnk-Signature");
String ts = request.getHeader("X-Blnk-Timestamp");
if (sig == null || ts == null) {
  response.setStatus(400);
  return;
}

byte[] body = request.getInputStream().readAllBytes();
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
mac.update((ts + ".").getBytes(StandardCharsets.UTF_8));
mac.update(body);
String expected = HexFormat.of().formatHex(mac.doFinal());

if (!sig.equals(expected)) {
  response.setStatus(401);
  return;
}
response.setStatus(200);