Skip to content
Go To Dashboard

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:

  1. Tag each inbox with the customer it belongs to, and record which customer owns each custom domain.
  2. Register one set of webhooks that receives events for every inbox in the organization.
  3. Verify each delivery’s signature and attribute the event to a customer.
  4. 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 });

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`.

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:

EventWhy you need it
message.bouncedA recipient’s server refused the message. Counts toward the bounce rate.
message.complainedA recipient marked the message as spam. Counts toward the complaint rate.
message.rejectedThe message was refused before it was sent.
message.sentThe 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.

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
HeadersX-Sapiom-Timestamp, X-Sapiom-Event-Id, X-Sapiom-Signature
Signed content<timestamp>.<eventId>.<raw body>
AlgorithmHMAC-SHA256 with the registration’s secret, lowercase hex
TimestampUnix 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.

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 rejected
from email_events
where occurred_at > now() - interval '24 hours'
group by customer_id
order 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.

Every delivery body has these fields:

FieldTypeDescription
eventTypestringOne of the event types below. Narrow EmailEvent on it.
eventstringThe same value as eventType, for receivers written before eventType existed.
eventIdstringStable ID of the event, identical on every retry. Deduplicate on it.
timestampstringISO 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:

eventTypeAdditional fields
message.received and its .spam, .blocked, .unauthenticated variantsfrom, to (string array), subject, preview
message.sentrecipients: string array
message.deliveredrecipients: string array
message.bouncedrecipients: array of { address, status }, where status is the delivery status code (for example 5.1.1); bounceType; bounceSubType
message.complainedrecipients: string array; complaintType; complaintSubType
message.rejectedreason
domain.verifieddomain, 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.

FieldValues
bounceTypePermanent: 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.
bounceSubTypeThe provider’s detail, for example General, NoEmail, Suppressed, OnAccountSuppressionList, or MailboxFull.
complaintTypeThe feedback type the recipient’s provider reported, for example abuse.
complaintSubTypeThe provider’s detail, for example OnAccountSuppressionList, or null.

A message.bounced body:

{
"event": "message.bounced",
"eventType": "message.bounced",
"eventId": "evt_2pXk9…",
"inboxId": "[email protected]",
"threadId": "thr_…",
"messageId": "msg_…",
"timestamp": "2026-10-13T14:02:11.000Z",
"recipients": [{ "address": "[email protected]", "status": "5.1.1" }],
"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.