Building Reactive Agent Workflows with Anima Webhooks

Stop polling. Drive your agent from message and call events as they land, and avoid the subscription mistake that fails silently.

Diyan BogdanovDiyan Bogdanov4 min read
#tutorial#webhooks#events

An agent that polls is an agent that is always slightly wrong. It learns about the email ninety seconds after it arrived, and it burns a request every time it checks and finds nothing. Webhooks invert that: Anima posts to your endpoint the moment something happens.

The mechanics are straightforward. The part that catches people is a subscription that looks like it worked and never fires.

Subscribe to a name that exists#

Every event Anima emits is on one list. Names not on it are accepted at subscription time and then never deliver anything.

Wrong names fail silently

Subscribing to an event that does not exist returns success. No error, no warning, no delivery — you find out when you notice your agent has been idle for a week. Copy the names exactly.

EventFires when
message.receivedEmail or SMS arrives for one of your agents
message.received.autoInbound mail detected as automated. Fires instead of message.received
message.sentAn outbound email or SMS is accepted for delivery
message.failedAn outbound message failed, or the recipient complained
message.bouncedAn outbound email bounced
message.loop_detectedRepeated sends to one address tripped the velocity breaker
agent.created / agent.updated / agent.deletedAgent lifecycle
phone.provisioned / phone.releasedA number is attached to or released from an agent
call.started / call.endedA voice call begins or completes
call.summary.ready / call.score.readyPost-call processing finishes
call.security.alert / call.security.scan.readyA call's security scan raises an alert or completes
a2a.task.receivedAnother agent sent yours an A2A task
vault.credential.refresh_failedA stored OAuth credential could not be refreshed

Two things surprise people here. Email and SMS share one event — there is no email.received; channel is a field on the payload, not a separate event. And message.received.auto fires instead of message.received for auto-replies and out-of-office mail, so an agent that only subscribes to the latter will silently skip them. GET /webhooks/event-types returns the same list from the live API if you would rather read it from the source.

Wildcards match one segment, not all of them#

* matches exactly one dot-separated segment. ** matches across segments. That distinction bites on the three-segment names:

Patterncall.endedcall.security.alert
call.*matchesno match
call.**matchesmatches
*matchesmatches

So call.* quietly misses every security event, and message.* does not match message.received.auto. If you want everything under a prefix, use **.

The payload is flat#

There is no envelope to unwrap. Every event carries event and occurredAt; message events add addressing:

{
  "event": "message.received",
  "occurredAt": "2026-07-28T12:00:00.000Z",
  "messageId": "cme9x2k1p0001s601abcdefgh",
  "agentId": "cme9x2k1p0000s601ijklmnop",
  "channel": "email",
  "direction": "INBOUND",
  "fromAddress": "user@example.com",
  "toAddress": "support-agent@agents.useanima.sh",
  "threadId": "cme9x2k1p0002s601qrstuvwx",
  "subject": "Hello",
  "spam": false
}

That is enough to reply without a second call. What is not there is the message body — fetch GET /v1/messages/{id} when you need content. Spam still fires the event; you get the verdict in spam and decide what to do with it.

Subscriptions are scoped to your organization, not to an agent. One endpoint receives events for every agent you run, and there is no agentId on the subscription — use the agentId in the payload to tell them apart.

Verify against the raw body#

Every delivery carries four headers:

HeaderContents
X-Anima-Signaturev1=<hex> — HMAC-SHA256 of {timestamp}.{rawBody}
X-Anima-TimestampISO-8601 time the delivery was signed, bound into the signature
X-Anima-EventThe event name
X-Anima-Delivery-IdStable id for this delivery, unchanged across retries

The timestamp is inside the signed content specifically so you can reject replays. Recompute the HMAC, compare in constant time, and refuse anything outside a tolerance window:

import { createHmac, timingSafeEqual } from "node:crypto";
 
const TOLERANCE_MS = 5 * 60 * 1000;
 
export function verifyAnimaWebhook(
  rawBody: string,
  headers: { "x-anima-signature": string; "x-anima-timestamp": string },
  secret: string,
): boolean {
  const timestamp = headers["x-anima-timestamp"];
  if (Math.abs(Date.now() - Date.parse(timestamp)) > TOLERANCE_MS) return false;
 
  const provided = headers["x-anima-signature"].replace(/^v1=/, "");
  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
 
  const a = Buffer.from(provided, "hex");
  const b = Buffer.from(expected, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

Verify before you parse

Sign against the raw request body, before any JSON parse or re-serialize. Re-encoding can reorder keys or change whitespace, and the signature will no longer match a payload that is otherwise identical.

The signing secret is returned once, in the create response. GET /webhooks and GET /webhooks/{id} never return it again. If you lose it, rotate it — which immediately invalidates the old one:

curl -X POST https://api.useanima.sh/v1/webhooks/{id}/rotate-secret \
  -H "Authorization: Bearer mk_..."

Handle the event#

With verification done, the handler is a switch on event:

export async function POST(req: Request) {
  const rawBody = await req.text();
 
  const ok = verifyAnimaWebhook(
    rawBody,
    {
      "x-anima-signature": req.headers.get("x-anima-signature") ?? "",
      "x-anima-timestamp": req.headers.get("x-anima-timestamp") ?? "",
    },
    process.env.ANIMA_WEBHOOK_SECRET!,
  );
  if (!ok) return new Response("Invalid signature", { status: 400 });
 
  const payload = JSON.parse(rawBody);
 
  // Retries reuse the delivery id, so this is the idempotency key.
  const deliveryId = req.headers.get("x-anima-delivery-id");
  if (deliveryId && (await alreadyProcessed(deliveryId))) {
    return new Response("OK", { status: 200 });
  }
 
  switch (payload.event) {
    case "message.received":
    case "message.received.auto":
      await handleInboundMessage(payload);
      break;
    case "call.ended":
      await handleCallEnded(payload);
      break;
    default:
      // Unknown events are normal — new ones ship without warning.
      break;
  }
 
  if (deliveryId) await markProcessed(deliveryId);
  return new Response("OK", { status: 200 });
}

Configure delivery, and expect retries#

Anything other than a 2xx is retried with exponential backoff. Two settings control it, and both are set when you create or update the webhook:

  • maxAttempts — attempts before the delivery is dead-lettered. Default 3. An endpoint that keeps failing is auto-disabled.
  • rateLimitPerMinute — caps deliveries per minute to one endpoint. Over-limit deliveries defer to the next window rather than being dropped.
import { Anima } from "@anima-labs/sdk";
 
const anima = new Anima({ apiKey: process.env.ANIMA_API_KEY! });
 
await anima.webhooks.create({
  url: "https://example.com/hooks/anima",
  events: ["message.received", "message.sent"],
  authConfig: { type: "bearer", token: "your-endpoint-token" },
  rateLimitPerMinute: 120,
  maxAttempts: 5,
});

authConfig is optional and stacks on top of the HMAC — useful when your gateway expects a header rather than a signature. It supports bearer, basic, and custom_header, and the credential is write-only: set on create or update, never returned by a read.

Because retries reuse X-Anima-Delivery-Id, idempotency is a lookup on that id rather than a diff of payload contents. Store it, check it, return 200 fast, and do the slow work after — a handler that does inference before responding is a handler that will get retried while it is still thinking.

Stay Updated

Get the latest on AI agent identity, delivered weekly.