Developer documentation

Webhook integration guide

AtEmail sends a signed JSON request to your endpoint whenever an email matches one of your rules. There is no SDK to install and nothing to poll: accept a POST, check one header, and reply 200.

How it works

  1. A connected Gmail account receives a message, and Google pushes it to AtEmail.
  2. AtEmail syncs the message and evaluates your active criteria against it.
  3. For each rule that matches, the webhooks linked to that rule are queued for delivery.
  4. AtEmail generates an AI summary of the message, if it does not already have one.
  5. It POSTs the message and summary to your URL, signed, and records the outcome in the delivery log.

Everything below describes that final POST and what your endpoint has to do with it.

Quickstart

  1. In Settings → Webhooks, add your endpoint URL and a signing secret. Generate a long random secret; it is the only thing proving a request came from us.
  2. In Settings → Email Criteria, create a rule and link it to that webhook.
  3. Deploy a receiver using one of the samples in Verify the signature, then send yourself a matching email and watch it arrive in Settings → Logs.

The request

Every delivery is a POST with a JSON body and these headers:

HeaderValue
Content-TypeAlways application/json.
X-AtEmail-SignatureHMAC-SHA256 of the raw request body, as lowercase hex.
X-AtEmail-TimestampUnix timestamp in milliseconds for when the request was sent.
Your custom headersAny headers you configured on the webhook, for example an account identifier your system expects.

Payload reference

POST /your-endpoint
{
  "event": "email.matched",
  "email": {
    "id": "clx123abc",
    "subject": "Invoice #4521 — payment due",
    "from": { "address": "billing@acme.com", "name": "Acme Billing" },
    "to": ["billing@yourteam.com"],
    "snippet": "Please find attached the invoice for…",
    "receivedAt": "2026-06-30T14:22:00.000Z",
    "labels": ["INBOX", "IMPORTANT"],
    "aiSummary": "Acme Billing sent invoice #4521 with a payment due date of July 15."
  },
  "timestamp": "2026-06-30T14:22:05.123Z"
}
FieldTypeNotes
eventstringAlways email.matched for rule deliveries.
email.idstringStable AtEmail identifier. Use it to deduplicate.
email.subjectstring | nullSubject line.
email.fromobjectSender address and display name, each may be null.
email.tostring[]Recipient addresses.
email.snippetstring | nullShort preview supplied by Gmail.
email.receivedAtstring | nullISO 8601 time the message was received.
email.labelsstring[]Gmail label IDs, such as INBOX or IMPORTANT.
email.aiSummarystring | nullTwo or three sentence summary. Null if generation failed.
timestampstringISO 8601 time the webhook was sent.

The message body is not included. If you need the full content, keep the email.id and fetch it from AtEmail when you need it.

Verify the signature

The signature is HMAC-SHA256 of the raw request body, using the webhook’s secret, encoded as lowercase hex. Compute it over the bytes you received, before any JSON parsing: re-serialising the body changes it and the signature will not match. Compare in constant time, and reject anything that fails.

webhook.js
const crypto = require('crypto');
const express = require('express');

const app = express();
const SECRET = process.env.ATEMAIL_WEBHOOK_SECRET;

function verify(rawBody, signature) {
  if (!signature) return false;
  const expected = crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
  try {
    return crypto.timingSafeEqual(
      Buffer.from(expected, 'hex'),
      Buffer.from(signature, 'hex'),
    );
  } catch {
    return false;             // malformed signature header
  }
}

// Register BEFORE express.json() so the raw body is still available.
app.post(
  '/api/atemail/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const rawBody = req.body.toString('utf8');

    if (!verify(rawBody, req.headers['x-atemail-signature'])) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    const { email } = JSON.parse(rawBody);

    // Answer first, work afterwards: you have 30 seconds.
    res.status(200).json({ received: true, email_id: email.id });

    queue.add(() => handleEmail(email));
  },
);

app.listen(3000);

Responding

