콘텐츠로 이동

Node.js SDK

@spacepost/node는 의존성이 없고 fetch 기반인 클라이언트로, Resend SDK와 같은 메서드 이름과 { data, error } 반환 형식을 가집니다. Node 18 이상, Bun, Deno, 엣지 런타임에서 실행됩니다.

터미널 창
npm install @spacepost/node
# or: bun add @spacepost/node | pnpm add @spacepost/node | yarn add @spacepost/node
client.ts
import { SpacePost } from "@spacepost/node";
// Reads SPACEPOST_API_KEY (and SPACEPOST_BASE_URL) from the environment when no key is passed.
const spacepost = new SpacePost();
// Or configure explicitly, for example in tests or against a self-hosted deployment.
const custom = new SpacePost("sp_live_YOUR_API_KEY", { baseUrl: "https://api.spacepost.dev" });
console.log(spacepost.baseUrl, custom.baseUrl);
생성자 환경 변수 대체값 기본값
new SpacePost(apiKey) SPACEPOST_API_KEY 없음. 없으면 예외 발생
{ baseUrl } SPACEPOST_BASE_URL https://api.spacepost.dev
{ fetch } 해당 없음 전역 fetch(테스트나 프록시용으로 주입)
{ userAgent } 해당 없음 spacepost-node/<version>

모든 메서드는 { data, error }의 프로미스를 반환합니다. 둘 중 정확히 하나만 null이 아닙니다. 메서드는 API 오류나 네트워크 오류에서 예외를 던지지 않습니다.

const { data, error } = await spacepost.emails.get(id);
if (error) {
// error.name "not_found" | "validation_error" | ...
// error.message human readable
// error.statusCode HTTP status, or null when the request never got a response
return;
}
console.log(data.last_event);

(webhooks.verify는 예외입니다. 유효하지 않은 서명이면 예외를 던집니다.)

errors.ts
import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost();
const { data, error } = await spacepost.emails.send({
from: "Acme <hello@acme.com>",
to: "user@example.com",
subject: "Hello",
html: "<p>Hello</p>",
});
if (error) {
switch (error.name) {
case "rate_limit_exceeded":
// wait about a second, then retry with the same idempotency key
break;
case "daily_quota_exceeded":
case "monthly_quota_exceeded":
// alert someone: you hit a limit
break;
case "validation_error":
case "invalid_from_address":
// a bug in your request: do not retry
throw new Error(`${error.name}: ${error.message}`);
default:
console.error(error.statusCode, error.name, error.message);
}
} else {
console.log(data.id);
}

요청 필드는 camelCase(replyTo, scheduledAt, contentType, audienceId, tenantId, externalId, dailyLimit)나 snake_case 중 어느 쪽으로 써도 되며 SDK가 변환합니다. 응답은 API가 보내는 그대로(snake_case) 반환됩니다.

네임스페이스 메서드
emails send, get, list, update, cancel
batch send
domains create, get, list, verify, update, remove
apiKeys create, list, remove
webhooks create, get, list, update, remove, verify
audiences create, get, list, remove
contacts create, get, list, update, remove
broadcasts create, get, list, update, remove, send
suppressions list, create, remove
tenants create, get, list, update, remove, pause, unpause

send(payload, { idempotencyKey? }), get(id), list({ limit, after, before }), update({ id, scheduledAt }), cancel(id).

emails.ts
import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost();
// send
const { data: sent } = await spacepost.emails.send({
from: "Acme <hello@acme.com>",
to: "user@example.com",
subject: "Hello",
html: "<p>Hello</p>",
scheduledAt: "in 1 hour",
});
if (sent) {
// get: includes html/text and last_event
const { data: email } = await spacepost.emails.get(sent.id);
console.log(email?.last_event); // "scheduled"
// update: reschedule
await spacepost.emails.update({ id: sent.id, scheduledAt: "in 3 hours" });
// cancel
await spacepost.emails.cancel(sent.id);
}
// list: cursor pagination, newest first, bodies omitted
const { data: page } = await spacepost.emails.list({ limit: 10 });
if (page?.has_more) {
const last = page.data[page.data.length - 1]!;
const { data: older } = await spacepost.emails.list({ limit: 10, after: last.id });
console.log(older?.data.length);
}

