When Bondi calls your action endpoints, it signs requests with HMAC-SHA256. This guide shows how to verify those signatures in any stack — the algorithm is short enough to implement in 5 lines of code in any language.
Every signed request includes three headers:
| Header | Format | Description |
|---|---|---|
x-bondi-timestamp |
unix seconds | When the request was signed (Math.floor(Date.now() / 1000)) |
x-bondi-action |
string | Action / trigger name (matches BondiAction.name) |
x-bondi-signature |
sha256={hex digest} |
HMAC-SHA256 of the signing string |
The signing string is:
{timestamp}.{action}.{rawBody}
The HMAC key is your integration token (BONDI_INTEGRATION_TOKEN).
JSON.parse and re-stringify — formatting/key-ordering changes break the signature.For most apps, reach for a framework adapter — they handle raw-body capture,
signature verification, body parsing, and typing in one call. See the
plain-node guide for actionHandler
examples in Express, Fastify, and Web standard runtimes (Cloudflare Workers,
Next.js, Hono, Bun, Deno).
If you need the low-level primitive:
import { verifyRequest } from "@bondi-labs/integration-sdk/core";
const result = verifyRequest({
token: process.env.BONDI_INTEGRATION_TOKEN!,
signature,
timestamp,
action,
rawBody,
});
if (!result.valid) {
// result.reason: "missing_headers" | "expired_timestamp" | "invalid_signature"
}
import hmac, hashlib, time
def verify_bondi(headers, raw_body, token, max_age_seconds=300):
ts = headers.get("x-bondi-timestamp")
action = headers.get("x-bondi-action")
sig = headers.get("x-bondi-signature")
if not (ts and action and sig):
return False
try:
if abs(time.time() - int(ts)) > max_age_seconds:
return False
except ValueError:
return False
signing_string = f"{ts}.{action}.{raw_body}".encode()
expected = hmac.new(token.encode(), signing_string, hashlib.sha256).hexdigest()
provided = sig.removeprefix("sha256=")
return hmac.compare_digest(provided, expected)
Flask example:
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.post("/contacts")
def create_contact():
raw_body = request.get_data(as_text=True)
if not verify_bondi(request.headers, raw_body, os.environ["BONDI_INTEGRATION_TOKEN"]):
return jsonify(error="invalid_signature"), 401
# ... process the request
return jsonify(id="new-id")
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
func verifyBondi(headers http.Header, rawBody []byte, token string) bool {
ts := headers.Get("X-Bondi-Timestamp")
action := headers.Get("X-Bondi-Action")
sig := headers.Get("X-Bondi-Signature")
if ts == "" || action == "" || sig == "" {
return false
}
tsInt, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return false
}
if abs(time.Now().Unix()-tsInt) > 300 {
return false
}
signingString := fmt.Sprintf("%s.%s.%s", ts, action, string(rawBody))
mac := hmac.New(sha256.New, []byte(token))
mac.Write([]byte(signingString))
expected := hex.EncodeToString(mac.Sum(nil))
provided := strings.TrimPrefix(sig, "sha256=")
return hmac.Equal([]byte(provided), []byte(expected))
}
func abs(x int64) int64 {
if x < 0 {
return -x
}
return x
}
require "openssl"
def verify_bondi(headers, raw_body, token, max_age_seconds: 300)
ts = headers["x-bondi-timestamp"]
action = headers["x-bondi-action"]
sig = headers["x-bondi-signature"]
return false unless ts && action && sig
return false if (Time.now.to_i - ts.to_i).abs > max_age_seconds
signing_string = "#{ts}.#{action}.#{raw_body}"
expected = OpenSSL::HMAC.hexdigest("SHA256", token, signing_string)
provided = sig.sub(/^sha256=/, "")
Rack::Utils.secure_compare(provided, expected)
end
TOKEN="bnd_tok_..."
TS="$(curl -sI .../webhook | grep -i x-bondi-timestamp | awk '{print $2}' | tr -d '\r')"
ACTION="..."
RAW='{"...":"..."}'
EXPECTED=$(printf '%s.%s.%s' "$TS" "$ACTION" "$RAW" | openssl dgst -sha256 -hmac "$TOKEN" | awk '{print $2}')
echo "Expected: sha256=$EXPECTED"
❌ Wrong:
const body = req.body; // already parsed by middleware
const rawBody = JSON.stringify(body); // re-serialized — formatting differs!
verifyRequest({ token, signature, timestamp, action, rawBody }); // ⚠ will fail
✅ Right — let a framework adapter capture raw bytes for you:
import { bondiExpressMiddleware } from "@bondi-labs/integration-sdk/express";
app.post("/webhook", bondiExpressMiddleware(), handler);
If your server's clock drifts, all requests will fail with expired_timestamp. Run NTP. The default tolerance is ±5 minutes; pass a larger maxAgeSeconds to verifyRequest({ ..., maxAgeSeconds }) (or to the framework adapter options) if needed — but this weakens replay protection.
When you rotate via the Studio UI, the previous token remains valid for 24 hours. During this window, Bondi may sign with either token — your verification accepts the new one but Bondi's verification accepts both. Plan deployments to update BONDI_INTEGRATION_TOKEN within 24h.
verifyRequest API: docs