Any 2xx status counts as success. Reply within 30 seconds; anything slower is treated as a failure and retried. Acknowledge first and do the real work afterwards, in a queue or background job.

200 OK
{
  "received": true,
  "email_id": "clx123abc"
}
Your responseWhat AtEmail does
200Marks the delivery SUCCESS and stops.
4xx, 5xx, timeoutMarks the attempt failed and retries, up to five attempts in total.

Retries and duplicates

SettingValue
Maximum attempts5
BackoffExponential, starting at 10 seconds
Timeout per request30 seconds

Because a slow response can be retried after your system already handled it, the same message can arrive more than once. Make your handler idempotent by keeping the email.id values you have processed and ignoring repeats.

Matching rules

A rule decides which emails reach your endpoint. Each filter is a comma-separated list, and matching is case-insensitive.

FilterMatches when
fromFilterThe sender address contains any one of the values.
toFilterAny recipient contains any one of the values.
subjectFilterThe subject contains any one of the values.
labelFilterThe message carries all of the listed Gmail labels.

Filters you leave empty are ignored. A rule with several filters set requires all of them to match, so start broad and narrow it once you see real deliveries in the log.

Delivery logs

Settings → Logs lists every attempt for a webhook with its status, attempt count, a snippet of your response and any error. Statuses are PENDING, SUCCESS, RETRYING and FAILED. This is the first place to look when a message did not arrive: a rule that never matched shows no attempt at all, while a rejected signature shows an attempt with a 401.

Endpoint requirements

  • Accept POST with a JSON body, and use HTTPS in production.
  • Be reachable on the public internet.
  • Return 2xx within 30 seconds.
  • Deduplicate on email.id.

AtEmail refuses destinations that are not publicly routable. URLs with embedded credentials, and addresses on loopback, private, carrier-grade NAT or link-local ranges including cloud metadata endpoints, are rejected when you save the webhook and checked again immediately before every delivery. Redirects are never followed, so the URL you save must be the URL that handles the request.

Management API

Everything in the dashboard is also available over the API, for provisioning webhooks and rules programmatically. Authenticate with a bearer token and send JSON.

Create a webhook
POST /api/webhooks
Authorization: Bearer <token>
Content-Type: application/json

{
  "connectedAccountId": "<account-id>",
  "name": "Production CRM",
  "url": "https://your-system.com/api/atemail/webhook",
  "secret": "whsec_your_random_secret_here",
  "headers": { "X-Client-Account": "billing@yourteam.com" }
}
Create a rule and link it
POST /api/criteria
Authorization: Bearer <token>
Content-Type: application/json

{
  "connectedAccountId": "<account-id>",
  "name": "Client invoices",
  "fromFilter": "billing@acme.com",
  "subjectFilter": "invoice, payment due",
  "webhookIds": ["<webhook-id>"]
}
Method and pathPurpose
GET /api/webhooks/accounts/:accountIdList webhooks for an account.
POST /api/webhooksCreate a webhook.
PATCH /api/webhooks/:idUpdate a webhook.
DELETE /api/webhooks/:idDelete a webhook.
GET /api/webhooks/:id/logsPaginated delivery logs.
GET /api/criteria/accounts/:accountIdList rules for an account.
POST /api/criteriaCreate a rule.

The full reference, including every parameter, is published by the API itself at /api-docs.

Troubleshooting

Nothing arrives and the log is empty

No rule matched. Check the filters against the real sender and subject, confirm the rule is active, and confirm it is linked to the webhook.

Every attempt returns 401

The signature check is failing. The usual cause is verifying a re-serialised body instead of the raw one, or a mismatch between the secret in AtEmail and the one your code reads.

Deliveries time out

Your handler is doing the work before replying. Return 2xx immediately and process the message in the background.

The same email arrives repeatedly

A slow or failed response triggered a retry. Deduplicate on email.id.

aiSummary is null

Summary generation failed for that message. The delivery still happens, so fall back to email.snippet.