@bondi-labs/integration-sdk - v0.0.3
    Preparing search index...

    Verifying webhook signatures

    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).

    1. All three headers must be present. Reject the request otherwise.
    2. Timestamp must be within ±5 minutes. Replay protection.
    3. Compare in constant time. Use a timing-safe equality function.
    4. Sign the raw bytes. Don't 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.