Mode plateforme (tenants)
Le mode plateforme permet à un seul compte SpacePost d’envoyer pour plusieurs de vos clients. Chaque client est un tenant. Utilisez-le lorsque vous construisez un produit qui envoie des e-mails pour d’autres entreprises, par exemple un SaaS de facturation ou de support où chaque client connecte son propre domaine et où un seul mauvais acteur ne doit pas nuire à tous les autres.
Si vous n’envoyez que les e-mails de votre propre produit, vous n’avez pas besoin de tenants.
Ce qu’un tenant vous apporte
Section intitulée « Ce qu’un tenant vous apporte »| Réputation isolée | Chaque tenant reçoit son propre tenant Amazon SES. Les plaintes et les rebonds d’un client n’apparaissent que sur ce client. |
| Son propre garde-fou de réputation | Les mêmes seuils de mise en pause automatique (plus de 5 % de rebonds ou 0,2 % de plaintes sur les 1 000 derniers e-mails du tenant, avec des tailles d’échantillon minimales) mettent en pause uniquement ce tenant. |
| Votre compte reste propre | Les e-mails d’un tenant ne comptent pas dans la vérification de réputation de votre propre équipe : un mauvais client ne peut donc pas mettre en pause votre compte ni vos autres clients. |
| Interrupteur de pause | Mettez en pause et reprenez n’importe quel tenant vous-même, avec une raison. |
| Plafond quotidien | daily_limit facultatif, en destinataires par jour UTC et par tenant. |
| Vos ID | Désignez un tenant par votre propre external_id au lieu de stocker le nôtre. |
| Attribution | Les e-mails, les domaines et les événements de webhook portent tenant_id. |
Les tenants partagent votre forfait : chaque destinataire d’un tenant compte dans votre quota mensuel, les limites quotidiennes de votre équipe et votre limite de débit d’API. Le filtrage de contenu s’applique aussi aux e-mails des tenants, y compris les tenants d’un compte qui en est par ailleurs exempté.
Créer un tenant
Section intitulée « Créer un tenant »POST /tenants prend un name et, en option, un external_id (unique au sein de votre compte) et un daily_limit. Tous les endpoints de tenants exigent une clé API 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
Section intitulée « external_id »Partout où un tenant est désigné (/tenants/:ref, tenant_id sur les e-mails et les domaines, spacepost tenants get <ref>), vous pouvez passer soit notre id, soit votre external_id. Stockez une seule fois votre propre id client et vous n’aurez jamais besoin de table de correspondance. Les webhooks portent toujours notre id de tenant ; retrouvez-le avec GET /tenants/{id} si vous avez besoin de l’external_id.
Domaines : appartenant au tenant ou au niveau de l’équipe
Section intitulée « Domaines : appartenant au tenant ou au niveau de l’équipe »| Domaine appartenant au tenant | Domaine au niveau de l’équipe | |
|---|---|---|
| Créé avec | tenant_id sur POST /domains |
POST /domains sans tenant_id |
| Propriétaire typique | Le domaine propre du client (mail.acme-inc.com) |
Votre domaine (yourapp.com) |
| Attribution | Automatique : chaque e-mail qui en part appartient au tenant | Vous passez tenant_id sur chaque e-mail, ou l’e-mail est le vôtre |
| Qui peut envoyer depuis ce domaine | Uniquement ce tenant | Vos propres e-mails, et n’importe quel tenant |
Si un e-mail indique un tenant_id différent de celui de son domaine, la requête échoue avec 422 validation_error. Un tenant_id est facultatif lorsque le domaine from appartient déjà à un tenant.
Ajoutez un domaine appartenant à un tenant exactement comme n’importe quel domaine, avec un champ supplémentaire, puis ajoutez les enregistrements DNS chez le fournisseur DNS du client. Voir Ajouter et vérifier.
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_123Envoyer pour un tenant
Section intitulée « Envoyer pour un 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 accepte tenant_id sur chaque élément.
Le paquet officiel resend fonctionne avec SpacePost sans modification. Pointez-le vers nous avec RESEND_BASE_URL=https://api.spacepost.dev ou new Resend(key, { baseUrl }).
Le plus simple : envoyer depuis un domaine appartenant au tenant. Le tenant est déduit du domaine from : vos appels resend.emails.send existants n’ont donc besoin d’aucun changement.
Depuis un domaine au niveau de l’équipe, utilisez resend.post. Le SDK Resend construit le corps de la requête à partir d’une liste fixe de champs : un tenant_id supplémentaire passé à resend.emails.send, resend.batch.send ou resend.domains.create est donc silencieusement supprimé avant d’atteindre SpacePost. resend.post envoie tel quel le corps que vous lui donnez ; utilisez-le avec des champs en snake_case :
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",});Contrairement à resend.emails.send, resend.post ne convertit pas le camelCase : écrivez reply_to et scheduled_at. Cela fonctionne de la même façon pour resend.post("/domains", { name, tenant_id }) et 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, reprise et plafonds
Section intitulée « Pause, reprise et plafonds »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 | Résultat |
|---|---|
| Envoi pour un tenant en pause | 403 sending_paused. Les e-mails déjà en file échouent avec un événement email.failed. |
Tenant au-delà de son daily_limit |
429 daily_quota_exceeded (le jour se réinitialise à 00:00 UTC). Rien de cette requête n’est envoyé. |
tenant_id en conflit avec le tenant du domaine |
422 validation_error |
tenant_id inconnu |
404 not_found |
| Reprise d’un tenant mis en pause par le garde-fou de réputation | 403 invalid_access, sauf si votre équipe est trusted ou internal. Contactez support@spacepost.dev après avoir corrigé la cause. Les pauses que vous avez faites vous-même peuvent toujours être levées. |
| Suppression d’un tenant qui a encore des domaines | 422 validation_error. Supprimez d’abord ses domaines. |
Webhooks
Section intitulée « Webhooks »Vos webhooks habituels reçoivent aussi les événements des e-mails de tenants, avec data.tenant_id défini sur notre id de tenant. Les e-mails sans tenant n’ont pas de clé 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} et GET /domains/{id} renvoient aussi tenant_id (ou null), ce qui vous permet de réconcilier avec votre propre base de données.
Liste de contrôle pour une intégration SaaS
Section intitulée « Liste de contrôle pour une intégration SaaS »- Créez un tenant lorsqu’un client s’inscrit, avec votre id client comme
external_id. - Décidez par client : son propre domaine (
tenant_idsurPOST /domains) ou votre domaine partagé (tenant_idà chaque envoi). - Définissez un
daily_limitprudent pour les nouveaux clients et relevez-le à mesure qu’ils font leurs preuves. - Abonnez-vous à
email.bounced,email.complainedetemail.failed, et faites remonter les problèmes detenant_iddans votre interface d’administration. - Mettez un client en pause vous-même dès que vous constatez un abus. C’est instantané et réversible.