Email gives a deployed agent real, addressable inboxes. An agent can send, reply, and forward from them, read what arrives, and attach a custom sending domain. Agent steps call the typed ctx.sapiom.email capability; Sapiom supplies the authenticated cloud connection when the deployed agent runs.
Where an inbox may send depends on its domain. Every inbox also has a daily recipient budget. Read Who an inbox can send to and Limits before you design a sending workflow.
Operations
Section titled “Operations”Operations are grouped by resource. An inboxId is the inbox’s email address; pass it unchanged as the first argument to every message and thread call.
| Resource | Operations |
|---|---|
inboxes | create, list, get, delete |
messages | send, list, get, reply, replyAll, forward |
threads | list, get |
domains | create, verify, get, list, delete |
webhooks | create, delete |
That list is the whole surface. Sapiom runs email on AgentMail, but it exposes only the operations above, and AgentMail’s own API is not reachable through Sapiom. Specifically, these are not available:
- Attachments. Sends carry no attachments. Received messages report attachment metadata (
attachmentId,filename,contentType,size), but the file contents cannot be downloaded. - Updates to existing resources. You cannot rename an inbox, change labels on a stored message, or change a domain’s
feedbackEnabledafter you create it. Delete and recreate instead. - Listing webhooks. Keep the
idreturned bywebhooks.create; you need it to delete the webhook. - Other AgentMail resources, such as drafts, pods, allow and block lists, and metrics. The one exception is your organization’s bounce and complaint rates, which you read over HTTP as shown in Your sending reputation.
messages.list and threads.list return metadata only. Call messages.get or threads.get for bodies. messages.get also returns extractedText / extractedHtml: the new content of a reply, with the quoted history removed. Lists are cursor-paginated: pass nextPageToken back as pageToken, with limit from 1 to 100.
Create an inbox and send
Section titled “Create an inbox and send”const inbox = await ctx.sapiom.email.inboxes.create({ username: "support", displayName: "Acme Support", clientId: "acme-support-inbox",});
const sent = await ctx.sapiom.email.messages.send(inbox.inboxId, { to: input.recipient, subject: "Your request was received", text: "We will reply within one business day.",});
return { inboxId: inbox.inboxId, messageId: sent.messageId, threadId: sent.threadId };clientId guards against duplicates: a second create with a clientId that already exists fails with HTTP 409 instead of creating another inbox. On a 409, find the existing inbox with inboxes.list, whose items include clientId. Without a domain, the inbox gets a generated address on the default domain. Which default domain your organization gets determines the send policy, as the next section explains. Pass domain to create the inbox on a verified custom domain.
send requires to and accepts cc, bcc, replyTo, subject, text, html, labels, and custom headers. send, reply, replyAll, and forward each return { messageId, threadId }. A reply with no to goes to the original sender.
Who an inbox can send to
Section titled “Who an inbox can send to”The send policy depends only on the domain of the sending inbox:
| Sending inbox domain | Policy |
|---|---|
<id>.sapiom.email (free Sapiom inbox) | Organization only: the organization’s owner and its active members |
agentmail.to | Unrestricted |
A custom domain you added with domains.create | Unrestricted |
Sapiom is moving inboxes created without a domain to a per-organization <id>.sapiom.email subdomain, one organization at a time. Until your organization is moved, those inboxes get an agentmail.to address. Existing inboxes keep their address. To know which policy applies, read the domain of the returned inboxId.
On an organization-only inbox:
- Every address on
to,cc, andbccmust belong to the organization’s owner or an active member. A reply is also checked against the original sender, so replying to an outside sender is blocked. If any recipient is outside that set, the send fails with HTTP 403,error: "recipient_not_allowed", and adisallowedRecipientslist. replyAll,forward, andreplywithreplyAll: trueare blocked outright with HTTP 403 anderror: "internal_only_send_blocked". Usereplywith explicit recipients instead.- Membership changes can take about a minute to apply.
- If Sapiom cannot resolve the member list, the send fails rather than going out unchecked.
To send outside your organization, send from your own domain.
Limits
Section titled “Limits”| Limit | Value |
|---|---|
Recipients per field (to, cc, bcc, replyTo) | 50 addresses each, so one call can address up to 150 recipients |
| Recipient deliveries per inbox per day | 200, resetting at 00:00 UTC |
limit on list calls | 1–100 |
The daily limit applies to every inbox, including inboxes on agentmail.to and custom domains. It counts recipient deliveries:
sendandforwardcount the distinct addresses acrossto,cc, andbcc. A send to 12 people uses 12.replycounts the addresses it names, with a minimum of 1.replyAll, andreplywithreplyAll: true, count the replied-to message’s sender plus itstoandcc, plus any address the call adds.- Addresses are compared case-insensitively, and the budget belongs to the inbox address.
A call that would exceed the budget is rejected whole and nothing is sent: HTTP 429, error: "daily_send_limit_exceeded", with limit, requested, retryAfterSeconds, and a Retry-After header that counts down to 00:00 UTC. A rejected call does not consume budget and is not charged. A send that fails after it was admitted gets its budget back.
Plan bulk sends around these limits: split large audiences across days or across inboxes on your own domain, and on a 429, wait retryAfterSeconds before sending from that inbox again.
Bounces and complaints count against your whole organization, and crossing a threshold suspends sending for every inbox. Read Your sending reputation before you send at volume.
Custom headers
Section titled “Custom headers”headers is a map of header name to string value. The policy is a denylist: any header not on the list below is passed through. That includes List-Unsubscribe and List-Unsubscribe-Post for one-click unsubscribe, and your own X- headers.
These names are rejected with HTTP 400, compared case-insensitively:
| Header group | Rejected names | Use instead |
|---|---|---|
| Addressing and identity | To, Cc, Bcc, From, Sender, Reply-To, Return-Path, Bounces-To, Errors-To | to, cc, bcc, replyTo, and the inbox itself |
| Threading | In-Reply-To, References | reply, replyAll, or forward, which set them |
| Envelope and MIME | Message-ID, Date, Received, Subject, MIME-Version, Content-Type, Content-Transfer-Encoding, DKIM-Signature | subject, text, html |
Every header value must be a string. { "X-Attempt": 2 } is rejected; send { "X-Attempt": "2" }.
await ctx.sapiom.email.messages.send(input.inboxId, { to: input.recipient, subject: input.subject, html: input.html, headers: { "List-Unsubscribe": `<${input.unsubscribeUrl}>`, "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", },});To continue a conversation, call reply with the received message’s messageId. Setting In-Reply-To on a fresh send is rejected.
Send from your own domain
Section titled “Send from your own domain”A custom domain is the way to send to anyone from an address you control. An email domain is separate from a registered domain: Domains & DNS registers the name and manages its DNS, and email.domains tells the mail provider to sign and receive mail for it. Bring a domain registered through Sapiom, or one you manage elsewhere and can add DNS records to.
- Register the domain, if you do not already own it. Registration is a purchase: gate it as shown in Check before registering.
- Add it as an email domain.
email.domains.createreturns the DNSrecordsto publish: TXT, CNAME, and MX records for DKIM, SPF, DMARC, and inbound mail. - Publish every record. Each
record.nameis a fully qualified host name. Name.com-style DNS takes a host relative to the zone, so remove the registered domain from the end of the name. - Verify. Call
email.domains.verify(domainId), then read the result withemail.domains.get(domainId).verifyreturns nothing; the status and each record’sMISSING/INVALID/VALIDstate appear onget. - Create inboxes on it with
inboxes.create({ domain })once the status isVERIFIED.
const zone = input.registeredDomain; // e.g. "example.com"const emailDomain = await ctx.sapiom.email.domains.create({ domain: input.sendingDomain, // "example.com", or a subdomain such as "mail.example.com"});
const existing = await ctx.sapiom.domains.dns.list({ domainName: zone });
for (const record of emailDomain.records) { const fqdn = record.name.replace(/\.$/, ""); const host = fqdn === zone ? "" : fqdn.endsWith(`.${zone}`) ? fqdn.slice(0, -(zone.length + 1)) : fqdn;
const published = existing.some( (r) => r.type === record.type && r.host === host && r.value === record.value, ); if (published) continue;
await ctx.sapiom.domains.dns.create({ domainName: zone, type: record.type, host, value: record.value, ...(record.priority !== undefined && { priority: record.priority }), });}
await ctx.sapiom.email.domains.verify(emailDomain.domainId);return { domainId: emailDomain.domainId };DNS changes take time to propagate, so status usually moves through PENDING and VERIFYING before it reaches VERIFIED. Check again from a later step or a scheduled run instead of polling in a tight loop. INVALID or FAILED means a record is wrong; compare each record’s status on get with what you published.
domains.create also takes feedbackEnabled (default false). When true, bounce and complaint notifications are delivered to the inbox as mail. Leave it off if your agent reads its inbox and should not mistake those notifications for replies; the message.bounced and message.complained webhook events carry the same signals.
What the domain capability does not do
Section titled “What the domain capability does not do”| Constraint | Detail |
|---|---|
| Registration term | One year only, for register and for each renew. There is no multi-year option. |
| Auto-renew | Not available. Track expiresAt and call renew before it. |
| Nameserver changes | Not available. The domain uses the registrar’s nameservers; manage records with domains.dns.*. |
| URL forwarding | Not available. Point records at a server that redirects. |
| Transfer in | Not available. Only domains registered through Sapiom can be managed; transferOut moves one away. |
| Transfer lock | A newly registered domain cannot be transferred out for 60 days. |
| Price | The registrar’s live quote at the time of the call, charged as quoted. Read it from check or get; do not hard-code it. If the price changes between authorization and purchase, register fails with HTTP 409; retry to get the new quote. |
A domain registered elsewhere still works for email: add it with email.domains.create and publish the records at your own DNS provider.
Receive mail with webhooks
Section titled “Receive mail with webhooks”webhooks.create({ url, eventType }) registers an HTTPS endpoint for inbound and delivery events. eventType is one of message.received, message.received.spam, message.received.blocked, message.received.unauthenticated, message.sent, message.delivered, message.bounced, message.complained, message.rejected, domain.verified, or * for all of them.
The response includes a secret that is returned only once. Store it when you create the webhook and use it to verify the signature on delivered events.
Handle failures
Section titled “Handle failures”Failed calls throw EmailHttpError, exported from @sapiom/tools, with the HTTP status and the parsed body:
| Status | body.error | Cause | What to do |
|---|---|---|---|
| 400 | varies | Invalid address, more than 50 addresses in one field, a rejected header, or a non-string header value | Fix the request; it will not succeed on retry |
| 403 | recipient_not_allowed | An organization-only inbox addressed someone outside the organization | Use a custom-domain inbox, or drop disallowedRecipients |
| 403 | internal_only_send_blocked | replyAll or forward from an organization-only inbox | Use reply with explicit recipients, or a custom-domain inbox |
| 409 | varies | inboxes.create with a clientId or address that already exists, or an address whose previous inbox is still being deleted | List inboxes to find the existing one, or retry the deletion case later |
| 422 | varies | inboxes.create on a custom domain that is not VERIFIED yet | Finish verification first |
| 429 | daily_send_limit_exceeded | The inbox’s 200 daily recipient deliveries are spent | Resume after retryAfterSeconds, or split the batch |
import { EmailHttpError } from "@sapiom/tools";
try { await ctx.sapiom.email.messages.send(input.inboxId, input.message);} catch (err) { if (err instanceof EmailHttpError && err.status === 429) { const body = err.body as { retryAfterSeconds?: number }; return { deferred: true, retryAfterSeconds: body.retryAfterSeconds ?? null }; } throw err;}Do not swallow a send error and report the message as sent. A step that reports success after a failed send hides the failure in the run inspector.
Test the behavior locally
Section titled “Test the behavior locally”Local Run replaces every email call with a deterministic stub and never sends mail. The stubs do not apply the send policy, the daily limit, or the header rules, so a passing local run does not prove a real send would be accepted. Stub the failure responses yourself to exercise those branches:
{ "version": 1, "steps": { "send": { "email.messages.send": { "messageId": "msg-1", "threadId": "thread-1" } } }}Stub keys follow the call path: email.inboxes.create, email.messages.send, email.messages.reply, email.domains.get, and so on. Without an override, email.inboxes.create returns an example.com address and email.domains.get reports VERIFIED. After each run, require both unusedStubs and stubWarnings to be empty.
© 2026 Sapiom, Inc.