Gareth Wilson Gareth Wilson

Guide to Agentcard Webhooks: Features and Best Practices

Published


Agentcard webhooks tell your platform what is happening to the cards and purchases of the users connected to it: a card is created or closed, a merchant authorizes a charge, a card lands in a user's vault, a checkout is approved or an order is placed. Agentcard provides virtual cards, a card vault and a purchase API for AI agents, and its documentation puts the role of webhooks plainly: "SDK callbacks are UI signals. Webhooks are the record."

This guide covers how Agentcard webhook endpoints are registered and signed, the events and their envelope, the retry schedule, and the practices that keep an Agentcard receiver reliable.

What are Agentcard webhooks?

An Agentcard webhook endpoint is an HTTPS URL registered with a list of events. Endpoints belong to one mode: an endpoint created with a sandbox token receives sandbox events, and one created with a production token receives production events. Each event arrives as a POST with a JSON envelope.

Every delivery has an AgentCard-Signature header in the form t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of <t>.<raw body>. The key is the endpoint's secret, which starts with whsec_. Despite the prefix, this is not Standard Webhooks: the whole string, whsec_ included, is the HMAC key, and there are no webhook-* headers.

Agentcard webhook features

FeatureDetails
ConfigurationPOST /api/v2/webhook_endpoints, or Developers > Webhooks in the dashboard
ModesOne endpoint per mode. Sandbox events carry livemode: false
Event selectionenabled_events: exact names, prefix wildcards such as vault.*, or ["*"]
Signature headerAgentCard-Signature: t=<unix seconds>,v1=<hex>
SchemeHMAC-SHA256, hex digest
Signed content<t>.<raw body>
Secretwhsec_..., used whole as the key. Returned on create, readable through the API, rotatable
Legacy headerX-AgentCard-Signature: sha256=..., over the body alone
Envelopeid, type, created, livemode, data
DeliveryAt least once. Deduplicate on id
RetriesUp to five: immediately, then after 1 minute, 5 minutes, 30 minutes and 1 hour
Delivery logList recent deliveries, with payloads and response codes, through the API or dashboard
Source IPsNot published

Common events

Agentcard documents 39 event types, grouped by object:

AreaEvents
Connectionsconnection.created, approval.requested
Vaultvault.session_linked, vault.card_stored, vault.payment_permission.updated
Checkout authorizationscheckout_authorization.approved, .submitted, .declined, .expired, .amount_mismatch, .settled, .settlement_unconfirmed, .refused, .watched
Ordersorder.placed, order.confirmed, order.failed, order.updated
Cards and transactionscard.created, card.updated, card.closed, transaction.authorized, transaction.cleared, transaction.declined, transaction.voided, balance.low
Identityidentity.verification.updated
Rewards and merchantsreward.earned, reward.reversed, merchant.connected
Company walletcardholder.created, cardholder_onboarding_session.completed, card_flow.started, card_flow.failed, transfer.approved, transfer.completed, recovery.requested, wallet.funded, wallet.balance.low

Every event has the same envelope, and Agentcard says type "is stable and part of the API contract, so branch on it directly". Here is its transaction.authorized example:

{
  "id": "evt_9f8e7d6c5b4a",
  "type": "transaction.authorized",
  "created": 1757264400,
  "livemode": false,
  "data": {
    "id": "txn_3f9a",
    "card_id": "card_7h2k",
    "last4": "7318",
    "amount": 675,
    "currency": "usd",
    "amount_display": "$6.75",
    "merchant": "COFFEE SHOP #42 SAN FRANCISCO",
    "status": "PENDING",
    "balance_cents": 325
  }
}

created is in Unix seconds and amounts are in cents. Some events follow each other for the same object: checkout_authorization.amount_mismatch, for example, fires after approved when the charge disagreed with what the user approved.

Setting up Agentcard webhooks

Register an endpoint with an organisation token for the mode you want:

curl -X POST https://api.agentcard.sh/api/v2/webhook_endpoints \
  -H "Authorization: Bearer $ORG_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/webhooks/agentcard", "enabled_events": ["card.*", "transaction.*", "balance.low"]}'

The response includes the endpoint's id and its secret. Store the secret, for example as AGENTCARD_WEBHOOK_SECRET. You can read it again later with GET /api/v2/webhook_endpoints/{id}/secret. PATCH on the endpoint changes the URL, replaces enabled_events, or sets status to disabled to pause deliveries.

Create a second endpoint with a sandbox token for testing. If sandbox actions complete but nothing arrives, check that the endpoint was created in sandbox mode.

If you receive Agentcard webhooks through Hookdeck, paste the endpoint's whole secret into the Hookdeck source, including the whsec_ prefix. Agentcard uses it as a plain HMAC key.

Securing Agentcard webhooks

Parse t and v1 from AgentCard-Signature, compute an HMAC-SHA256 of <t>.<raw body> with the whole secret as a UTF-8 key, and compare the hex digests in constant time. Don't use a Standard Webhooks or Svix library, and don't strip or decode whsec_.

const crypto = require('crypto');
const express = require('express');

