Skip to main content
Every webhook delivery includes two signature headers. Verify the timestamped signature (AgentCard-Signature) on every request — it provides replay protection in addition to integrity. The legacy X-AgentCard-Signature header is body-only and is kept for back-compat with older receivers.

The AgentCard-Signature header

AgentCard-Signature: t=1763356800,v1=8b7e5c4a3d2…
ComponentMeaning
tUnix epoch seconds at the moment the signature was produced.
v1Hex HMAC-SHA256 of the string ${t}.${raw_body} using your endpoint’s signing secret.
To verify:
  1. Parse t and v1 from the header.
  2. Reject the request if t is more than 5 minutes in the past — that is a stale replay.
  3. Compute HMAC_SHA256(t + "." + raw_request_body, secret) and compare to v1 with a constant-time comparison.
Use the raw request body — not the parsed JSON. Reserialization changes whitespace and breaks the signature.

Examples

Node.js (Express)

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

const app = express();
const SECRET = process.env.AGENTCARD_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;

// Express must give you the raw body — disable body parsing on this route
// and use express.raw() instead.
app.post(
  "/webhooks/agentcard",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const header = req.header("AgentCard-Signature") ?? "";
    const match = header.match(/^t=(\d+),v1=([0-9a-f]+)$/);
    if (!match) return res.status(400).send("Bad signature header");

    const [, t, v1] = match;
    if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > TOLERANCE_SECONDS) {
      return res.status(400).send("Timestamp outside tolerance");
    }

    const expected = crypto
      .createHmac("sha256", SECRET)
      .update(`${t}.${req.body.toString("utf8")}`)
      .digest("hex");

    if (!crypto.timingSafeEqual(Buffer.from(v1, "hex"), Buffer.from(expected, "hex"))) {
      return res.status(400).send("Signature mismatch");
    }

    const event = JSON.parse(req.body.toString("utf8"));
    // Handle the event…

    res.status(200).send("ok");
  },
);

Python (Flask)

import hmac
import hashlib
import time
import os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["AGENTCARD_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300

@app.post("/webhooks/agentcard")
def receive_webhook():
    header = request.headers.get("AgentCard-Signature", "")
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1:
        abort(400, "Bad signature header")

    if abs(int(time.time()) - int(t)) > TOLERANCE_SECONDS:
        abort(400, "Timestamp outside tolerance")

    payload = request.get_data()  # raw bytes
    expected = hmac.new(SECRET, f"{t}.".encode() + payload, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(v1, expected):
        abort(400, "Signature mismatch")

    event = request.get_json()
    # Handle the event…
    return "", 200

Go (net/http)

package main

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

var secret = []byte(os.Getenv("AGENTCARD_WEBHOOK_SECRET"))

func receive(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)

    var t, v1 string
    for _, part := range strings.Split(r.Header.Get("AgentCard-Signature"), ",") {
        kv := strings.SplitN(part, "=", 2)
        if len(kv) == 2 {
            switch kv[0] {
            case "t":  t = kv[1]
            case "v1": v1 = kv[1]
            }
        }
    }
    if t == "" || v1 == "" {
        http.Error(w, "bad signature header", 400); return
    }
    ts, _ := strconv.ParseInt(t, 10, 64)
    if d := time.Now().Unix() - ts; d > 300 || d < -300 {
        http.Error(w, "timestamp outside tolerance", 400); return
    }

    mac := hmac.New(sha256.New, secret)
    mac.Write([]byte(t + "."))
    mac.Write(body)
    expected := hex.EncodeToString(mac.Sum(nil))

    expectedBytes, _ := hex.DecodeString(expected)
    v1Bytes, _ := hex.DecodeString(v1)
    if !hmac.Equal(expectedBytes, v1Bytes) {
        http.Error(w, "signature mismatch", 400); return
    }

    // Handle the event…
    w.WriteHeader(200)
}

Rotating the signing secret

Rotate the secret if you suspect it has leaked, or as a periodic hygiene measure. After rotation the old secret stops working immediately — deploy the new secret to your receiver before rotating.
agent-cards-admin webhooks roll-secret wh_abc123
Or via the API:
curl -X POST https://api.agentcard.sh/api/v1/webhook_endpoints/wh_abc123/roll_secret \
  -H "Authorization: Bearer sk_test_..."
The new secret is returned in the response. There is no overlap window — plan your rollout accordingly. If you need zero downtime, create a second endpoint with the new secret, cut traffic over, then delete the old one.