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.
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.
| Event | Fires when |
|---|---|
message.received | Email or SMS arrives for one of your agents |
message.received.auto | Inbound mail detected as automated. Fires instead of message.received |
message.sent | An outbound email or SMS is accepted for delivery |
message.failed | An outbound message failed, or the recipient complained |
message.bounced | An outbound email bounced |
message.loop_detected | Repeated sends to one address tripped the velocity breaker |
agent.created / agent.updated / agent.deleted | Agent lifecycle |
phone.provisioned / phone.released | A number is attached to or released from an agent |
call.started / call.ended | A voice call begins or completes |
call.summary.ready / call.score.ready | Post-call processing finishes |
call.security.alert / call.security.scan.ready | A call's security scan raises an alert or completes |
a2a.task.received | Another agent sent yours an A2A task |
vault.credential.refresh_failed | A 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:
| Pattern | call.ended | call.security.alert |
|---|---|---|
call.* | matches | no match |
call.** | matches | matches |
* | matches | matches |
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:
| Header | Contents |
|---|---|
X-Anima-Signature | v1=<hex> — HMAC-SHA256 of {timestamp}.{rawBody} |
X-Anima-Timestamp | ISO-8601 time the delivery was signed, bound into the signature |
X-Anima-Event | The event name |
X-Anima-Delivery-Id | Stable 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.