@bondi-labs/integration-sdk - v0.0.1
    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.
    import { verifyRequest } from "@bondi-labs/integration-sdk/core";

    const result = verifyRequest(
    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, sig, ts, action, rawBody); // ⚠ will fail

    ✅ Right — capture raw bytes BEFORE the parser:

    app.use(express.json({
    verify: (req, _res, buf) => { (req as any).rawBody = buf.toString("utf8"); },
    }));

    If your server's clock drifts, all requests will fail with expired_timestamp. Run NTP. The default tolerance is ±5 minutes; you can pass a larger maxAgeSeconds to verifyRequest 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.