Webhooks push events to your server as they happen, so you don’t have to poll. Register an endpoint, subscribe to the events you care about, and Agentcard delivers a signed JSON payload for each one — a card being used, a transaction settling, a balance running low.
Events fired by sandbox activity carry livemode: false; live activity carries livemode: true. A destination belongs to one mode: it’s created in the mode you’re viewing and receives only that mode’s events — register one in each mode to run both.
Register an endpoint
Webhook destinations live in the dashboard: open your organization → Webhooks → Add destination, pick the events, and paste your endpoint URL. Subscribe to All events, a whole family like transaction.*, or individual events — and change the selection any time.
Creating a destination reveals its signing secret (prefixed whsec_) — store it, you use it to verify every delivery’s signature. You can reveal it again from the destination’s page, and rotate it there too (for example after a suspected leak). The same page sends test events so you can prove your handler before any real traffic, pauses or deletes the destination, and shows a live view of recent deliveries with their response codes.
The event payload
Every delivery is a POST with this envelope. Dedupe on id — the same event can be delivered more than once.
{
"id": "evt_…",
"type": "transaction.authorized",
"created": 1751976000,
"livemode": true,
"data": {
"id": "…",
"card_id": "card_…",
"last4": "4242",
"amount_cents": 675,
"merchant": "COFFEE SHOP #42 SAN FRANCISCO",
"status": "PENDING",
"balance_cents": 1825
}
}
| Field | Meaning |
|---|
id | Stable, idempotent event id (evt_…). Dedupe on this. |
type | The event type (see the catalog below). |
created | Unix epoch seconds. |
livemode | false for sandbox/test events, true for live. |
data | Event-specific payload; its shape depends on type. |
Verify the signature
Every delivery carries an AgentCard-Signature header so you can confirm it came from us and wasn’t replayed. The header looks like:
AgentCard-Signature: t=1751976000,v1=<hex-hmac>
v1 is an HMAC-SHA256 of "{t}.{raw_request_body}" keyed with your endpoint secret. Verify it against the raw body (before any JSON parsing), and reject timestamps that are too old to blunt replays.
import crypto from "node:crypto";
// `rawBody` must be the exact bytes we sent — do not re-serialize parsed JSON.
// Returns false (never throws) on a missing or malformed header, so a random
// probe to your endpoint can't turn verification into a 500.
export function verify(rawBody, header, secret, toleranceSeconds = 300) {
if (typeof header !== "string" || !header) return false;
const parts = Object.fromEntries(
header.split(",").map((kv) => {
const i = kv.indexOf("=");
return i === -1 ? [kv, ""] : [kv.slice(0, i), kv.slice(i + 1)];
}),
);
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const given = Buffer.from(String(parts.v1 ?? ""), "utf8");
const want = Buffer.from(expected, "utf8");
return given.length === want.length && crypto.timingSafeEqual(given, want);
}
import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
t = int(parts.get("t", 0))
if not t or abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
A legacy X-AgentCard-Signature: sha256=<hex-hmac> header (an HMAC of the body alone, no timestamp) is also sent for backward compatibility. New integrations should verify AgentCard-Signature — the timestamp gives you replay protection.
Respond 2xx to acknowledge. Any other status (or a timeout) is treated as a failure and retried.
Delivery and retries
We attempt each delivery up to 5 times with backoff on any non-2xx response, timeout, or network error. Requests time out after 10 seconds. The destination’s page in the dashboard shows every recent delivery — status, attempt count, the response code we got, and when the next retry is scheduled.
An endpoint that keeps failing is eventually disabled; fix your handler, then re-enable the destination from its page.
Event catalog
Subscribe to any of these by name (or "*" / a "family.*" prefix).
| Event | Fires when |
|---|
card.created | A card is issued. |
card.updated | A card’s balance or status changes. |
card.closed | A card is closed (one-time-use spend, manual close, or expiry). |
cardholder.created | A cardholder is created. |
cardholder.updated | A cardholder’s details change. |
cardholder_onboarding_session.completed | A hosted onboarding session finishes. |
transaction.authorized | A charge is authorized (funds held). |
transaction.cleared | A charge settles. |
transaction.declined | A charge is declined. |
transaction.voided | An authorization is reversed. |
balance.low | A card’s remaining balance drops into the low range. |
merchant.connected | A cardholder links a merchant (e.g. DoorDash). |
card_flow.started | A user’s company-funded card creation reserved your pool (company wallet). |
transfer.approved | You (or auto-approve) confirmed collection for a company-funded card. |
transfer.initiated | The pool-to-user transfer was submitted on-chain. |
transfer.completed | The funds became the user’s spending power. Capture your customer here. |
transfer.failed | Company funding aborted before the card was created. |
transfer.released | A company-funded card closed with unused balance. |
card_flow.failed | The mint failed after funds moved; the allocation stays as user spending power. |
wallet.funded | A deposit landed in your company wallet. |
wallet.balance.low | Your company wallet dropped under its threshold. |