Receive webhooks

Get incoming mail, sends, and approval results pushed to your server. Webhooks are managed through the API only, and every request is signed.

View as MarkdownLast updated

Webhooks have no console screen; you register and manage them through the API. Register them per agent.

Register an endpoint

http
POST /v1/agents/{agentId}/webhook-endpoints

Needs the Read (messages:read) scope.

json
{ "url": "https://example.com/atmark-webhook", "event_types": ["message.received", "message.bounced"] }
  • url must be an https address. Private network addresses are refused (400 invalid_webhook_url).
  • Without event_types, you get the default four (approval.resolved, approval.expired, message.sent, message.received).
  • The subscription can't be changed after registration. To change it, register a new endpoint.

The 201 response includes the secret (whsec_…) exactly once. It can't be fetched again, so store it right away. If you lose it, register a new endpoint.

Your plan sets how many active endpoints an agent can have (409 webhook_endpoint_limit). The number of registrations per day is limited too (409 webhook_endpoint_registration_limit).

List and delete

http
GET    /v1/agents/{agentId}/webhook-endpoints
DELETE /v1/agents/{agentId}/webhook-endpoints/{endpointId}

The list never includes secrets. Deleting needs the Send (messages:send) scope; nothing more is sent to a deleted endpoint, and it can't be restored.

Events

EventWhendata
message.receivedMail reached the agent (quarantined mail excluded)message_id, thread_id, received_at
message.sentMail went outmessage_id, sent_at
approval.resolvedAn approval request was approved or rejectedapproval_id, decision, resolved_at, message_ids
approval.expiredAn approval deadline passedapproval_id, expired_at, message_ids
message.deliveredThe recipient's mail server accepted it (subscribe to receive)message_id, delivered_at, recipient_positions
message.bouncedIt bounced (subscribe to receive)message_id, bounced_at, recipient_positions, bounce_type (permanent, transient, undetermined)
message.complainedA recipient reported it as spam (subscribe to receive)message_id, complained_at, recipient_positions

The request body looks like this.

json
{
  "id": "…",
  "type": "message.received",
  "api_version": "2026-09-23",
  "created_at": "2026-09-27T09:00:00.000Z",
  "agent_id": "7c21…f5a1",
  "data": { "message_id": "…", "thread_id": "…", "received_at": "2026-09-27T09:00:00.000Z" }
}

Webhooks never include the sender, subject, body, or recipient addresses. If you need them, call the received mail API with message_id. recipient_positions are positions (from 0) in that email's recipient list, after lowercasing the addresses, removing duplicates, and sorting by Unicode code point. Sort your own recipient list the same way to map positions back to addresses. Values are ascending and unique.

Verify the signature

Every request carries three headers (Standard Webhooks format).

HeaderValue
webhook-idThe event ID. Use it to drop duplicates.
webhook-timestampWhen it was sent (Unix seconds)
webhook-signaturev1,<base64 signature>. Several are separated by spaces.

The signature is an HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<body> with your secret. The part of the secret after whsec_ is base64.

verify-webhook.mjs
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyWebhook(secret, headers, rawBody) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = headers['webhook-signature'];
  if (!id || !timestamp || !signatures) return false;
  const ts = Number(timestamp);
  if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();

  return signatures.split(' ').some((entry) => {
    const [version, value] = entry.split(',');
    if (version !== 'v1' || !value) return false;
    const given = Buffer.from(value, 'base64');
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}

Use the raw body exactly as received, before parsing. Accepting only timestamps within 5 minutes blocks replays.

Delivery contract

  • Respond with 2xx within 10 seconds. Do heavy work after acknowledging.
  • Anything other than 2xx, a timeout, or a connection failure is a failure. Redirects (3xx) aren't followed and count as failures.
  • Failed deliveries are retried after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 2 hours, 4 hours, 8 hours, and 16 hours: 10 attempts in total, over about 31 hours 40 minutes.
  • Retries keep the same event ID and body with a fresh signature time. Drop duplicates by webhook-id.
  • If the destination is rejected at delivery time (a domain that resolves to a private address, an invalid URL, or a deleted endpoint), there are no retries and the delivery goes straight to dead.
  • A delivery that never succeeds also ends as dead.

Delivery records and redelivery

http
GET  /v1/agents/{agentId}/webhook-deliveries?status=dead
POST /v1/agents/{agentId}/webhook-deliveries/{deliveryId}/redeliver

The list (Read scope) takes status (pending, delivered, dead), limit (1–100), and cursor. Each item has id, event_id, event_type, endpoint_id, status, attempt_count, last_error, last_http_status, redelivery_of, created_at, next_attempt_at (only while pending), delivered_at, and dead_at. Bodies and URLs aren't in the list.

Redelivery (Send scope) only works for dead deliveries and creates a new delivery with the same event ID and body (201). One delivery can be redelivered up to 3 times, and an agent can make up to 100 redeliveries per hour.

Errors

StatuserrorMeaning
400invalid_webhook_urlThe URL can't be registered.
404not_foundThe endpoint or delivery doesn't exist (delete, redelivery).
409webhook_endpoint_limitThe agent's active endpoint limit was reached.
409webhook_endpoint_registration_limitThe daily registration limit was reached.
409delivery_not_deadOnly dead deliveries can be redelivered.
409webhook_endpoint_deletedDeliveries to a deleted endpoint can't be redelivered.
409redelivery_destination_rejectedIt died because the destination was rejected. Register a new endpoint with a corrected URL.
409delivery_already_deliveredThis delivery (or a redelivery of it) was already received.
409redelivery_limitThis delivery's redeliveries are used up.
429redelivery_rate_limitedThe hourly redelivery limit was reached. Try again later.

Feedback on this page? Write to support@atmark.ai.