Agent skill
Agentcard Webhooks Skill
Receive and verify Agentcard webhooks. Use when setting up Agentcard virtual card webhook handlers, debugging AgentCard-Signature verification, or handling events such as card.created, card.updated, card.closed, transaction.authorized, approval.requested, checkout_authorization.approved, and balance.low.
Install this skill
npx skills add hookdeck/webhook-skills --skill agentcard-webhooks
When to Use This Skill
- How do I receive Agentcard webhooks?
- How do I verify the
AgentCard-Signatureheader? - How do I handle
card.created,transaction.authorized,approval.requested,checkout_, orauthorization.approved balance.lowevents? - Why does Standard Webhooks / svix verification fail on my Agentcard
whsec_secret? - Why is my Agentcard webhook signature verification failing?
Essential Code (USE THIS)
Every Agentcard delivery carries AgentCard-Signature: t=<unix seconds>,v1=<hex>. v1 is the HMAC-SHA256 (hex) of "<t>.<raw body>". The key is the endpoint's whole secret string, whsec_ prefix included, as UTF-8. This is not Standard Webhooks: don't use the standardwebhooks or svix libraries, and don't strip or base64-decode the prefix. roll_secret signs deliveries with the new secret from the next event, and Agentcard documents no overlap period, so update your secret promptly; the examples accept the header if any v1 matches, which is harmless. The legacy X-AgentCard-Signature: sha256=… header signs the body alone and is not used by the examples.
Verification (Node.js)
const crypto = require('crypto');
// rawBody: Buffer of the exact request bytes (express.raw({ type: '*/*' }), not express.json)
function verifyAgentcard(rawBody, header, secret) {
if (!secret || !header) return false;
let t; const v1 = [];
for (const part of header.split(',')) { // trim: a space after the comma is allowed
const p = part.trim(), i = p.indexOf('=');
if (i > 0) { const k = p.slice(0, i).trim(), v = p.slice(i + 1).trim();
if (k === 't') t = v; else if (k === 'v1') v1.push(v); }
}
if (!t || !/^\d+$/.test(t) || v1.length === 0) return false;
// Optional replay window, off by default: retries and replays keep the original timestamp
const expected = crypto.createHmac('sha256', secret) // whole whsec_... string
.update(`${t}.`).update(rawBody).digest();
// 64 hex chars first: Buffer.from(x, 'hex') silently truncates invalid hex
return v1.some((s) => /^[0-9a-f]{64}$/i.test(s) &&
crypto.timingSafeEqual(Buffer.from(s, 'hex'), expected));
}
Verification (Python)
import hashlib, hmac
def verify_agentcard(raw_body: bytes, header: str, secret: str) -> bool:
if not secret or not header:
return False
t, v1 = None, []
for part in header.split(","):
k, sep, v = part.strip().partition("=")
if sep and k.strip() == "t": t = v.strip()
elif sep and k.strip() == "v1": v1.append(v.strip())
if not t or not (t.isascii() and t.isdigit()) or not v1:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest().encode()
# bytes on both sides: a non-ASCII header gives a rejection, not a 500
return any(hmac.compare_digest(s.lower().encode(), expected) for s in v1)
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Timestamp window: off by default
Agentcard suggests rejecting timestamps older than a few minutes. Hookdeck retries and replays resend the original timestamp, so a 5-minute window rejects late retries. The examples therefore skip the freshness check unless you set AGENTCARD_ to a positive number of seconds (default 0 = off). Use the event id for idempotency instead. Agentcard's own verifyAgentcardWebhook in @agent-cards/commerce-sdk defaults to a 300-second tolerance, so pass a larger toleranceSeconds if you use it behind Hookdeck. references/verification.md shows exactly where the window can be dropped or widened.
Setup with Hookdeck
Paste the endpoint's whole secret, including the whsec_ prefix, into the Hookdeck source. Agentcard uses it as a plain HMAC key. The secret is returned once by POST /api/v2/webhook_ (and again by GET /api/v2/webhook_). Use the Hookdeck source URL as the endpoint url. See references/setup.md.
Event Envelope and Types
Every event is { id, type, created, livemode, data }. Branch on type; delivery is at least once, so dedupe on id. Agentcard waits 10 seconds for your answer, and redelivers after anything other than a 2xx.
| Event | Triggered When |
|---|---|
card.created | A one-time virtual card was created |
card.updated | The card's balance or status changed |
card.closed | The card closed |
transaction.authorized | A merchant authorized a charge on the card |
approval.requested | An action on a user's card needs their approval |
checkout_ | The user approved a checkout and the processor answered |
balance.low | A card's balance dropped below its threshold |
See references/overview.md for payloads and the other event families.
Important Headers
| Header | Description |
|---|---|
AgentCard-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
X-AgentCard-Signature | Legacy sha256=… over the body alone; not used here |
Environment Variables
AGENTCARD_WEBHOOK_SECRET=whsec_your_endpoint_secret # whole secret incl. whsec_, used as a plain key
AGENTCARD_TIMESTAMP_TOLERANCE_SECONDS=0 # optional; a positive value opts in to the replay window
Local Development
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 agentcard --path /webhooks/agentcard
Reference Materials
- references/overview.md - Agentcard webhook concepts, events, payloads, retries
- references/setup.md - Creating the endpoint through the API, the secret, Hookdeck setup
- references/verification.md - Signature details, the timestamp window, and debugging