Skip to content

Node.js SDK

@spacepost/node is a zero-dependency, fetch-based client with the same method names and { data, error } return shape as the Resend SDK. It runs on Node 18+, Bun, Deno and edge runtimes.

Terminal window
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);
Constructor Environment fallback Default
new SpacePost(apiKey) SPACEPOST_API_KEY none; throws if missing
{ baseUrl } SPACEPOST_BASE_URL https://api.spacepost.dev
{ fetch } n/a global fetch (inject for tests or proxies)
{ userAgent } n/a spacepost-node/<version>

Every method returns a promise of { data, error }. Exactly one is non-null. Methods never throw on API or network errors.

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 is the exception: it throws on an invalid signature.)

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);
}

Request fields can be written in camelCase (replyTo, scheduledAt, contentType, audienceId, tenantId, externalId, dailyLimit) or snake_case; the SDK converts. Responses are returned exactly as the API sends them (snake_case).

Namespace Methods
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 accepts every field of Send an email, including tenantId for platform mode, plus react for React Email (needs the optional peer dependency @react-email/render; see React Email). Attachment content may be a base64 string, a Buffer or a Uint8Array.

batch.send(emails, { idempotencyKey? }) sends up to 100 emails (each may carry its own tenantId) and resolves to { data: { data: [{ id }] } }. See Batch sending.

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) (with records), list(), verify(id), update({ id, openTracking?, clickTracking? }), remove(id). See Adding and verifying.

create({ name, permission?, domainId? }) returns { id, token }; the token is shown once.

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 manage endpoints (Webhooks).

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 }) checks a delivery’s signature and returns the parsed event, or throws. Pass the raw body; headers can be a Headers object or a plain object.

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 });
}
}

Webhook events are typed. verify returns a WebhookPayload, a union discriminated by type, so narrowing on event.type gives you data.bounce, data.click, data.open, data.failed or data.suppressed. Every event also has data.tenant_id when the email was sent for a tenant. See Event types.

Platform mode: one tenant per customer you send for, with isolated reputation, a pause switch and an optional daily cap. See the Platform mode guide.

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 removes the cap), remove(idOrExternalId) (422 while the tenant has domains), pause({ id, reason? }) (or pause(id)) and unpause(idOrExternalId) (403 for reputation pauses unless your team is trusted). Anywhere an id is expected you can pass your 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" });

Contacts are addressed with audienceId plus either id or email.

create, get, list, update(id, payload) (or update({ id, ... })), remove, send(id, { scheduledAt? }). See Broadcasts.

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

The second argument of emails.send and batch.send takes { idempotencyKey }. See Idempotency keys.

You do not need @spacepost/node: the official resend package works unchanged against SpacePost. Set RESEND_BASE_URL=https://api.spacepost.dev or pass new Resend(key, { baseUrl: "https://api.spacepost.dev" }). See Migrating from Resend.

SpacePost extras such as tenant_id are not part of the Resend SDK’s methods, which drop unknown fields. Use @spacepost/node, resend.post("/emails", { ..., tenant_id }) or tenant-owned domains; see Platform mode.

All request and response types are exported: import type { SendEmailOptions, Email, Domain, Tenant, WebhookPayload } from "@spacepost/node". Emails and domains include tenant_id: string | null.