Skip to content
Go To Dashboard

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 targetWhat a delivery does
start (default)Starts a new run of the agent. The message fields become the run input.
signalResumes 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 ready build.
  • Create the inbox that will receive the mail. See Email.
  1. 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-intake is the agent’s slug. The response carries the trigger’s id, its url (https://api.sapiom.ai/v1/workflows/hooks/{publicId}), and its secret. The secret is shown once: keep it until the next step is done. If you lose it, rotate it.

  2. Register an email webhook for message.received

    Section titled “Register an email webhook for message.received”

    Pass 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, and signatureVersion. Check that signatureVersion is "v2". A v1 registration pointed at a trigger gets a 401 on every delivery, because the trigger only verifies v2.

  3. 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 in recentFires has the executionId of 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.

The message.received payload becomes the run input, folded over the trigger’s stored input:

{
"event": "message.received",
"eventType": "message.received",
"eventId": "evt_2pXk9…",
"inboxId": "[email protected]",
"threadId": "thr_…",
"messageId": "msg_…",
"timestamp": "2026-10-14T09:12:40.000Z",
"from": "Ada Lovelace <[email protected]>",
"to": ["[email protected]"],
"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.

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.

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”
  1. 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, and target: "signal".

  2. Register an email webhook for message.received with that trigger’s url, its secret, and signatureVersion: "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.

FieldRule
signalNameRequired with target: "signal". Up to 200 characters, no leading or trailing whitespace. Must equal the signal in pauseUntilSignal.
correlationPathRequired 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.
inputRejected 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:

FireMeaning
succeeded, resumedCount ≥ 1Runs were resumed. A matching run that something else resumed first also counts as success.
skipped, resumedCount: 0No run was waiting on that thread. Nothing started.
failed, reason input_invalidThe payload had no string or number at correlationPath.
failed, reason resume_failedRuns 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.

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.

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 a 401.
  • 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.

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.

v1v2 (same scheme as agent webhook triggers)
HeadersX-Sapiom-SignatureX-Sapiom-Timestamp, X-Sapiom-Event-Id, X-Sapiom-Signature
Signed stringthe raw request body<timestamp>.<eventId>.<raw body>
AlgorithmHMAC-SHA256, lowercase hexHMAC-SHA256, lowercase hex
TimestampnoneUnix epoch milliseconds, digits only
Clock skewnot applicablereject when more than 5 minutes from your clock
Event IDnot applicablematches ^[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 eventId already matches ^[A-Za-z0-9_-]{1,128}$, the header carries it unchanged. Otherwise the header is evt_ followed by the SHA-256 hex of id:<eventId>, or of body:<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 secret and 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 with 400 and the reason. openssl rand -hex 32 produces a valid one.
  • The secret is returned once, on create. GET never returns it.

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 hashlib
import hmac
import re
import time
MAX_SKEW_MS = 5 * 60 * 1000
TIMESTAMP = 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.

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.

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.