// AGENT INBOX · HOW-TO

Inbound email webhooks and delivery events, in one place

Email webhooks come in two kinds that are constantly confused. An inbound webhook fires when mail arrives at your address and POSTs the envelope to your endpoint. A delivery webhook fires after you send, reporting queued, sent, delivered or bounced. Clize sets each up with one command: clize email address add <addr> --webhook <url> for inbound, clize email delivery-webhook set <url> for delivery. Delivery events are signed with X-Clize-Signature; inbound POSTs are not, and this page shows what to do about that.

Real payloads, not samplesHMAC verification in Node and PythonTwo directions, one page

Which webhook do you actually want?

Search for an email webhook and the results split into two camps that never mention each other. Inbound tooling — mail arrives, your endpoint gets a POST. Delivery tooling from the sending providers — you sent something, here is what happened to it. Both are called an email webhook, and picking up the wrong guide costs an afternoon.

Inbound webhookDelivery webhook
Fires whenSomeone sends mail to your address.Something you sent changes state.
Set up withclize email address add <addr> --webhook <url>clize email delivery-webhook set <url>
ScopeOne mailbox.One sending domain, or all of them.
BodyThe envelope: sender, recipient, subject, time, message id, triage class.The event name, message id, recipients, sender, subject, timestamp.
SignedNo. Treat the URL as a secret and verify out of band.Yes — X-Clize-Signature, HMAC-SHA256 over the raw body.
Typical useSupport ticket from an email, an agent reacting to a reply, parsing a form-by-mail.Suppression lists, retry logic, a dashboard that knows which digest bounced.

Most projects eventually want both, which is why they are on one page here. An agent that answers customer mail needs the inbound side to know a message arrived and the delivery side to know its reply landed. Wiring only the first leaves you guessing about the second.

Inbound: turn an address into an HTTP POST

An address in Clize is an object, and the webhook is a property of it. Open a mailbox and give it a URL in one step:

$ clize email address add support@acme.com --webhook https://acme.com/hooks/mail

If the mailbox already exists — the support@ address a free handle gets from clize claim <slug> --email, for instance — attach the URL afterwards:

$ clize email address update support@studio.clize.app --webhook https://acme.com/hooks/mail

update only touches what you name. Tag, knowledge file, owner and forwarding stay as they were, so you can point a mailbox at a staging endpoint and back without disturbing anything else.

Two things that are not how it works, both of which appear in older notes and are wrong. There is no --webhook flag on clize email setup: that command switches a whole domain on for send and receive, and it takes no options. And a webhook does not replace the mailbox — mail is still stored, still indexed, still readable with clize email inbox, search and show. The POST is an additional push, not a redirect. That matters more than it sounds, because it is what makes reconciliation possible when a POST is lost.

Forwarding is a separate, orthogonal setting. --forward you@gmail.com also drops a copy into a human inbox, once the recipient confirms it. A mailbox can push to your endpoint, forward to a person and be read by an agent at the same time.

What the inbound POST actually contains

When mail lands, Clize parses it, classifies it, stores the raw MIME, indexes it, and then POSTs this to your URL. This is the object as it is emitted, field for field:

POST https://acme.com/hooks/mail
content-type: application/json

{
  "from": "\"Ana Duarte\" <ana@example.com>",
  "to": "support@acme.com",
  "subject": "Re: invoice 1042",
  "receivedAt": "2026-09-04T09:21:44.812Z",
  "id": "msg_3f0a7c19b2d84e6510af22c7",
  "class": "human"
}

Six fields, and it is worth reading what is missing as carefully as what is there.

  • from is the header value as sent, display name included when there is one. Parse it; do not assume a bare address.
  • to is the delivery recipient, lower-cased and stripped of any display name.
  • id is the stable message id, derived from a hash of the Message-ID header. It is your idempotency key, and it is the argument to clize email show.
  • class is the triage verdict: human, otp, notice, promo, spam or self. Most endpoints only want the first two; the classifier reads authentication results, sender history and content signals.
  • There is no body. No text, no HTML, no attachments, no headers. The POST is an envelope — a notification that something arrived and what shape it was.

