Skip to main content

Respond 2xx fast

Acknowledge the delivery with a 2xx response as quickly as possible — within 10 seconds at the outside, but ideally well under one second. Do the actual work asynchronously: enqueue a job, write to a durable buffer, and process out-of-band. If your handler takes longer than 10 seconds we treat the delivery as failed and retry, which can lead to duplicate processing of the same event.
app.post("/webhooks/agentcard", async (req, res) => {
  verifySignature(req);                        // synchronous, fast
  await queue.enqueue("agentcard.event", req.body);  // durable queue
  res.status(200).send("ok");                  // ack immediately
});

Dedupe on event.id

Webhook delivery is at-least-once. The same event may be delivered more than once — for example, if your server returns 2xx but the network drops the connection before we record it, we’ll retry. Every event carries a stable id (evt_…). Maintain a short-lived (24–72h) set of processed event IDs and skip events you’ve seen before.
async function handle(event) {
  const seen = await redis.set(`evt:${event.id}`, "1", "NX", "EX", 60 * 60 * 72);
  if (!seen) return;            // duplicate — skip
  await applyEvent(event);
}

Subscribe with wildcards for forward-compat

We add new event types over time. If you want to receive everything a noun emits today and in the future, subscribe with a prefix wildcard rather than enumerating specific types.
{ "enabled_events": ["card.*", "transaction.*"] }
Adding card.frozen later will surface to your receiver automatically — no endpoint update required.

Don’t trust data blindly — refetch when it matters

For high-value side effects, treat the webhook as a signal, not the source of truth. Refetch the resource from the API to get the canonical state.
async function handle(event) {
  if (event.type === "card.closed") {
    const card = await fetchCard(event.data.id);  // canonical state
    if (card.status !== "CLOSED") return;          // stale — ignore
    await markCardClosed(card);
  }
}
This matters more for state-machine events (status changes) than for terminal events (a transaction.cleared with a specific txn id is unambiguous).

Return errors on bad signatures, not on business errors

Reserve non-2xx responses for cases where you genuinely want us to retry — invalid signature, downstream system temporarily down. If the event itself is one you don’t want to process (e.g. wrong env, unknown card), return 2xx to acknowledge and move on. Otherwise we’ll keep retrying and your endpoint may auto-disable.

Plan secret rotation

Rotate the signing secret periodically and any time it might have leaked (developer leaves the team, secret was in a config file that was checked in, etc.). Because rotation is a hard cutover (no overlap window), the safe procedure is:
  1. Create a second webhook endpoint with the new secret.
  2. Deploy your receiver to accept either endpoint’s secret.
  3. Verify the new endpoint is delivering successfully.
  4. Delete the old endpoint.
  5. Remove the old secret from your receiver.

Auto-disable behaviour

If every retry of every delivery fails for 7 consecutive days, we mark the endpoint disabled and stop sending. The billing email on the organization gets a notification. To re-enable after fixing your receiver:
curl -X PATCH https://api.agentcard.sh/api/v1/webhook_endpoints/wh_abc123 \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"status": "active"}'
This resets the failure counter; the next event delivery will proceed normally.