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.
Webhooks have no console screen; you register and manage them through the API. Register them per agent.
Register an endpoint
POST /v1/agents/{agentId}/webhook-endpointsNeeds the Read (messages:read) scope.
{ "url": "https://example.com/atmark-webhook", "event_types": ["message.received", "message.bounced"] }urlmust be anhttpsaddress. 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
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
| Event | When | data |
|---|---|---|
message.received | Mail reached the agent (quarantined mail excluded) | message_id, thread_id, received_at |
message.sent | Mail went out | message_id, sent_at |
approval.resolved | An approval request was approved or rejected | approval_id, decision, resolved_at, message_ids |
approval.expired | An approval deadline passed | approval_id, expired_at, message_ids |
message.delivered | The recipient's mail server accepted it (subscribe to receive) | message_id, delivered_at, recipient_positions |
message.bounced | It bounced (subscribe to receive) | message_id, bounced_at, recipient_positions, bounce_type (permanent, transient, undetermined) |
message.complained | A recipient reported it as spam (subscribe to receive) | message_id, complained_at, recipient_positions |
The request body looks like this.
{
"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).
| Header | Value |
|---|---|
webhook-id | The event ID. Use it to drop duplicates. |
webhook-timestamp | When it was sent (Unix seconds) |
webhook-signature | v1,<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.
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
2xxwithin 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
GET /v1/agents/{agentId}/webhook-deliveries?status=dead
POST /v1/agents/{agentId}/webhook-deliveries/{deliveryId}/redeliverThe 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
| Status | error | Meaning |
|---|---|---|
400 | invalid_webhook_url | The URL can't be registered. |
404 | not_found | The endpoint or delivery doesn't exist (delete, redelivery). |
409 | webhook_endpoint_limit | The agent's active endpoint limit was reached. |
409 | webhook_endpoint_registration_limit | The daily registration limit was reached. |
409 | delivery_not_dead | Only dead deliveries can be redelivered. |
409 | webhook_endpoint_deleted | Deliveries to a deleted endpoint can't be redelivered. |
409 | redelivery_destination_rejected | It died because the destination was rejected. Register a new endpoint with a corrected URL. |
409 | delivery_already_delivered | This delivery (or a redelivery of it) was already received. |
409 | redelivery_limit | This delivery's redeliveries are used up. |
429 | redelivery_rate_limited | The hourly redelivery limit was reached. Try again later. |
Feedback on this page? Write to support@atmark.ai.