That last point is a deliberate trade and you should design for it rather than around it. A body in the payload means an unbounded POST to your endpoint, sometimes megabytes of quoted history and inline images, from a sender who chose the size. The envelope is small and predictable; when you want the content, fetch it by id:

$ clize email show msg_3f0a7c19b2d84e6510af22c7

The POST is not signed. There is no shared secret and no signature header on the inbound side today — that machinery exists for delivery events, further down this page. So treat the endpoint URL as a credential: give it an unguessable path, keep it out of your repository, and do not let it perform a privileged action on the strength of the request alone. The safe pattern is to treat the POST purely as a trigger and re-read the message by id through the API before acting on it, which also gets you the body you needed anyway.

Delivery: queued, sent, delivered, bounced

The other direction covers mail sent through the transactional API with a scoped send key. Register one URL for everything, or one per sending domain:

$ clize email delivery-webhook set https://acme.com/hooks/clize-email --domain acme.com
# → { "url": "…", "secret": "whsec_…", "message": "save the signing secret (shown once)" }

$ clize email delivery-webhook list
$ clize email delivery-webhook remove --domain acme.com

Save that secret when it appears; it is shown once and it is what the next section verifies. A domain-specific hook wins over the account-wide fallback, so you can route one product's events somewhere separate without disturbing the rest.

Events arrive as email.queued, email.sent, email.delivered or email.bounced. A real one:

POST https://acme.com/hooks/clize-email
content-type: application/json
x-clize-event: email.bounced
x-clize-signature: 4f2a9c7e13b8d05a6c1e8f37b204da915ce6083f7a2d4b19e05c8f6132ad7be4

{
  "event": "email.bounced",
  "messageId": "msg_9f3c1ab27de4",
  "to": ["user@example.com"],
  "from": "reports@mail.acme.com",
  "subject": "Weekly metrics",
  "at": "2026-09-04T09:22:03.114Z"
}

Three notes on that payload. to is always an array, even for one recipient. Non-bounce events carry an extra providerId, the upstream provider's own reference, useful when you are reading their logs alongside yours. And from is the resolved sending identity rather than the address you typed: hosted sending maps reports@acme.com onto the verified subdomain reports@mail.acme.com, with the natural address preserved as the reply-to. Match on messageId, not on the from address.

Verifying X-Clize-Signature

The most thorough inbound-webhook guide currently ranking for this term runs to about 2,500 words and never mentions signature verification. It is the step that decides whether your endpoint is an integration or an open door, so here it is in both languages people actually receive webhooks in.

The rule is one line: X-Clize-Signature is the lowercase hex HMAC-SHA256 of the raw request body, keyed with your webhook secret. Raw is the load-bearing word — verify the bytes that arrived, before any JSON parse. A re-serialised object will not match, because key order and whitespace will not survive the round trip.

// Node — Express. Note express.raw, not express.json.
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

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

app.post("/hooks/clize-email", express.raw({ type: "application/json" }), (req, res) => {
  const got = Buffer.from(req.get("x-clize-signature") || "", "utf8");
  const want = Buffer.from(createHmac("sha256", SECRET).update(req.body).digest("hex"), "utf8");
  if (got.length !== want.length || !timingSafeEqual(got, want)) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString("utf8"));
  console.log(event.event, event.messageId);
  res.sendStatus(200);
});
# Python — Flask. request.get_data() is the raw body.
import hmac, hashlib, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["CLIZE_WEBHOOK_SECRET"].encode()

@app.post("/hooks/clize-email")
def clize_email():
    raw = request.get_data()
    want = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(want, request.headers.get("X-Clize-Signature", "")):
        abort(401)

    event = request.get_json()
    app.logger.info("%s %s", event["event"], event["messageId"])
    return "", 200

