Email for your customers
This page is for a platform that runs email for its own customers from one Sapiom organization. Your customers exist only in your system and never hold a Sapiom key. Every inbox and domain belongs to your organization, and your server keeps track of which customer each one belongs to.
You will:
- Tag each inbox with the customer it belongs to, and record which customer owns each custom domain.
- Register one set of webhooks that receives events for every inbox in the organization.
- Verify each delivery’s signature and attribute the event to a customer.
- Compute bounce and complaint rates per customer, and compare them with the organization-wide rate.
The calls on this page run on your server with your organization’s own API key. The same operations are available inside an agent step as ctx.sapiom.email; see the Email capability for sending, limits, and domain setup.
import { createClient } from "@sapiom/tools";
const sapiom = createClient({ apiKey: process.env.SAPIOM_API_KEY });Tag each inbox with its customer
Section titled “Tag each inbox with its customer”Pass your customer ID as clientId when you create the inbox. clientId is unique within the organization: a second create with the same clientId fails with HTTP 409 and creates nothing. That makes creation safe to retry, and it means the inbox can always be traced back to its customer, including from inboxes.list.
import { EmailHttpError } from "@sapiom/tools";
async function inboxForCustomer(customer: { id: string; name: string }) { try { const inbox = await sapiom.email.inboxes.create({ displayName: customer.name, clientId: customer.id, }); await db.inboxOwners.upsert({ inboxId: inbox.inboxId, customerId: customer.id }); return inbox; } catch (err) { if (!(err instanceof EmailHttpError && err.status === 409)) throw err; // An earlier attempt already created it: find it by clientId. const existing = await findInboxByClientId(customer.id); if (!existing) throw err; // The 409 was an address conflict, not this customer's inbox. await db.inboxOwners.upsert({ inboxId: existing.inboxId, customerId: customer.id }); return existing; }}
async function findInboxByClientId(clientId: string) { let pageToken: string | undefined; do { const page = await sapiom.email.inboxes.list({ limit: 100, pageToken }); const match = page.inboxes.find((inbox) => inbox.clientId === clientId); if (match) return match; pageToken = page.nextPageToken; } while (pageToken); return undefined;}Store inboxId → customerId in your own database. Every message event carries inboxId, and that table is how you attribute an event to a customer. If the table is ever lost, rebuild it by listing all inboxes and reading each one’s clientId.
clientId = customerId gives each customer one inbox. To give a customer several inboxes, make each clientId unique and keep the customer ID in it, for example `${customer.id}:support` and `${customer.id}:billing`.
Record which customer owns each domain
Section titled “Record which customer owns each domain”Email domains have no clientId. When you add a customer’s sending domain, store the returned domainId and domain against the customer yourself:
const emailDomain = await sapiom.email.domains.create({ domain: customer.sendingDomain });await db.domainOwners.insert({ domainId: emailDomain.domainId, domain: emailDomain.domain, customerId: customer.id,});The domain.verified event carries domainId, so this table attributes it. Inboxes you then create on the domain still get a clientId as above. Publishing the DNS records and waiting for verification are covered in Send from your own domain.
Register one set of webhooks for every inbox
Section titled “Register one set of webhooks for every inbox”A webhook registered with your organization’s key receives events for every inbox in the organization. You do not register per inbox or per customer.
Each registration subscribes to one event type (or * for all of them) and gets its own signing secret. To watch sending outcomes, register four event types:
| Event | Why you need it |
|---|---|
message.bounced | A recipient’s server refused the message. Counts toward the bounce rate. |
message.complained | A recipient marked the message as spam. Counts toward the complaint rate. |
message.rejected | The message was refused before it was sent. |
message.sent | The denominator for per-customer rates. |
Give each registration its own URL path, so your receiver knows which secret to verify a delivery with before it reads the body. Registration is safe to repeat: list what exists first and create only what is missing.
import type { email } from "@sapiom/tools";
const RECEIVER = "https://platform.example.com/hooks/sapiom-email";const EVENTS: email.EmailEventType[] = [ "message.sent", "message.bounced", "message.complained", "message.rejected",];
const { webhooks } = await sapiom.email.webhooks.list();
for (const eventType of EVENTS) { const url = `${RECEIVER}/${eventType}`; if (webhooks.some((w) => w.url === url && w.eventType === eventType)) continue;
const created = await sapiom.email.webhooks.create({ url, eventType }); // `secret` is returned only here. Store it before doing anything else. await secrets.put(`sapiom-email/${eventType}`, created.secret);}webhooks.list() returns your registrations, newest first. webhooks.get(id) returns one. Each has id, url, eventType, and createdAt. Neither returns the secret. The REST routes GET /v1/webhooks and GET /v1/webhooks/{id} also return each registration’s signatureVersion. If you lose a secret, delete that registration with webhooks.delete(id) and create it again. Two registrations for the same event type and URL each receive every event, so do not create duplicates.
The URL must be https and resolve to a public address. Deliveries to private, loopback, or link-local addresses are dropped.
Receive and verify events
Section titled “Receive and verify events”Each delivery is a POST with a JSON body. New registrations, including those made with webhooks.create, sign with v2, the same scheme as agent webhook triggers. A registration made before v2 existed keeps v1.
| v2 | |
|---|---|
| Headers | X-Sapiom-Timestamp, X-Sapiom-Event-Id, X-Sapiom-Signature |
| Signed content | <timestamp>.<eventId>.<raw body> |
| Algorithm | HMAC-SHA256 with the registration’s secret, lowercase hex |
| Timestamp | Unix epoch milliseconds. Reject a delivery more than 5 minutes from your clock. |
The v1 scheme, the event ID rules, and a Python version of this check are in the signing reference.
Verify against the raw bytes before you parse them. Re-serialized JSON does not match the signature.
import { createHmac, timingSafeEqual } from "node:crypto";import express from "express";import type { email } from "@sapiom/tools";
const MAX_SKEW_MS = 5 * 60 * 1000;
function verified(rawBody: Buffer, req: express.Request, secret: string): boolean { const timestamp = req.get("x-sapiom-timestamp"); const eventId = req.get("x-sapiom-event-id"); const signature = req.get("x-sapiom-signature"); if (!timestamp || !/^[0-9]{1,16}$/.test(timestamp)) return false; if (!eventId || !/^[A-Za-z0-9_-]{1,128}$/.test(eventId)) return false; if (!signature || 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);}
const app = express();
app.post( "/hooks/sapiom-email/:eventType", express.raw({ type: "application/json" }), async (req, res) => { const secret = await secrets.get(`sapiom-email/${req.params.eventType}`); if (!secret || !verified(req.body, req, secret)) { return res.sendStatus(401); }
const event = JSON.parse(req.body.toString("utf8")) as email.EmailEvent;
// Deliveries are at least once. Record eventId and skip repeats. if (event.eventId && !(await db.emailEvents.claim(event.eventId))) { return res.sendStatus(200); }
await recordEvent(event); res.sendStatus(200); },);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. After the third failure the event is not delivered again, so do slow work after you answer, not before.
Attribute the event to a customer
Section titled “Attribute the event to a customer”Narrow on eventType to get the payload type, then look up the customer from inboxId:
async function recordEvent(event: email.EmailEvent) { if (event.eventType === "domain.verified") { const owner = event.domainId ? await db.domainOwners.get(event.domainId) : undefined; if (owner) await notifyDomainVerified(owner.customerId, event.domain); return; }
if (!("inboxId" in event) || !event.inboxId) return; const owner = await db.inboxOwners.get(event.inboxId); if (!owner) return; // An inbox your platform did not create.
const row = { eventId: event.eventId, customerId: owner.customerId, inboxId: event.inboxId, eventType: event.eventType, occurredAt: event.timestamp ?? new Date().toISOString(), recipients: 0, bounceType: null as string | null, };
switch (event.eventType) { case "message.sent": row.recipients = event.recipients?.length ?? 1; break; case "message.bounced": row.recipients = event.recipients?.length ?? 1; row.bounceType = event.bounceType; if (event.bounceType === "Permanent") { // A hard bounce: stop sending to these addresses. for (const r of event.recipients ?? []) await db.suppressions.add(owner.customerId, r.address); } break; case "message.complained": row.recipients = event.recipients?.length ?? 1; for (const address of event.recipients ?? []) await db.suppressions.add(owner.customerId, address); break; case "message.rejected": break; default: return; }
await db.emailEvents.insert(row);}Compute bounce and complaint rates per customer
Section titled “Compute bounce and complaint rates per customer”The organization-wide rate counts sent messages per recipient over a rolling 24 hours. Count the same way per customer: bounced recipients over sent recipients, and complained recipients over sent recipients, for each customer’s inboxes.
select customer_id, sum(recipients) filter (where event_type = 'message.sent') as sent, sum(recipients) filter (where event_type = 'message.bounced')::numeric / nullif(sum(recipients) filter (where event_type = 'message.sent'), 0) as bounce_rate, sum(recipients) filter (where event_type = 'message.complained')::numeric / nullif(sum(recipients) filter (where event_type = 'message.sent'), 0) as complaint_rate, count(*) filter (where event_type = 'message.rejected') as rejectedfrom email_eventswhere occurred_at > now() - interval '24 hours'group by customer_idorder by bounce_rate desc nulls last;Both hard and soft bounces count toward the bounce rate. A rejected message was never sent, so it is not in either rate; a run of rejections from one customer’s inboxes is still worth investigating.
Every customer sends under your organization, and email moderation acts on the organization-wide rate, not on one customer’s. A suspension stops sending from every inbox in the organization; Your sending reputation gives the thresholds and what a suspension blocks. Read that rate with metrics.rates() and use the per-customer table to find who is driving it:
const rates = await sapiom.email.metrics.rates();// { bounceRate: 0.021, complaintRate: 0.0004, sent: 1830, windowSeconds: 86400, windowEnd: "…" }bounceRate and complaintRate are fractions (0.05 is 5%), and null when nothing was sent in the window. The call is free. It needs your organization’s own key; a key issued for one of your tenants gets HTTP 403.
Event payload reference
Section titled “Event payload reference”Every delivery body has these fields:
| Field | Type | Description |
|---|---|---|
eventType | string | One of the event types below. Narrow EmailEvent on it. |
event | string | The same value as eventType, for receivers written before eventType existed. |
eventId | string | Stable ID of the event, identical on every retry. Deduplicate on it. |
timestamp | string | ISO 8601 time of the event, when the provider supplies one. |
Message events also carry inboxId (the inbox’s address), threadId, and messageId. The other fields depend on the event:
eventType | Additional fields |
|---|---|
message.received and its .spam, .blocked, .unauthenticated variants | from, to (string array), subject, preview |
message.sent | recipients: string array |
message.delivered | recipients: string array |
message.bounced | recipients: array of { address, status }, where status is the delivery status code (for example 5.1.1); bounceType; bounceSubType |
message.complained | recipients: string array; complaintType; complaintSubType |
message.rejected | reason |
domain.verified | domain, domainId, status |
bounceType, bounceSubType, complaintType, and complaintSubType are always present on their own event and are null when the provider sent no value. They never appear on other events. Fields other than these four and the common fields are present only when the provider supplied them, so check before you use them.
| Field | Values |
|---|---|
bounceType | Permanent: a hard bounce; the address will not accept mail. Transient: a soft bounce, such as a full mailbox. Treat any other value, or null, as unclassified. |
bounceSubType | The provider’s detail, for example General, NoEmail, Suppressed, OnAccountSuppressionList, or MailboxFull. |
complaintType | The feedback type the recipient’s provider reported, for example abuse. |
complaintSubType | The provider’s detail, for example OnAccountSuppressionList, or null. |
A message.bounced body:
{ "event": "message.bounced", "eventType": "message.bounced", "eventId": "evt_2pXk9…", "threadId": "thr_…", "messageId": "msg_…", "timestamp": "2026-10-13T14:02:11.000Z", "bounceType": "Permanent", "bounceSubType": "General"}The SDK types are exported from the email namespace of @sapiom/tools: EmailEventType (every subscribable event), WebhookEventType (EmailEventType or "*"), EmailEvent (the union of payloads), and one interface per event, such as MessageBouncedEvent and MessageComplainedEvent.
© 2026 Sapiom, Inc.