Webhooks
Real-time event notifications for messages, contacts, and agent activity.
Overview
Webhooks allow you to receive real-time notifications when events happen in your CustomAgents account. Configure a URL endpoint and select the events you want to receive. Deliveries are queued, signed, retried with exponential backoff, and recorded so you can audit every attempt.
Events
Each subscription lists the events it wants. Events marked emitted today are delivered now; planned events are part of the catalog but not yet produced.
| Event | Description | Status |
|---|---|---|
message.received | A new message was received by an agent | emitted today |
message.sent | An agent sent a message | emitted today |
inbox.created | An inbox was created | emitted today |
inbox.deleted | An inbox was deleted | emitted today |
inbox.updated | An inbox's configuration changed | planned |
message.delivered | A sent message was delivered | planned |
message.bounced | A sent message bounced | planned |
message.complained | A recipient marked a message as spam | planned |
message.rejected | A message was rejected before delivery | planned |
Payload format
Every delivery is a JSON envelope. The id is stable across retries of the same delivery — dedupe on it, since a flaky endpoint may receive the same event more than once. Delivery order across events is best-effort; use id + timestamp to reorder if you need to.
{
"id": "d1f0c3e2-8b7a-4c19-9f2e-6a1b2c3d4e5f",
"event": "message.received",
"timestamp": "2026-08-05T10:30:00.000Z",
"apiVersion": "2026-08-05",
"inboxId": "inbox_abc123",
"messageId": "msg_xyz789",
"payload": { "...": "the serialized resource for this event" }
}Setup
Via API
POST /v1/webhooks{
"url": "https://yourapp.com/webhook",
"events": ["message.received", "message.sent"]
}The signing secret is generated by CustomAgents and returned exactly once in the creation response (secret, format whsec_...). Store it securely — subsequent GETs never include it. Rotate it any time with POST /v1/webhooks/:id/rotate-secret (the new secret is likewise returned once). The endpoint URL must be a public https:// address — localhost, private, link-local, and cloud-metadata targets are rejected.
{
"id": "wh_abc123",
"url": "https://yourapp.com/webhook",
"events": ["message.received", "message.sent"],
"status": "active",
"secret": "whsec_returned_once_on_creation_store_it_securely"
}Delivery, retries, and auto-disable
- Each delivery is
POSTed with a 10-second timeout. A non-2xx response, a timeout, or a connection error is a failure. - Failed deliveries are retried up to 5 times with exponential backoff (~30s, 1m, 2m, 4m).
- Every attempt is recorded (status, error, response, duration) — inspect them at
GET /v1/webhooks/:id/attempts. - After 20 consecutive deliveries that exhaust all retries, the subscription is auto-disabled and the account owner is notified. Fix your endpoint, then re-enable it with
POST /v1/webhooks/:id/enable.
Verification
Every delivery carries an X-CustomAgents-Signature header in the Stripe-compatible scheme:
X-CustomAgents-Signature: t=1754388600,v1=<hex hmac-sha256>t is the Unix timestamp (seconds) at send time and v1 is HMAC-SHA256(secret, "<t>.<rawBody>") — the timestamp and a literal . prepended to the raw request body. Verify it by recomputing the HMAC over t + "." + rawBody, comparing in constant time, and rejecting timestamps outside a replay window (e.g. 5 minutes):
import crypto from 'crypto';
function verifyWebhook(
rawBody: string,
signatureHeader: string,
secret: string,
toleranceSeconds = 300,
): boolean {
const parts = Object.fromEntries(
signatureHeader.split(',').map((kv) => kv.split('=', 2) as [string, string]),
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!Number.isFinite(t) || !v1) return false;
// Reject replays outside the tolerance window.
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`, 'utf8')
.digest('hex');
const a = Buffer.from(v1, 'hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Compute the HMAC over the raw bytes of the request body, before any JSON parsing — re-serializing the parsed object can change the bytes and break the signature.