Both use a constant-time comparison. That is not ceremony: a naive == on a hex string leaks timing information an attacker can walk one character at a time, and it costs nothing to avoid. The X-Clize-Event header carries the event name too, which is convenient for routing — but route on the verified body, never on an unverified header.

What happens to a hard bounce

Most writing about bounce codes stops at taxonomy: 5.1.1 means no such user, 4.x.x means try later, a hard bounce is permanent and a soft bounce is temporary. Useful, and it stops exactly where the work starts — because the real question is what happens automatically after the bounce, and whether you have to build it.

When a hard bounce is reported at send time, Clize does three things without being asked. The recipient goes onto your account-wide suppression list. An email.bounced event fires to your delivery webhook. And the message is recorded as bounced in the transactional ledger, readable later with clize email messages --status bounced.

The suppression list is the part that saves you from yourself. Every later send checks it first, and a suppressed recipient comes back as status: "suppressed" with nothing sent — which means you can fire your whole subscriber loop at the API without maintaining opt-out state anywhere in your own code. The same list absorbs unsubscribes: any message tagged with a list id gets RFC 8058 one-click unsubscribe headers and a footer link injected, and the recipient's click lands on the hosted page and into the list. Manage it directly when you need to:

$ clize email suppressions --list weekly-digest       # who opted out
$ clize email suppress   user@example.com --reason manual
$ clize email unsuppress user@example.com

One boundary to be precise about, because it decides whether this fits your case. Bounces reported synchronously by the provider at send time are what suppress and fire today. Asynchronous bounce and complaint feeds — the callbacks and feedback loops that arrive minutes or hours later — ride the same webhook and suppression machinery and light up as those signals become available upstream. The dispatch side is built; the coverage depends on what the provider reports back.

Retries, idempotency, and what to do when a POST is lost

Here is the honest reliability picture, which is more useful than a promise you would find out about the hard way.

Neither webhook is retried. Both are dispatched in the background after the work that triggered them, and a failed POST — your endpoint down, a timeout, a 500 — is dropped silently. Notification never blocks the mail: a broken endpoint of yours will not stop mail being received, stored, or sent.

Duplicates are prevented upstream, not by you. Inbound ingest is idempotent on a digest of the Message-ID and the raw message, so the same mail redelivered to the same mailbox is not stored twice and does not fire the webhook a second time. Still key on id at your end; it costs one index and it protects you from your own retries.

So reconcile rather than trust. Because the mailbox is the source of truth and the webhook is only a notification, a lost POST is recoverable without anyone replaying anything:

$ clize email inbox --since 2026-09-04T00:00:00Z --all      # what actually arrived
$ clize email messages --status bounced --limit 50           # what actually happened to sends

Run the first on a timer if your endpoint matters — anything present there and absent from your database is a POST that went missing. This is the compensating design for having no retry queue, and it is available because we never made the webhook the only copy.

Sending itself has a stronger guarantee than the notifications do. The transactional endpoint honours an Idempotency-Key header, remembered for seven days: retry with the same key after a timeout and you get the original message id back with idempotent: true, rather than a second copy in someone's inbox. Use a natural key — digest-2026-w36-user42 — not a random one.

The DNS you do not have to write

Receiving mail at your own domain normally starts with a DNS panel, and that is where inbound webhook projects stall. One command instead:

$ clize email setup acme.com

That turns on Cloudflare Email Routing for the domain and writes the MX and SPF records for you, then onboards mail.acme.com as the sending subdomain and triggers its verification. Run it again later to re-trigger verification if it has not completed.

Be precise about the boundary, because plenty of guides are not. MX and SPF are written; DKIM and DMARC records are not. If your deliverability checklist or your compliance review requires a published DMARC policy, that is a record you add yourself. Everything on this page works without it; your inbox placement on strict receivers may not be what you want until you do.