send는 이메일 보내기의 모든 필드를 받으며, 플랫폼 모드용 tenantId와 React Email용 react(선택적 피어 의존성 @react-email/render 필요, React Email 참고)도 포함합니다. 첨부 파일 content는 base64 문자열, Buffer, Uint8Array 중 하나일 수 있습니다.

batch.send(emails, { idempotencyKey? })는 최대 100통의 이메일(각각 자체 tenantId를 가질 수 있음)을 보내고 { data: { data: [{ id }] } }로 해석됩니다. 배치 발송을 참고하세요.

batch.ts
import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY);
const { data, error } = await spacepost.batch.send(
[
{ from: "Acme <hello@acme.com>", to: ["a@example.com"], subject: "Hi Ada", html: "<p>Hi Ada</p>" },
{ from: "Acme <hello@acme.com>", to: ["b@example.com"], subject: "Hi Bob", html: "<p>Hi Bob</p>" },
],
{ idempotencyKey: "weekly-digest-2026-w43" },
);
if (error) console.error(error);
else console.log(data.data.map((e) => e.id)); // ids in input order
domains.ts
import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost();
const { data: domain } = await spacepost.domains.create({ name: "acme.com", customReturnPath: "send" });
if (domain) {
await spacepost.domains.verify(domain.id);
const { data: fresh } = await spacepost.domains.get(domain.id); // includes records
console.log(fresh?.status, fresh?.records);
await spacepost.domains.update({ id: domain.id, openTracking: true, clickTracking: true });
await spacepost.domains.remove(domain.id);
}
const { data: all } = await spacepost.domains.list(); // no records in lists
console.log(all?.data.map((d) => d.name));

create({ name, region?, customReturnPath?, tenantId? }), get(id)(records 포함), list(), verify(id), update({ id, openTracking?, clickTracking? }), remove(id). 추가 및 확인을 참고하세요.

create({ name, permission?, domainId? })는 { id, token }을 반환합니다. 토큰은 한 번만 표시됩니다.

api-keys.ts
import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost();
// A key that can only send, and only from one domain.
const { data: key, error } = await spacepost.apiKeys.create({
name: "worker-prod",
permission: "sending_access",
domainId: "0192e1c4-0000-7000-8000-000000000001",
});
if (error) throw new Error(error.message);
console.log(key.token); // sp_live_..., shown only once
const { data: keys } = await spacepost.apiKeys.list();
console.log(keys?.data.map((k) => `${k.name} ${k.prefix}... ${k.permission}`));
await spacepost.apiKeys.remove(key.id);

create, get, list, update, remove로 엔드포인트를 관리합니다(웹훅).

create-webhook.ts
import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY);
const { data: webhook, error } = await spacepost.webhooks.create({
endpoint: "https://acme.com/webhooks/spacepost",
events: ["email.delivered", "email.bounced", "email.complained"],
});
if (error) throw new Error(`${error.name}: ${error.message}`);
// Store this secret: you need it to verify signatures. It starts with "whsec_".
console.log(webhook.id, webhook.signing_secret);

verify({ payload, headers, secret })는 전달의 서명을 확인하고 파싱된 이벤트를 반환하거나 예외를 던집니다. 원본 본문을 전달하세요. headers는 Headers 객체나 일반 객체일 수 있습니다.

webhook-handler.ts
import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY);
/** Works as a Bun, Deno, Cloudflare Workers or Next.js route handler. */
export async function POST(request: Request): Promise<Response> {
const payload = await request.text(); // the RAW body: do not parse it first
try {
const event = await spacepost.webhooks.verify({
payload,
headers: request.headers,
secret: process.env.SPACEPOST_WEBHOOK_SECRET!,
});
switch (event.type) {
case "email.bounced":
case "email.complained":
console.warn("Stop mailing", event.data.to);
break;
case "email.delivered":
console.log("Delivered", event.data.email_id);
break;
}
return new Response("ok");
} catch {
return new Response("invalid signature", { status: 400 });
}
}

웹훅 이벤트에는 타입이 지정되어 있습니다. verify는 type으로 구분되는 유니언인 WebhookPayload를 반환하므로 event.type으로 좁히면 data.bounce, data.click, data.open, data.failed, data.suppressed를 사용할 수 있습니다. 이메일이 테넌트를 위해 발송된 경우 모든 이벤트에 data.tenant_id도 있습니다. 이벤트 유형을 참고하세요.

