Modo plataforma (tenants)
El modo plataforma permite que una cuenta de SpacePost envíe en nombre de muchos de tus clientes. Cada cliente es un tenant. Úsalo cuando construyas un producto que envía correo para otras empresas, por ejemplo un SaaS de facturación o de soporte donde cada cliente conecta su propio dominio y un mal actor no debe perjudicar a todos los demás.
Si solo envías el correo de tu propio producto, no necesitas tenants.
Qué te da un tenant
Sección titulada «Qué te da un tenant»| Reputación aislada | Cada tenant recibe su propio tenant de Amazon SES. Las quejas y los rebotes de un cliente solo se reflejan en ese cliente. |
| Su propia protección de reputación | Los mismos umbrales de pausa automática (más de un 5 % de rebotes o un 0,2 % de quejas en los últimos 1.000 correos del tenant, con tamaños mínimos de muestra) pausan solo a ese tenant. |
| Tu cuenta se mantiene limpia | El correo de los tenants no cuenta en la comprobación de reputación de tu propio equipo, así que un mal cliente no puede pausar tu cuenta ni a tus otros clientes. |
| Interruptor de pausa | Pausa y reanuda tú mismo cualquier tenant, indicando un motivo. |
| Tope diario | daily_limit opcional, en destinatarios por día UTC y por tenant. |
| Tus IDs | Identifica a un tenant con tu propio external_id en lugar de guardar el nuestro. |
| Atribución | Los correos, los dominios y los eventos de webhook llevan tenant_id. |
Los tenants comparten tu plan: cada destinatario de un tenant cuenta para tu cuota mensual, los límites diarios de tu equipo y tu límite de tasa de la API. El filtrado de contenido también se aplica al correo de los tenants, incluidos los de una cuenta que por lo demás esté exenta de él.
Crear un tenant
Sección titulada «Crear un tenant»POST /tenants recibe un name y, opcionalmente, un external_id (único dentro de tu cuenta) y un daily_limit. Todos los endpoints de tenants necesitan una clave de 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
Sección titulada «external_id»En cualquier lugar donde se identifica a un tenant (/tenants/:ref, tenant_id en correos y dominios, spacepost tenants get <ref>) puedes pasar nuestro id o tu external_id. Guarda una sola vez el id de tu cliente y no necesitarás mantener una tabla de correspondencias. Los webhooks siempre llevan nuestro id de tenant; consúltalo con GET /tenants/{id} si necesitas el external_id.
Dominios: propiedad del tenant o de nivel de equipo
Sección titulada «Dominios: propiedad del tenant o de nivel de equipo»| Dominio propiedad del tenant | Dominio de nivel de equipo | |
|---|---|---|
| Se crea con | tenant_id en POST /domains |
POST /domains sin tenant_id |
| Propietario habitual | El dominio del propio cliente (mail.acme-inc.com) |
Tu dominio (yourapp.com) |
| Atribución | Automática: todos los correos enviados desde él pertenecen al tenant | Pasas tenant_id en cada correo, o el correo es tuyo |
| Quién puede enviar desde él | Solo ese tenant | Tu propio correo y cualquier tenant |
Si un correo indica un tenant_id distinto del tenant de su dominio, la petición falla con 422 validation_error. El tenant_id es opcional cuando el dominio de from ya pertenece a un tenant.
Añade un dominio propiedad de un tenant igual que cualquier otro dominio, con un campo adicional, y luego añade los registros DNS en el proveedor de DNS del cliente. Consulta Añadir y verificar.
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_123Enviar para un tenant
Sección titulada «Enviar para 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 acepta tenant_id en cada elemento.
El paquete oficial resend funciona con SpacePost sin cambios. Apúntalo hacia nosotros con RESEND_BASE_URL=https://api.spacepost.dev o new Resend(key, { baseUrl }).
Lo más fácil: envía desde un dominio propiedad del tenant. El tenant se deduce del dominio de from, así que tus llamadas actuales a resend.emails.send no necesitan ningún cambio.
Desde un dominio de nivel de equipo, usa resend.post. El SDK de Resend construye el cuerpo de la petición a partir de una lista fija de campos, así que un tenant_id adicional pasado a resend.emails.send, resend.batch.send o resend.domains.create se descarta en silencio antes de llegar a SpacePost. resend.post envía tal cual el cuerpo que le das, así que úsalo con campos 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",});A diferencia de resend.emails.send, resend.post no convierte el camelCase: escribe reply_to y scheduled_at. Lo mismo funciona con resend.post("/domains", { name, tenant_id }) y resend.post("/emails/batch", [...]).
spacepost send --from invoices@yourapp.com --to buyer@example.com \ --subject "Invoice #1042" --text "Your invoice is attached." --tenant cust_123Pausa, reanudación y topes
Sección titulada «Pausa, reanudación y topes»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| Situación | Resultado |
|---|---|
| Enviar para un tenant pausado | 403 sending_paused. Los correos que ya estaban en cola fallan con un evento email.failed. |
Tenant que supera su daily_limit |
429 daily_quota_exceeded (el día se reinicia a las 00:00 UTC). No se envía nada de esa petición. |
tenant_id en conflicto con el tenant del dominio |
422 validation_error |
tenant_id desconocido |
404 not_found |
| Reanudar un tenant pausado por la protección de reputación | 403 invalid_access, salvo que tu equipo sea trusted o internal. Contacta con support@spacepost.dev tras corregir la causa. Las pausas que hiciste tú mismo siempre se pueden levantar. |
| Eliminar un tenant que aún tiene dominios | 422 validation_error. Elimina primero sus dominios. |
Webhooks
Sección titulada «Webhooks»Tus webhooks habituales también reciben eventos del correo de los tenants, con data.tenant_id establecido con nuestro id de tenant. Los correos sin tenant no tienen la clave 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} y GET /domains/{id} también devuelven tenant_id (o null), para que puedas conciliar en tu propia base de datos.
Lista de comprobación para una integración SaaS
Sección titulada «Lista de comprobación para una integración SaaS»- Crea un tenant cuando un cliente se registre, con el id de tu cliente como
external_id. - Decide por cliente: su propio dominio (
tenant_idenPOST /domains) o tu dominio compartido (tenant_iden cada envío). - Define un
daily_limitprudente para los clientes nuevos y súbelo a medida que demuestren su fiabilidad. - Suscríbete a
email.bounced,email.complainedyemail.failed, y muestra los problemas detenant_iden tu interfaz de administración. - Pausa tú mismo a un cliente en cuanto veas un abuso. Es instantáneo y reversible.