# Guide to Agentcard Webhooks: Features and Best Practices

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

| Feature | Details |
| --- | --- |
| Configuration | `POST /api/v2/webhook_endpoints`, or **Developers > Webhooks** in the dashboard |
| Modes | One endpoint per mode. Sandbox events carry `livemode: false` |
| Event selection | `enabled_events`: exact names, prefix wildcards such as `vault.*`, or `["*"]` |
| Signature header | `AgentCard-Signature: t=<unix seconds>,v1=<hex>` |
| Scheme | HMAC-SHA256, hex digest |
| Signed content | `<t>.<raw body>` |
| Secret | `whsec_...`, used whole as the key. Returned on create, readable through the API, rotatable |
| Legacy header | `X-AgentCard-Signature: sha256=...`, over the body alone |
| Envelope | `id`, `type`, `created`, `livemode`, `data` |
| Delivery | At least once. Deduplicate on `id` |
| Retries | Up to five: immediately, then after 1 minute, 5 minutes, 30 minutes and 1 hour |
| Delivery log | List recent deliveries, with payloads and response codes, through the API or dashboard |
| Source IPs | Not published |

## Common events

Agentcard documents 39 event types, grouped by object:

| Area | Events |
| --- | --- |
| Connections | `connection.created`, `approval.requested` |
| Vault | `vault.session_linked`, `vault.card_stored`, `vault.payment_permission.updated` |
| Checkout authorizations | `checkout_authorization.approved`, `.submitted`, `.declined`, `.expired`, `.amount_mismatch`, `.settled`, `.settlement_unconfirmed`, `.refused`, `.watched` |
| Orders | `order.placed`, `order.confirmed`, `order.failed`, `order.updated` |
| Cards and transactions | `card.created`, `card.updated`, `card.closed`, `transaction.authorized`, `transaction.cleared`, `transaction.declined`, `transaction.voided`, `balance.low` |
| Identity | `identity.verification.updated` |
| Rewards and merchants | `reward.earned`, `reward.reversed`, `merchant.connected` |
| Company wallet | `cardholder.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:

```json
{
  "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:

```bash
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_`.

```javascript
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:

```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](https://github.com/hookdeck/webhook-skills/tree/main/skills/agentcard-webhooks) 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](/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](/docs/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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) sits in front of your receiver, checks every Agentcard signature, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Agentcard webhooks reliably in minutes.