CUSTOMAGENTS
Developers

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.

EventDescriptionStatus
message.receivedA new message was received by an agentemitted today
message.sentAn agent sent a messageemitted today
inbox.createdAn inbox was createdemitted today
inbox.deletedAn inbox was deletedemitted today
inbox.updatedAn inbox's configuration changedplanned
message.deliveredA sent message was deliveredplanned
message.bouncedA sent message bouncedplanned
message.complainedA recipient marked a message as spamplanned
message.rejectedA message was rejected before deliveryplanned

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.