Trigger agents from inbound email
An email webhook can deliver straight to an agent’s webhook trigger. You register the email webhook with the trigger’s URL and secret, and Sapiom signs each delivery the way the trigger expects. No relay service sits in between.
There are two modes, set on the trigger:
Trigger target | What a delivery does |
|---|---|
start (default) | Starts a new run of the agent. The message fields become the run input. |
signal | Resumes the runs paused on a signal whose correlation ID matches a field of the message, such as threadId. Starts no run. |
The last section is the signing reference for both email webhook signature versions, for when you receive email events on your own server instead.
Before you start:
- Deploy the agent and confirm it has a
readybuild. - Create the inbox that will receive the mail. See Email.
Start an agent from inbound mail
Section titled “Start an agent from inbound mail”-
Create a webhook trigger
Section titled “Create a webhook trigger”Terminal window curl -sS -X POST https://api.sapiom.ai/v1/workflows/definitions/support-intake/triggers \-H "x-api-key: $SAPIOM_API_KEY" \-H "content-type: application/json" \-d '{ "kind": "webhook" }'support-intakeis the agent’s slug. The response carries the trigger’sid, itsurl(https://api.sapiom.ai/v1/workflows/hooks/{publicId}), and itssecret. The secret is shown once: keep it until the next step is done. If you lose it, rotate it. -
Register an email webhook for
Section titled “Register an email webhook for message.received”message.receivedPass the trigger’s URL, the trigger’s secret, and
signatureVersion: "v2":Terminal window curl -sS -X POST https://agentmail.services.sapiom.ai/v1/webhooks \-H "x-sapiom-api-key: $SAPIOM_API_KEY" \-H "content-type: application/json" \-d '{"url": "https://api.sapiom.ai/v1/workflows/hooks/<publicId>","eventType": "message.received","secret": "<trigger secret>","signatureVersion": "v2"}'The response has the registration’s
id,url,eventType, andsignatureVersion. Check thatsignatureVersionis"v2". Av1registration pointed at a trigger gets a401on every delivery, because the trigger only verifies v2. -
Send mail to the inbox
Section titled “Send mail to the inbox”Each message that arrives at any inbox in your organization is delivered to the trigger and starts one run. To confirm, inspect the trigger with
GET /v1/workflows/triggers/{id}: each entry inrecentFireshas theexecutionIdof the run it started. A delivery the trigger rejected leaves no fire.
A webhook registration receives events for every inbox in the organization. There is no per-inbox filter. If only some inboxes should start this agent, compare inboxId in the entry step and end the run early for the others.
What the run receives
Section titled “What the run receives”The message.received payload becomes the run input, folded over the trigger’s stored input:
{ "event": "message.received", "eventType": "message.received", "eventId": "evt_2pXk9…", "threadId": "thr_…", "messageId": "msg_…", "timestamp": "2026-10-14T09:12:40.000Z", "subject": "Order 1042 has not arrived", "preview": "Hi, I ordered on Monday and…"}The payload carries a preview, not the full body. Read the message with messages.get(inboxId, messageId); its extractedText is the new content with the quoted history removed. All payload fields are described in the event payload reference.
Declare the fields the entry step reads with z.object(), which ignores the others:
import { defineStep, terminate } from "@sapiom/agent";import { z } from "zod/v4";
const inboundEmail = z.object({ inboxId: z.string(), threadId: z.string(), messageId: z.string(), from: z.string(), subject: z.string().optional(),});
const intake = defineStep({ name: "intake", next: [], terminal: true, inputSchema: inboundEmail, async run(input, ctx) { const message = await ctx.sapiom.email.messages.get(input.inboxId, input.messageId); if (message.inReplyTo) { // A reply on a thread another run already handles. See "Resume a run waiting on a reply". return terminate({ skipped: "reply" }); } return terminate({ from: input.from, subject: input.subject ?? "", body: message.extractedText ?? message.text ?? "", }); },});A payload that fails the entry inputSchema starts no run; the fire is marked failed with reason input_invalid.
Replies arrive as message.received too, so a start-mode registration starts a run for every reply as well. The inReplyTo check above ends those runs at once. Leave it out if every message, replies included, should start a run.
Resume a run waiting on a reply
Section titled “Resume a run waiting on a reply”An agent that sends mail and waits for the answer pauses on a signal, with the thread ID as the correlation ID. A webhook trigger with target: "signal" turns each inbound message into that signal.
Pause on the thread
Section titled “Pause on the thread”import { defineAgent, defineStep, pauseUntilSignal, terminate } from "@sapiom/agent";import { z } from "zod/v4";
const ask = defineStep({ name: "ask", next: ["handleReply"], pause: { signal: "email.reply", resumeStep: "handleReply" }, inputSchema: z.object({ inboxId: z.string(), to: z.string() }), async run(input, ctx) { const sent = await ctx.sapiom.email.messages.send(input.inboxId, { to: input.to, subject: "Can you confirm your delivery address?", text: "Reply to this email with the address we should ship to.", }); return pauseUntilSignal({ signal: "email.reply", correlationId: sent.threadId, resumeStep: "handleReply", }); },});
const handleReply = defineStep({ name: "handleReply", next: [], terminal: true, inputSchema: z.object({ inboxId: z.string(), messageId: z.string(), from: z.string() }), async run(input, ctx) { const reply = await ctx.sapiom.email.messages.get(input.inboxId, input.messageId); return terminate({ from: input.from, answer: reply.extractedText ?? reply.text ?? "" }); },});
export const agent = defineAgent({ name: "confirm-address", entry: "ask", steps: { ask, handleReply },});messages.send, reply, replyAll, and forward all return the threadId, and a reply to that message arrives with the same threadId. The whole message.received payload becomes the resume step’s input. It is validated against that step’s inputSchema and is not narrowed to the entry step’s fields. See Use signals for the pause declaration and deadlines.
Create a signal trigger and its email webhook
Section titled “Create a signal trigger and its email webhook”-
Create the trigger with
target: "signal", the signal name the runs wait on, and the path to the correlation ID in the payload:Terminal window curl -sS -X POST https://api.sapiom.ai/v1/workflows/definitions/confirm-address/triggers \-H "x-api-key: $SAPIOM_API_KEY" \-H "content-type: application/json" \-d '{"kind": "webhook","target": "signal","signalName": "email.reply","correlationPath": "threadId"}'The response carries
url,secret, andtarget: "signal". -
Register an email webhook for
message.receivedwith that trigger’surl, itssecret, andsignatureVersion: "v2", exactly as in step 2 above.
Each inbound message now resumes every run in your tenant paused on email.reply with a correlation ID equal to the message’s threadId. A message on a thread nobody is waiting on is dropped.
| Field | Rule |
|---|---|
signalName | Required with target: "signal". Up to 200 characters, no leading or trailing whitespace. Must equal the signal in pauseUntilSignal. |
correlationPath | Required with target: "signal". Dot-separated keys of letters, digits, _, or -, at most 8 (for example threadId or data.thread.id). No array indexes. The value at that path must be a non-empty string or a finite number. |
input | Rejected with 400: a signal trigger resumes runs and arms no run input. |
signalName and correlationPath on a trigger without target: "signal" are also rejected with 400.
Inspect the trigger with GET /v1/workflows/triggers/{id} to see what each delivery did. Each entry in recentFires has a state and a resumedCount:
| Fire | Meaning |
|---|---|
succeeded, resumedCount ≥ 1 | Runs were resumed. A matching run that something else resumed first also counts as success. |
skipped, resumedCount: 0 | No run was waiting on that thread. Nothing started. |
failed, reason input_invalid | The payload had no string or number at correlationPath. |
failed, reason resume_failed | Runs were waiting, but none of them could be resumed. |
A signal trigger never starts a run. A second reply on the same thread, after the run has resumed, finds nothing waiting and is skipped.
Using both modes in one organization
Section titled “Using both modes in one organization”Both registrations receive every inbound message. With a start trigger and a signal trigger in place, a reply resumes the waiting run and also starts a new run of the start agent. Keep the inReplyTo check in the start agent’s entry step so those runs end at once.
Keep the secrets in step
Section titled “Keep the secrets in step”The email webhook stores a copy of the trigger’s secret. Nothing updates that copy for you.
- Rotating the trigger secret. After
POST /v1/workflows/triggers/{id}/secret/rotate, the old secret keeps verifying for 24 hours. Within that window, delete the email webhook (DELETE /v1/webhooks/{id}) and register it again with the new secret. After the grace ends, deliveries signed with the old secret get a401. - Revoking or cancelling the trigger. Every delivery gets a
401. Delete the email webhook too, or register it against a new trigger.
A rejected delivery is retried, then dropped (see Delivery and retries). It is not queued for later.
Signing reference
Section titled “Signing reference”Use this section when you receive email events on your own HTTPS endpoint. When the endpoint is a Sapiom webhook trigger, Sapiom verifies the signature for you.
Each registration has a signatureVersion. New registrations default to v2, including those created with email.webhooks.create from @sapiom/tools. Registrations created before v2 existed keep v1 and are never changed. To create a v1 registration, pass "signatureVersion": "v1" to POST /v1/webhooks. A registration’s version cannot be changed; delete it and create a new one. GET /v1/webhooks and GET /v1/webhooks/{id} return each registration’s signatureVersion.
| v1 | v2 (same scheme as agent webhook triggers) | |
|---|---|---|
| Headers | X-Sapiom-Signature | X-Sapiom-Timestamp, X-Sapiom-Event-Id, X-Sapiom-Signature |
| Signed string | the raw request body | <timestamp>.<eventId>.<raw body> |
| Algorithm | HMAC-SHA256, lowercase hex | HMAC-SHA256, lowercase hex |
| Timestamp | none | Unix epoch milliseconds, digits only |
| Clock skew | not applicable | reject when more than 5 minutes from your clock |
| Event ID | not applicable | matches ^[A-Za-z0-9_-]{1,128}$, the same on every retry of one event |
- Key. The HMAC key is the registration’s secret, used as its UTF-8 bytes. Do not hex-decode it.
- Raw body. Sign and verify the exact bytes received. Re-serialized JSON does not match.
- Timestamp. Set per delivery attempt, so a retry after a backoff is still inside the 5-minute window.
- Event ID. When the email event’s own
eventIdalready matches^[A-Za-z0-9_-]{1,128}$, the header carries it unchanged. Otherwise the header isevt_followed by the SHA-256 hex ofid:<eventId>, or ofbody:<raw body>when the event has no ID. Both are stable across retries. Deduplicate on the header value: it is always present in v2. - Secret. Omit
secretand Sapiom generates one. A secret you pass must be 32 to 256 characters with no whitespace, contain at least 8 distinct characters, and not be a shorter run repeated. An agent webhook trigger’s secret (64 lowercase hex characters) qualifies. Anything else is rejected with400and the reason.openssl rand -hex 32produces a valid one. - The secret is returned once, on create.
GETnever returns it.
Verify v2
Section titled “Verify v2”Node.js:
import { createHmac, timingSafeEqual } from "node:crypto";
const MAX_SKEW_MS = 5 * 60 * 1000;const TIMESTAMP = /^[0-9]{1,16}$/;const EVENT_ID = /^[A-Za-z0-9_-]{1,128}$/;
export function verifyV2( rawBody: Buffer, headers: { timestamp?: string; eventId?: string; signature?: string }, secret: string,): boolean { const { timestamp, eventId, signature } = headers; if (!timestamp || !TIMESTAMP.test(timestamp)) return false; if (!eventId || !EVENT_ID.test(eventId)) return false; if (!signature) return false; if (Math.abs(Date.now() - Number(timestamp)) > MAX_SKEW_MS) return false;
const expected = Buffer.from( createHmac("sha256", secret) .update(`${timestamp}.${eventId}.`) .update(rawBody) .digest("hex"), ); const given = Buffer.from(signature); // timingSafeEqual throws when the lengths differ. return given.length === expected.length && timingSafeEqual(given, expected);}
// With Express: app.post("/hooks/email", express.raw({ type: "application/json" }), ...)// verifyV2(req.body, {// timestamp: req.get("x-sapiom-timestamp"),// eventId: req.get("x-sapiom-event-id"),// signature: req.get("x-sapiom-signature"),// }, secret)Python:
import hashlibimport hmacimport reimport time
MAX_SKEW_MS = 5 * 60 * 1000TIMESTAMP = re.compile(r"[0-9]{1,16}")EVENT_ID = re.compile(r"[A-Za-z0-9_-]{1,128}")
def verify_v2(raw_body: bytes, headers, secret: str) -> bool: """`headers` is a case-insensitive mapping, such as Flask's `request.headers`.""" timestamp = headers.get("x-sapiom-timestamp", "") event_id = headers.get("x-sapiom-event-id", "") signature = headers.get("x-sapiom-signature", "") if not TIMESTAMP.fullmatch(timestamp) or not EVENT_ID.fullmatch(event_id): return False if abs(time.time() * 1000 - int(timestamp)) > MAX_SKEW_MS: return False
signed = f"{timestamp}.{event_id}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature)After the signature verifies, record the X-Sapiom-Event-Id and skip a delivery whose ID you have already processed.
Verify v1
Section titled “Verify v1”import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyV1(rawBody: Buffer, signature: string | undefined, secret: string): boolean { if (signature === undefined) return false; const expected = Buffer.from(createHmac("sha256", secret).update(rawBody).digest("hex")); const given = Buffer.from(signature); return given.length === expected.length && timingSafeEqual(given, expected);}v1 has no timestamp, so a captured delivery can be replayed. Deduplicate on the body’s eventId, and move to v2 when you can.
Delivery and retries
Section titled “Delivery and retries”Answer with a 2xx within 15 seconds. Any other status, a redirect, or a timeout counts as a failed attempt. A delivery is attempted 3 times in total, about 1 and 2 seconds apart, and is then dropped. The URL must be https and resolve to a public address; deliveries to private, loopback, or link-local addresses are dropped.
© 2026 Sapiom, Inc.