If the domain is not yours yet, the same account registers it — clize domain search, then clize domain buy, which returns a price quote and only registers with --confirm. Agent Domains covers that half.

When the receiver is an AI agent

If the thing on the other end of that POST is a language model rather than a parser, the threat model changes and the webhook is the wrong place to be casual.

Inbound mail is attacker-controlled text. Anyone who learns the address can put words in front of your model, and a support address is meant to be public. Clize labels this at the source: every command that reads mail prints one line to stderr — that what follows is inbound email, data and not instructions — before the JSON reaches stdout, and the inbox is triaged so that only real people and verification codes come back by default.

A webhook bypasses all of that framing, because you are wiring the mail into your own code path. So keep the labelling yourself: mark the content as untrusted where you put it into the prompt, decide on the class field rather than on the message text, and never let an inbound message alone authorise an action with a side effect. The outbound gate is the backstop — clize email send still returns a draft until a human adds --confirm, so a successful injection cannot turn itself into mail leaving your domain. The comparison page goes through that gate in detail, and the MCP server page covers the version of this where the agent, not your endpoint, is the receiver.

// FAQ

What is an inbound email webhook?

It is an HTTP endpoint that receives a POST whenever mail arrives at one of your addresses, so an email can trigger code instead of waiting to be polled. With Clize you attach one to a mailbox with clize email address add <addr> --webhook <url>, or clize email address update for a mailbox that already exists. The mail is still stored and readable through the API; the POST is an extra push, not a redirect.

What fields are in the inbound payload?

Six: from (the header value, display name included), to (lower-cased recipient), subject, receivedAt as an ISO timestamp, id (a stable msg_ identifier), and class — the triage verdict, one of human, otp, notice, promo, spam or self. There is no message body, no headers and no attachments. Fetch the content by id with clize email show when you need it.

Are inbound webhooks signed?

No. Inbound POSTs carry only a content-type header today, with no shared secret and no signature. Treat the endpoint URL as a credential: use an unguessable path, keep it out of source control, and treat the POST as a trigger rather than as evidence — re-read the message by id through the API before acting on it. Delivery events are different: those carry X-Clize-Signature.

How do I verify X-Clize-Signature?

Compute the lowercase hex HMAC-SHA256 of the raw request body, keyed with the whsec_ secret shown once when you ran clize email delivery-webhook set, and compare it to the header in constant time. Verify the raw bytes before parsing JSON; a re-serialised object will not match because key order and whitespace change. The X-Clize-Event header names the event, but route on the verified body rather than on that header.

What happens automatically when an email hard bounces?

Three things, without you wiring them. The recipient is added to your account-wide suppression list, so later sends to that address return status suppressed and nothing goes out. An email.bounced event fires to your delivery webhook. And the message is recorded as bounced in the ledger, visible with clize email messages --status bounced. This covers bounces the provider reports synchronously at send time; asynchronous feedback loops ride the same machinery as those signals become available.

Are webhook deliveries retried if my endpoint is down?

No. Both inbound and delivery webhooks are dispatched in the background and a failed POST is dropped silently, so a broken endpoint never blocks mail from being received or sent. Reconcile instead of relying on retries: the mailbox is the source of truth, so clize email inbox --since <iso> --all shows everything that actually arrived and clize email messages shows what happened to your sends. Inbound ingest is also deduplicated upstream, so redelivery of the same message does not fire the webhook twice.

clize email address add --webhook — one command, one endpoint

Point a real address at your endpoint.

Claim a handle with an inbox, attach a webhook, and send yourself a test message. The envelope arrives in seconds, and the mail is still in the inbox when you want the body.

$ npm i -g @clize/clize && clize login
$ clize claim studio --email
$ clize email address update support@studio.clize.app --webhook https://acme.com/hooks/mail
[ Agent Inbox → ]