콘텐츠로 이동

플랫폼 모드(테넌트)

플랫폼 모드를 사용하면 하나의 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 키가 필요합니다.

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"
}

테넌트를 지정하는 모든 곳(/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" }'
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");
상황 결과
일시 중지된 테넌트를 대신해 발송 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" }
}
}
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}와 GET /domains/{id}도 tenant_id(또는 null)를 반환하므로 내 데이터베이스와 대조할 수 있습니다.

  1. 고객이 가입하면 고객 ID를 external_id로 사용해 테넌트를 만듭니다.
  2. 고객별로 고객 자신의 도메인(POST /domains에 tenant_id)을 쓸지, 공용 도메인(발송마다 tenant_id)을 쓸지 정합니다.
  3. 신규 고객에게는 보수적인 daily_limit을 설정하고, 신뢰가 쌓이면 올립니다.
  4. email.bounced, email.complained, email.failed를 구독하고 tenant_id 문제를 관리자 UI에 드러내세요.
  5. 악용이 보이는 즉시 직접 고객을 일시 중지하세요. 즉시 적용되고 되돌릴 수 있습니다.