플랫폼 모드: 대신 발송하는 고객마다 테넌트 하나를 두며, 분리된 평판, 일시 중지 스위치, 선택적 일일 한도를 제공합니다. 플랫폼 모드 가이드를 참고하세요.

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

create({ name, externalId?, dailyLimit? }), get(idOrExternalId), list({ limit, after, before }), update({ id, name?, externalId?, dailyLimit? })(dailyLimit: null은 한도를 제거), remove(idOrExternalId)(테넌트에 도메인이 있으면 422), pause({ id, reason? })(또는 pause(id)), unpause(idOrExternalId)(팀이 trusted가 아니면 평판으로 인한 일시 중지는 403). ID가 필요한 곳에는 어디든 내 external_id를 전달할 수 있습니다.

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");
contacts.ts
import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY);
const { data: audience, error } = await spacepost.audiences.create({ name: "Newsletter" });
if (error) throw new Error(error.message);
// Create
await spacepost.contacts.create({
audienceId: audience.id,
email: "ada@example.com",
firstName: "Ada",
lastName: "Lovelace",
});
// Read by email (or by id)
const { data: contact } = await spacepost.contacts.get({ audienceId: audience.id, email: "ada@example.com" });
console.log(contact?.unsubscribed);
// Update
await spacepost.contacts.update({ audienceId: audience.id, email: "ada@example.com", firstName: "Augusta" });
// List, newest first
const { data: page } = await spacepost.contacts.list({ audienceId: audience.id, limit: 50 });
console.log(page?.data.length, page?.has_more);
// Unsubscribe manually, then delete
await spacepost.contacts.update({ audienceId: audience.id, email: "ada@example.com", unsubscribed: true });
await spacepost.contacts.remove({ audienceId: audience.id, email: "ada@example.com" });

연락처는 audienceId와 id 또는 email로 지정합니다.

create, get, list, update(id, payload)(또는 update({ id, ... })), remove, send(id, { scheduledAt? }). 브로드캐스트를 참고하세요.

broadcast.ts
import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY);
const audienceId = "0192e1c4-0000-7000-8000-0000000000a1";
// 1. Create a draft
const { data: draft, error } = await spacepost.broadcasts.create({
audienceId,
name: "October newsletter",
from: "Acme <news@acme.com>",
replyTo: "support@acme.com",
subject: "What's new at Acme, {{{FIRST_NAME|friend}}}",
previewText: "Three things we shipped this month",
html: `
<h1>Hi {{{FIRST_NAME|there}}},</h1>
<p>Here is what is new.</p>
<p><a href="{{{SPACEPOST_UNSUBSCRIBE_URL}}}">Unsubscribe</a></p>
`,
});
if (error) throw new Error(error.message);
// 2. Send it now, or schedule it
const { error: sendError } = await spacepost.broadcasts.send(draft.id, { scheduledAt: "in 1 hour" });
if (sendError) throw new Error(sendError.message);
// 3. Check progress
const { data: broadcast } = await spacepost.broadcasts.get(draft.id);
console.log(broadcast?.status); // "queued" | "sending" | "sent" | "failed"
suppressions.ts
import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost();
await spacepost.suppressions.create({ email: "do-not-mail@example.com" });
const { data } = await spacepost.suppressions.list({ limit: 100 });
for (const s of data?.data ?? []) console.log(s.email, s.reason);
await spacepost.suppressions.remove("do-not-mail@example.com"); // by email or id

emails.send와 batch.send의 두 번째 인자는 { idempotencyKey }를 받습니다. 멱등성 키를 참고하세요.

@spacepost/node가 꼭 필요한 것은 아닙니다. 공식 resend 패키지는 SpacePost에서 변경 없이 동작합니다. RESEND_BASE_URL=https://api.spacepost.dev를 설정하거나 new Resend(key, { baseUrl: "https://api.spacepost.dev" })를 전달하세요. Resend에서 마이그레이션을 참고하세요.

tenant_id 같은 SpacePost 확장 기능은 Resend SDK의 메서드에 포함되어 있지 않으며, 알 수 없는 필드는 버려집니다. @spacepost/node, resend.post("/emails", { ..., tenant_id }), 또는 테넌트 소유 도메인을 사용하세요. 플랫폼 모드를 참고하세요.

모든 요청 및 응답 타입이 내보내집니다: import type { SendEmailOptions, Email, Domain, Tenant, WebhookPayload } from "@spacepost/node". 이메일과 도메인에는 tenant_id: string | null이 포함됩니다.