Platform mode (tenants)
Platform mode lets one SpacePost account send for many of your customers. Each customer is a tenant. Use it when you build a product that sends email for other businesses, for example an invoicing or support SaaS where every customer connects their own domain and one bad actor must not hurt everybody else.
If you only send your own product’s email, you do not need tenants.
What a tenant gives you
Section titled “What a tenant gives you”| Isolated reputation | Every tenant gets its own Amazon SES tenant. Complaints and bounces from one customer show up on that customer only. |
| Its own reputation guard | The same automatic pausing thresholds (over 5% bounces or 0.2% complaints across the tenant’s last 1,000 emails, with minimum sample sizes) pause only that tenant. |
| Your account stays clean | Tenant mail does not count toward your own team’s reputation check, so one bad customer cannot pause your account or your other customers. |
| Pause switch | Pause and resume any tenant yourself, with a reason. |
| Daily cap | Optional daily_limit in recipients per UTC day, per tenant. |
| Your IDs | Address a tenant by your own external_id instead of storing ours. |
| Attribution | Emails, domains and webhook events carry tenant_id. |
Tenants share your plan: every tenant recipient counts toward your monthly quota, your team’s daily limits and your API rate limit. Content filtering applies to tenant mail too, including tenants of an account that is otherwise exempt from it.
Create a tenant
Section titled “Create a tenant”POST /tenants takes a name and optionally an external_id (unique within your account) and a daily_limit. All tenant endpoints need a full_access API key.
import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY);
// One tenant per customer. `externalId` is your own customer id and works anywhere a tenant id does.const { data: tenant, error } = await spacepost.tenants.create({ name: "Acme Inc", externalId: "cust_123", dailyLimit: 500, // optional cap, in recipients per UTC day});if (error) throw new Error(`${error.name}: ${error.message}`);console.log(tenant.id, tenant.status); // "active"
// Optional: give the tenant its own sending domain. Mail from it is attributed to the tenant automatically.const { data: domain } = await spacepost.domains.create({ name: "mail.acme-inc.com", tenantId: "cust_123" });console.log(domain?.tenant_id === tenant.id); // truecurl -X POST https://api.spacepost.dev/tenants \ -H "Authorization: Bearer $SPACEPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Inc", "external_id": "cust_123", "daily_limit": 500 }'spacepost tenants create --name "Acme Inc" --external-id cust_123 --daily-limit 500{ "object": "tenant", "id": "0192e1c4-5555-7000-8000-0000000000cc", "name": "Acme Inc", "external_id": "cust_123", "daily_limit": 500, "status": "active", "paused_reason": null, "created_at": "2026-10-23T14:02:09.512Z"}external_id
Section titled “external_id”Everywhere a tenant is addressed (/tenants/:ref, tenant_id on emails and domains, spacepost tenants get <ref>) you can pass either our id or your external_id. Store your own customer id once and you never need to keep a mapping table. Webhooks always carry our tenant id; look it up with GET /tenants/{id} if you need the external_id.
Domains: tenant-owned or team-level
Section titled “Domains: tenant-owned or team-level”| Tenant-owned domain | Team-level domain | |
|---|---|---|
| Created with | tenant_id on POST /domains |
POST /domains without tenant_id |
| Typical owner | The customer’s own domain (mail.acme-inc.com) |
Your domain (yourapp.com) |
| Attribution | Automatic: every email from it belongs to the tenant | You pass tenant_id on each email, or the email is your own |
| Who may send from it | Only that tenant | Your own mail, and any tenant |
If an email names a tenant_id that differs from its domain’s tenant, the request fails with 422 validation_error. A tenant_id is optional when the from domain already belongs to a tenant.
Add a tenant-owned domain exactly like any domain, with one extra field, then add the DNS records at the customer’s DNS provider. See Adding and verifying.
curl -X POST https://api.spacepost.dev/domains \ -H "Authorization: Bearer $SPACEPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "mail.acme-inc.com", "tenant_id": "cust_123" }'spacepost domains add mail.acme-inc.com --tenant cust_123Send for a tenant
Section titled “Send for a tenant”import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY);
// From a team-level domain (yours), name the tenant on each email.const { data, error } = await spacepost.emails.send({ from: "Acme Inc via YourApp <invoices@yourapp.com>", to: "buyer@example.com", subject: "Invoice #1042", html: "<p>Your invoice is attached.</p>", tenantId: "cust_123", // our tenant id or your external_id});
if (error?.name === "sending_paused") { console.warn("This customer is paused:", error.message);} else if (error?.name === "daily_quota_exceeded") { console.warn("This customer hit its daily cap; retry tomorrow.");} else if (error) { throw new Error(`${error.name}: ${error.message}`);} else { console.log("Queued", data.id);}
// batch.send accepts tenantId per email.await spacepost.batch.send([ { from: "invoices@yourapp.com", to: "a@example.com", subject: "Hello A", text: "Hi A", tenantId: "cust_123" }, { from: "invoices@yourapp.com", to: "b@example.com", subject: "Hello B", text: "Hi B", tenantId: "cust_456" },]);curl -X POST https://api.spacepost.dev/emails \ -H "Authorization: Bearer $SPACEPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme Inc via YourApp <invoices@yourapp.com>", "to": ["buyer@example.com"], "subject": "Invoice #1042", "html": "<p>Your invoice is attached.</p>", "tenant_id": "cust_123" }'POST /emails/batch accepts tenant_id on each item.
The official resend package works against SpacePost unchanged. Point it at us with RESEND_BASE_URL=https://api.spacepost.dev or new Resend(key, { baseUrl }).
Easiest: send from a tenant-owned domain. The tenant is inferred from the from domain, so your existing resend.emails.send calls need no change at all.
From a team-level domain, use resend.post. The Resend SDK builds the request body from a fixed list of fields, so an extra tenant_id passed to resend.emails.send, resend.batch.send or resend.domains.create is silently dropped before it reaches SpacePost. resend.post sends the body you give it as is, so use it with snake_case fields:
import { Resend } from "resend";
const resend = new Resend(process.env.SPACEPOST_API_KEY, { baseUrl: "https://api.spacepost.dev" });
const { data, error } = await resend.post<{ id: string }>("/emails", { from: "Acme Inc via YourApp <invoices@yourapp.com>", to: ["buyer@example.com"], subject: "Invoice #1042", html: "<p>Your invoice is attached.</p>", tenant_id: "cust_123",});Unlike resend.emails.send, resend.post does not convert camelCase: write reply_to and scheduled_at. The same works for resend.post("/domains", { name, tenant_id }) and resend.post("/emails/batch", [...]).
spacepost send --from invoices@yourapp.com --to buyer@example.com \ --subject "Invoice #1042" --text "Your invoice is attached." --tenant cust_123Pause, resume and caps
Section titled “Pause, resume and caps”import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY);
const { data: page } = await spacepost.tenants.list({ limit: 20 });for (const t of page?.data ?? []) console.log(t.name, t.external_id, t.status, t.paused_reason);
await spacepost.tenants.get("cust_123"); // by external_id or our idawait spacepost.tenants.update({ id: "cust_123", dailyLimit: 1000 }); // dailyLimit: null removes the cap
await spacepost.tenants.pause({ id: "cust_123", reason: "Investigating spam reports" });
const { error } = await spacepost.tenants.unpause("cust_123");if (error?.statusCode === 403) console.warn("Paused by the reputation guard; contact support to review it.");
// Domains first, then the tenant (422 while it still has domains).const { data: domains } = await spacepost.domains.list({ limit: 100 });for (const d of domains?.data ?? []) if (d.tenant_id) console.log(d.name, "belongs to", d.tenant_id);await spacepost.tenants.remove("cust_123");# Pause (the body is optional)curl -X POST https://api.spacepost.dev/tenants/cust_123/pause \ -H "Authorization: Bearer $SPACEPOST_API_KEY" -H "Content-Type: application/json" \ -d '{ "reason": "Investigating spam reports" }'
# Resumecurl -X POST https://api.spacepost.dev/tenants/cust_123/unpause -H "Authorization: Bearer $SPACEPOST_API_KEY"
# Change the cap (null removes it)curl -X PATCH https://api.spacepost.dev/tenants/cust_123 \ -H "Authorization: Bearer $SPACEPOST_API_KEY" -H "Content-Type: application/json" \ -d '{ "daily_limit": 1000 }'
# Delete (422 while the tenant still has domains)curl -X DELETE https://api.spacepost.dev/tenants/cust_123 -H "Authorization: Bearer $SPACEPOST_API_KEY"spacepost tenants listspacepost tenants get cust_123spacepost tenants pause cust_123 --reason "Investigating spam reports"spacepost tenants unpause cust_123spacepost tenants rm cust_123| Situation | Result |
|---|---|
| Sending for a paused tenant | 403 sending_paused. Emails already queued fail with an email.failed event. |
Tenant over its daily_limit |
429 daily_quota_exceeded (the day resets at 00:00 UTC). Nothing from that request is sent. |
tenant_id conflicts with the domain’s tenant |
422 validation_error |
Unknown tenant_id |
404 not_found |
| Unpausing a tenant paused by the reputation guard | 403 invalid_access, unless your team is trusted or internal. Contact support@spacepost.dev after fixing the cause. Pauses you made yourself can always be lifted. |
| Deleting a tenant that still has domains | 422 validation_error. Delete its domains first. |
Webhooks
Section titled “Webhooks”Your normal webhooks receive events for tenant mail too, with data.tenant_id set to our tenant id. Emails without a tenant have no tenant_id key.
{ "type": "email.bounced", "created_at": "2026-10-23T14:02:12.000Z", "data": { "email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10", "from": "Acme Inc via YourApp <invoices@yourapp.com>", "to": ["nobody@example.com"], "subject": "Invoice #1042", "created_at": "2026-10-23T14:02:09.512Z", "tenant_id": "0192e1c4-5555-7000-8000-0000000000cc", "bounce": { "type": "Permanent", "subType": "General", "message": "smtp; 550 5.1.1 user unknown" } }}import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY);
export async function handle(request: Request): Promise<Response> { const payload = await request.text(); try { const event = await spacepost.webhooks.verify({ payload, headers: request.headers, secret: process.env.SPACEPOST_WEBHOOK_SECRET!, }); // `tenant_id` is our tenant id (not your external_id). It is absent on the team's own mail. const tenantId = event.data.tenant_id; if (event.type === "email.bounced" && tenantId) { console.log(`Bounce for tenant ${tenantId}: ${event.data.bounce?.subType}`); } return new Response("ok"); } catch { return new Response("invalid signature", { status: 400 }); }}GET /emails/{id} and GET /domains/{id} also return tenant_id (or null), so you can reconcile in your own database.
Checklist for a SaaS integration
Section titled “Checklist for a SaaS integration”- Create a tenant when a customer signs up, with your customer id as
external_id. - Decide per customer: their own domain (
tenant_idonPOST /domains) or your shared domain (tenant_idon every send). - Set a conservative
daily_limitfor new customers and raise it as they prove themselves. - Subscribe to
email.bounced,email.complainedandemail.failed, and surfacetenant_idproblems in your admin UI. - Pause a customer yourself the moment you see abuse. It is instant and reversible.