플랫폼 모드(테넌트)
플랫폼 모드를 사용하면 하나의 SpacePost 계정으로 내 여러 고객을 대신해 발송할 수 있습니다. 각 고객이 테넌트입니다. 다른 기업을 대신해 이메일을 보내는 제품을 만들 때 사용하세요. 예를 들어 고객마다 자기 도메인을 연결하는 청구서 발행이나 고객 지원 SaaS에서, 문제를 일으키는 한 곳이 나머지 모두에게 피해를 주어서는 안 되는 경우입니다.
자사 제품의 이메일만 보낸다면 테넌트가 필요하지 않습니다.
테넌트가 제공하는 것
섹션 제목: “테넌트가 제공하는 것”| 분리된 평판 | 모든 테넌트는 자체 Amazon SES 테넌트를 가집니다. 한 고객의 스팸 신고와 반송은 그 고객에게만 표시됩니다. |
| 자체 평판 보호 | 같은 자동 일시 중지 기준(최소 표본 수를 충족한 상태에서 테넌트의 최근 이메일 1,000통 중 반송 5% 초과 또는 스팸 신고 0.2% 초과)이 해당 테넌트만 일시 중지합니다. |
| 내 계정은 깨끗하게 유지 | 테넌트 메일은 내 팀의 평판 점검에 반영되지 않으므로, 문제가 있는 고객 한 곳이 내 계정이나 다른 고객을 일시 중지시킬 수 없습니다. |
| 일시 중지 스위치 | 사유와 함께 원하는 테넌트를 직접 일시 중지하고 재개할 수 있습니다. |
| 일일 한도 | 테넌트별로 UTC 하루 기준 수신자 수를 제한하는 선택적 daily_limit이 있습니다. |
| 내 ID 사용 | SpacePost의 ID를 저장하지 않고 내 external_id로 테넌트를 지정할 수 있습니다. |
| 귀속 | 이메일, 도메인, 웹훅 이벤트에 tenant_id가 포함됩니다. |
테넌트는 내 요금제를 공유합니다. 테넌트의 모든 수신자는 내 월 할당량, 팀의 일일 한도, API 요청 한도에 포함됩니다. 콘텐츠 필터링은 다른 경우에는 면제되는 계정의 테넌트를 포함해 테넌트 메일에도 적용됩니다.
테넌트 만들기
섹션 제목: “테넌트 만들기”POST /tenants는 name을 받으며, 선택적으로 external_id(계정 안에서 고유)와 daily_limit을 받습니다. 모든 테넌트 엔드포인트에는 full_access API 키가 필요합니다.
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
섹션 제목: “external_id”테넌트를 지정하는 모든 곳(/tenants/:ref, 이메일과 도메인의 tenant_id, spacepost tenants get <ref>)에서 SpacePost의 id나 내 external_id 중 아무거나 전달할 수 있습니다. 내 고객 ID를 한 번만 저장해 두면 매핑 테이블을 따로 관리할 필요가 없습니다. 웹훅에는 항상 SpacePost의 테넌트 ID가 담기므로 external_id가 필요하면 GET /tenants/{id}로 조회하세요.
도메인: 테넌트 소유 또는 팀 수준
섹션 제목: “도메인: 테넌트 소유 또는 팀 수준”| 테넌트 소유 도메인 | 팀 수준 도메인 | |
|---|---|---|
| 만드는 방법 | POST /domains에 tenant_id 포함 |
tenant_id 없이 POST /domains |
| 일반적인 소유자 | 고객 자신의 도메인(mail.acme-inc.com) |
내 도메인(yourapp.com) |
| 귀속 | 자동: 이 도메인에서 나가는 모든 이메일이 테넌트에 속함 | 이메일마다 tenant_id를 전달하거나, 내 이메일로 취급됨 |
| 발송할 수 있는 대상 | 해당 테넌트만 | 내 이메일과 모든 테넌트 |
이메일에 지정한 tenant_id가 도메인의 테넌트와 다르면 요청은 422 validation_error로 실패합니다. from 도메인이 이미 테넌트에 속해 있다면 tenant_id는 생략할 수 있습니다.
테넌트 소유 도메인은 필드 하나만 더 추가하면 일반 도메인과 똑같이 추가할 수 있고, 이후 고객의 DNS 제공업체에서 DNS 레코드를 추가합니다. 추가 및 확인을 참고하세요.
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_123테넌트를 대신해 발송하기
섹션 제목: “테넌트를 대신해 발송하기”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는 각 항목에 tenant_id를 받습니다.
공식 resend 패키지는 SpacePost에서 변경 없이 동작합니다. RESEND_BASE_URL=https://api.spacepost.dev 또는 new Resend(key, { baseUrl })로 SpacePost를 가리키게 하세요.
가장 쉬운 방법: 테넌트 소유 도메인에서 보내세요. from 도메인에서 테넌트가 추론되므로 기존 resend.emails.send 호출을 전혀 바꿀 필요가 없습니다.
팀 수준 도메인에서는 resend.post를 사용하세요. Resend SDK는 고정된 필드 목록으로 요청 본문을 만들기 때문에, resend.emails.send, resend.batch.send, resend.domains.create에 추가로 전달한 tenant_id는 SpacePost에 도달하기 전에 조용히 버려집니다. resend.post는 전달한 본문을 그대로 보내므로 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",});resend.emails.send와 달리 resend.post는 camelCase를 변환하지 않으므로 reply_to와 scheduled_at으로 쓰세요. resend.post("/domains", { name, tenant_id })와 resend.post("/emails/batch", [...])도 마찬가지입니다.
spacepost send --from invoices@yourapp.com --to buyer@example.com \ --subject "Invoice #1042" --text "Your invoice is attached." --tenant cust_123일시 중지, 재개, 한도
섹션 제목: “일시 중지, 재개, 한도”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| 상황 | 결과 |
|---|---|
| 일시 중지된 테넌트를 대신해 발송 | 403 sending_paused. 이미 큐에 들어간 이메일은 email.failed 이벤트와 함께 실패합니다. |
테넌트가 daily_limit 초과 |
429 daily_quota_exceeded(하루는 UTC 00:00에 초기화됨). 해당 요청의 어떤 이메일도 발송되지 않습니다. |
tenant_id가 도메인의 테넌트와 충돌 |
422 validation_error |
알 수 없는 tenant_id |
404 not_found |
| 평판 보호로 일시 중지된 테넌트의 재개 | 403 invalid_access. 팀이 trusted 또는 internal이면 예외입니다. 원인을 해결한 뒤 support@spacepost.dev로 문의하세요. 직접 일시 중지한 경우에는 언제든 해제할 수 있습니다. |
| 도메인이 남아 있는 테넌트 삭제 | 422 validation_error. 먼저 해당 테넌트의 도메인을 삭제하세요. |
일반 웹훅도 테넌트 메일의 이벤트를 받으며, data.tenant_id에는 SpacePost의 테넌트 ID가 설정됩니다. 테넌트가 없는 이메일에는 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}와 GET /domains/{id}도 tenant_id(또는 null)를 반환하므로 내 데이터베이스와 대조할 수 있습니다.
SaaS 연동 체크리스트
섹션 제목: “SaaS 연동 체크리스트”- 고객이 가입하면 고객 ID를
external_id로 사용해 테넌트를 만듭니다. - 고객별로 고객 자신의 도메인(
POST /domains에tenant_id)을 쓸지, 공용 도메인(발송마다tenant_id)을 쓸지 정합니다. - 신규 고객에게는 보수적인
daily_limit을 설정하고, 신뢰가 쌓이면 올립니다. email.bounced,email.complained,email.failed를 구독하고tenant_id문제를 관리자 UI에 드러내세요.- 악용이 보이는 즉시 직접 고객을 일시 중지하세요. 즉시 적용되고 되돌릴 수 있습니다.