Plattform-Modus (Tenants)
Der Plattform-Modus lässt ein SpacePost-Konto für viele deiner Kunden senden. Jeder Kunde ist ein Tenant. Nutze ihn, wenn du ein Produkt baust, das E-Mails für andere Unternehmen sendet, zum Beispiel ein SaaS für Rechnungen oder Support, bei dem jeder Kunde seine eigene Domain verbindet und ein einzelner Missbrauchsfall nicht allen anderen schaden darf.
Wenn du nur die E-Mails deines eigenen Produkts sendest, brauchst du keine Tenants.
Was dir ein Tenant bringt
Abschnitt betitelt „Was dir ein Tenant bringt“| Isolierte Reputation | Jeder Tenant erhält einen eigenen Amazon-SES-Tenant. Beschwerden und Bounces eines Kunden erscheinen nur bei diesem Kunden. |
| Eigene Reputationsüberwachung | Dieselben Schwellenwerte des automatischen Pausierens (über 5 % Bounces oder 0,2 % Beschwerden bei den letzten 1.000 E-Mails des Tenants, mit Mindest-Stichprobengrößen) pausieren nur diesen Tenant. |
| Dein Konto bleibt sauber | E-Mails von Tenants zählen nicht für die Reputationsprüfung deines eigenen Teams, sodass ein schlechter Kunde weder dein Konto noch deine anderen Kunden pausieren kann. |
| Pausenschalter | Pausiere und aktiviere jeden Tenant selbst, mit Begründung. |
| Tageslimit | Optionales daily_limit in Empfängern pro UTC-Tag und Tenant. |
| Deine IDs | Sprich einen Tenant über deine eigene external_id an, statt unsere zu speichern. |
| Zuordnung | E-Mails, Domains und Webhook-Events tragen tenant_id. |
Tenants teilen sich deinen Plan: Jeder Empfänger eines Tenants zählt auf dein Monatskontingent, die Tageslimits deines Teams und dein API-Rate-Limit. Die Inhaltsfilterung gilt auch für E-Mails von Tenants, einschließlich Tenants eines Kontos, das davon sonst ausgenommen ist.
Tenant erstellen
Abschnitt betitelt „Tenant erstellen“POST /tenants nimmt einen name und optional eine external_id (eindeutig innerhalb deines Kontos) und ein daily_limit entgegen. Alle Tenant-Endpoints benötigen einen API-Schlüssel mit full_access.
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
Abschnitt betitelt „external_id“Überall, wo ein Tenant angesprochen wird (/tenants/:ref, tenant_id bei E-Mails und Domains, spacepost tenants get <ref>), kannst du entweder unsere id oder deine external_id übergeben. Speichere deine eigene Kunden-ID einmal, und du brauchst nie eine Zuordnungstabelle zu pflegen. Webhooks enthalten immer unsere Tenant-ID; schlage sie mit GET /tenants/{id} nach, wenn du die external_id brauchst.
Domains: im Besitz eines Tenants oder auf Team-Ebene
Abschnitt betitelt „Domains: im Besitz eines Tenants oder auf Team-Ebene“| Domain im Besitz eines Tenants | Domain auf Team-Ebene | |
|---|---|---|
| Erstellt mit | tenant_id bei POST /domains |
POST /domains ohne tenant_id |
| Typischer Besitzer | Die eigene Domain des Kunden (mail.acme-inc.com) |
Deine Domain (yourapp.com) |
| Zuordnung | Automatisch: Jede E-Mail von ihr gehört dem Tenant | Du übergibst tenant_id bei jeder E-Mail, oder die E-Mail ist deine eigene |
| Wer von ihr senden darf | Nur dieser Tenant | Deine eigenen E-Mails und jeder Tenant |
Nennt eine E-Mail eine tenant_id, die vom Tenant ihrer Domain abweicht, schlägt die Anfrage mit 422 validation_error fehl. Eine tenant_id ist optional, wenn die from-Domain bereits zu einem Tenant gehört.
Füge eine Domain im Besitz eines Tenants genauso hinzu wie jede Domain, mit einem zusätzlichen Feld, und lege dann die DNS-Einträge beim DNS-Anbieter des Kunden an. Siehe Hinzufügen und verifizieren.
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_123Für einen Tenant senden
Abschnitt betitelt „Für einen Tenant senden“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 akzeptiert tenant_id bei jedem Eintrag.
Das offizielle Paket resend funktioniert unverändert mit SpacePost. Richte es mit RESEND_BASE_URL=https://api.spacepost.dev oder new Resend(key, { baseUrl }) auf uns aus.
Am einfachsten: von einer Domain im Besitz eines Tenants senden. Der Tenant wird aus der from-Domain abgeleitet, sodass deine bestehenden Aufrufe von resend.emails.send überhaupt nicht geändert werden müssen.
Von einer Domain auf Team-Ebene verwende resend.post. Das Resend SDK baut den Request-Body aus einer festen Liste von Feldern, sodass eine zusätzliche tenant_id, die du an resend.emails.send, resend.batch.send oder resend.domains.create übergibst, stillschweigend verworfen wird, bevor sie SpacePost erreicht. resend.post sendet den Body, den du angibst, unverändert; verwende es daher mit snake_case-Feldern:
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",});Anders als resend.emails.send wandelt resend.post kein camelCase um: Schreibe reply_to und scheduled_at. Dasselbe funktioniert für resend.post("/domains", { name, tenant_id }) und resend.post("/emails/batch", [...]).
spacepost send --from invoices@yourapp.com --to buyer@example.com \ --subject "Invoice #1042" --text "Your invoice is attached." --tenant cust_123Pausieren, fortsetzen und Limits
Abschnitt betitelt „Pausieren, fortsetzen und Limits“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 | Ergebnis |
|---|---|
| Senden für einen pausierten Tenant | 403 sending_paused. Bereits eingereihte E-Mails schlagen mit einem email.failed-Event fehl. |
Tenant über seinem daily_limit |
429 daily_quota_exceeded (der Tag wird um 00:00 UTC zurückgesetzt). Aus dieser Anfrage wird nichts gesendet. |
tenant_id kollidiert mit dem Tenant der Domain |
422 validation_error |
Unbekannte tenant_id |
404 not_found |
| Fortsetzen eines Tenants, der von der Reputationsüberwachung pausiert wurde | 403 invalid_access, es sei denn, dein Team ist trusted oder internal. Wende dich nach der Behebung der Ursache an support@spacepost.dev. Pausen, die du selbst gesetzt hast, kannst du immer aufheben. |
| Löschen eines Tenants, der noch Domains hat | 422 validation_error. Lösche zuerst seine Domains. |
Webhooks
Abschnitt betitelt „Webhooks“Deine normalen Webhooks erhalten auch Events für E-Mails von Tenants, wobei data.tenant_id auf unsere Tenant-ID gesetzt ist. E-Mails ohne Tenant haben keinen Schlüssel tenant_id.
{ "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} und GET /domains/{id} geben ebenfalls tenant_id zurück (oder null), sodass du in deiner eigenen Datenbank abgleichen kannst.
Checkliste für eine SaaS-Integration
Abschnitt betitelt „Checkliste für eine SaaS-Integration“- Erstelle einen Tenant, wenn sich ein Kunde registriert, mit deiner Kunden-ID als
external_id. - Entscheide pro Kunde: seine eigene Domain (
tenant_idbeiPOST /domains) oder deine gemeinsame Domain (tenant_idbei jedem Versand). - Setze für neue Kunden ein vorsichtiges
daily_limitund erhöhe es, sobald sie sich bewähren. - Abonniere
email.bounced,email.complainedundemail.failedund zeige Probleme protenant_idin deiner Admin-Oberfläche an. - Pausiere einen Kunden selbst, sobald du Missbrauch siehst. Das wirkt sofort und ist umkehrbar.