Skip to content

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.

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.

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.

create-tenant.ts
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); // true
Response
{
"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"
}

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.

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.

Terminal window
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" }'
send-for-tenant.ts
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" },
]);
manage-tenants.ts
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 id
await 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");
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.

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" }
}
}
webhook-tenant.ts
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.

  1. Create a tenant when a customer signs up, with your customer id as external_id.
  2. Decide per customer: their own domain (tenant_id on POST /domains) or your shared domain (tenant_id on every send).
  3. Set a conservative daily_limit for new customers and raise it as they prove themselves.
  4. Subscribe to email.bounced, email.complained and email.failed, and surface tenant_id problems in your admin UI.
  5. Pause a customer yourself the moment you see abuse. It is instant and reversible.