function verifyAgentcardSignature(rawBody, header, secret) {
  if (!header || !secret) return false;
  let t;
  const candidates = [];
  for (const part of header.split(',')) {
    const pair = part.trim();
    const index = pair.indexOf('=');
    if (index <= 0) continue;
    const key = pair.slice(0, index).trim();
    const value = pair.slice(index + 1).trim();
    if (key === 't') t = value;
    else if (key === 'v1') candidates.push(value);
  }
  if (!/^\d+$/.test(t || '') || candidates.length === 0) return false;

  // The key is the whole whsec_... string.
  const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-f]{64}$/i.test(candidate)) return false; // before Buffer.from(x, 'hex')
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/agentcard', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.AGENTCARD_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyAgentcardSignature(req.body, req.get('agentcard-signature'), secret)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.id, branch on event.type, then process asynchronously.
  res.sendStatus(200);
});

The same check in Python:

import hashlib
import hmac


def verify_agentcard_signature(raw_body: bytes, header, secret: str) -> bool:
    if not header or not secret:
        return False
    t, candidates = None, []
    for part in header.split(","):
        key, sep, value = part.strip().partition("=")
        if sep and key.strip() == "t":
            t = value.strip()
        elif sep and key.strip() == "v1":
            candidates.append(value.strip())
    if not t or not (t.isascii() and t.isdigit()) or not candidates:
        return False
    expected = hmac.new(
        secret.encode("utf-8"), t.encode("utf-8") + b"." + raw_body, hashlib.sha256
    ).hexdigest().encode("utf-8")
    return any(hmac.compare_digest(c.lower().encode("utf-8"), expected) for c in candidates)

Agentcard also says to "reject timestamps older than a few minutes to block replays". The Agentcard webhook skill leaves that window off by default, so it accepts Hookdeck retries and replays of older events. If you turn the window on with AGENTCARD_TIMESTAMP_TOLERANCE_SECONDS, Hookdeck retries and replays resend the original t, so any that arrive after the window has passed fail the check. Keep the window generous and rely on deduplicating by id.

Agentcard's own short Node sample calls crypto.timingSafeEqual without checking lengths first, which throws on a malformed header. Keep the length check shown above.

Make Agentcard webhooks production-ready. Hookdeck Event Gateway verifies the AgentCard-Signature header with your whole whsec_ secret, stores every card, transaction and checkout event, and delivers it to your services with retries you control.

Agentcard webhook limitations and pain points

A whsec_ secret that isn't Standard Webhooks

The Problem: Verification fails on every delivery, even with the correct secret.

Why It Happens: The whsec_ prefix suggests Standard Webhooks, whose libraries strip it, base64-decode the rest and expect webhook-id and webhook-timestamp headers. Agentcard uses the whole string as the key and signs <t>.<body>, so a Standard Webhooks verifier never matches.

Workarounds:

  • Use a plain HMAC-SHA256 keyed with the full secret, as above.
  • Read AgentCard-Signature, not webhook-signature.

How Hookdeck Can Help: Hookdeck's Agentcard source type uses the secret exactly as pasted and parses t and v1 from the header. Requests that fail the check are marked as failed verification and are not delivered to your destinations.

Rotation applies from the next event

The Problem: Rotating the secret can cause failed verifications until your receiver has the new value.

Why It Happens: After roll_secret, deliveries are signed with the new secret from the next event. Agentcard tells you to update your verifier before events start arriving with it, but doesn't document a period where both secrets are valid.

Workarounds:

  • Deploy the new secret straight after rotating, during a quiet period.
  • Rely on Agentcard's retries for events that fail in the gap, and check the deliveries log afterwards.

How Hookdeck Can Help: With Hookdeck in front, the secret lives on one source. Update it there and every service behind it keeps receiving verified events.

A short retry schedule

The Problem: An outage of more than an hour or two loses events.

Why It Happens: Agentcard retries a failed delivery up to five times: immediately, then after 1 minute, 5 minutes, 30 minutes and 1 hour. Agentcard documents no attempts after that, and does not state a timeout, but says that "a slow handler looks like a failure".

Workarounds:

  • Return 2xx quickly and do slow work afterwards.
  • After an outage, use the List recent deliveries API to find failed events and fetch the objects they refer to.

How Hookdeck Can Help: Hookdeck accepts and stores each delivery as it arrives, then delivers it to your service with automatic retries on a schedule you set, and lets you replay any event.

Best practices

Verify against the raw body

Agentcard says to verify against "the raw bytes, never re-serialized JSON". Use express.raw() in Express, await request.text() in a Next.js route handler and await request.body() in FastAPI.

Deduplicate on id

Delivery is at least once. Store each event's id before processing and skip repeats. See our guide to webhook idempotency.

Keep sandbox and production apart

Use separate endpoints, secrets and environment variables per mode, and check livemode before acting on an event that moves money.

Subscribe with wildcards deliberately

["*"] subscribes to every current and future event. Prefix wildcards such as card.* are narrower. Either way, answer 2xx to types you don't handle.

Acknowledge fast and process asynchronously

Verify, persist the event, return 200, and do the work afterwards. See why to process webhooks asynchronously.

Conclusion

Agentcard signs each event with AgentCard-Signature: a timestamp and a hex HMAC-SHA256 of <t>.<raw body>, keyed with the whole whsec_ secret. Verify with a plain HMAC rather than a Standard Webhooks library, deduplicate on the envelope id, keep any timestamp check optional and generous, and deploy rotated secrets quickly because the new one applies from the next event.

Hookdeck Event Gateway sits in front of your receiver, checks every Agentcard signature, and holds each event until your service has processed it.

Get started with Hookdeck for free and handle Agentcard webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.