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-Signature header?
  • How do I handle card.created, transaction.authorized, approval.requested, checkout_authorization.approved, or balance.low events?
  • 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_TIMESTAMP_TOLERANCE_SECONDS 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_endpoints (and again by GET /api/v2/webhook_endpoints/{id}/secret). 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.

EventTriggered When
card.createdA one-time virtual card was created
card.updatedThe card's balance or status changed
card.closedThe card closed
transaction.authorizedA merchant authorized a charge on the card
approval.requestedAn action on a user's card needs their approval
checkout_authorization.approvedThe user approved a checkout and the processor answered
balance.lowA card's balance dropped below its threshold

See references/overview.md for payloads and the other event families.

Important Headers

HeaderDescription
AgentCard-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
X-AgentCard-SignatureLegacy 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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 5, 2026

View on GitHub →