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.

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 webhooks | Transaction hooks | |
|---|---|---|
| Configuration | In webhook configuration | With the Hooks API |
| When they fire | On all named lifecycle events | Only on transaction events |
| Management | Edit Blnk configuration | Update, list, delete via Hooks API |
| Typical use | Global notifications, reconciliation results, system errors | Related 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.
| Header | Description |
|---|---|
X-Blnk-Signature | Hex-encoded HMAC-SHA256 of the signed payload. |
X-Blnk-Timestamp | Unix timestamp in seconds (string), used in the signed payload and for replay protection. |
X-Hook-ID | The hook identifier (transaction hooks only). |
X-Hook-Type | PRE_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 + "." + rawRequestBodyCompute 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);