Zum Inhalt springen

Node.js SDK

@spacepost/node ist ein fetch-basierter Client ohne Abhängigkeiten, mit denselben Methodennamen und demselben Rückgabeformat { data, error } wie das Resend SDK. Er läuft unter Node 18+, Bun, Deno und Edge-Runtimes.

Terminal-Fenster
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);
Konstruktor Fallback aus der Umgebung Standard
new SpacePost(apiKey) SPACEPOST_API_KEY keiner; wirft eine Exception, wenn er fehlt
{ baseUrl } SPACEPOST_BASE_URL https://api.spacepost.dev
{ fetch } n. z. globales fetch (für Tests oder Proxys injizierbar)
{ userAgent } n. z. spacepost-node/<version>

Jede Methode gibt ein Promise von { data, error } zurück. Genau eines von beiden ist nicht null. Methoden werfen bei API- oder Netzwerkfehlern nie eine Exception.

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 ist die Ausnahme: Es wirft bei einer ungültigen Signatur eine Exception.)

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

Felder in Anfragen können in camelCase (replyTo, scheduledAt, contentType, audienceId, tenantId, externalId, dailyLimit) oder snake_case geschrieben werden; das SDK wandelt um. Antworten werden genau so zurückgegeben, wie die API sie sendet (snake_case).

Namespace Methoden
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 akzeptiert jedes Feld aus E-Mail senden, einschließlich tenantId für den Plattform-Modus, sowie react für React Email (benötigt die optionale Peer-Dependency @react-email/render; siehe React Email). Der content eines Anhangs darf ein Base64-String, ein Buffer oder ein Uint8Array sein.

batch.send(emails, { idempotencyKey? }) sendet bis zu 100 E-Mails (jede kann ihre eigene tenantId tragen) und liefert { data: { data: [{ id }] } }. Siehe Batch-Versand.

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) (mit records), list(), verify(id), update({ id, openTracking?, clickTracking? }), remove(id). Siehe Hinzufügen und verifizieren.

create({ name, permission?, domainId? }) gibt { id, token } zurück; das Token wird einmal angezeigt.

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 verwalten 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 }) prüft die Signatur einer Zustellung und gibt das geparste Event zurück oder wirft eine Exception. Übergib den rohen Body; headers kann ein Headers-Objekt oder ein einfaches Objekt sein.

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 sind typisiert. verify gibt einen WebhookPayload zurück, eine über type unterschiedene Union; wenn du auf event.type einengst, erhältst du data.bounce, data.click, data.open, data.failed oder data.suppressed. Jedes Event hat außerdem data.tenant_id, wenn die E-Mail für einen Tenant gesendet wurde. Siehe Event-Typen.

Plattform-Modus: ein Tenant pro Kunde, für den du sendest, mit isolierter Reputation, einem Pausenschalter und einem optionalen Tageslimit. Siehe die Anleitung zum Plattform-Modus.

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 entfernt das Limit), remove(idOrExternalId) (422, solange der Tenant Domains hat), pause({ id, reason? }) (oder pause(id)) und unpause(idOrExternalId) (403 bei Reputationspausen, es sei denn, dein Team ist trusted). Überall, wo eine ID erwartet wird, kannst du deine external_id übergeben.

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

Kontakte werden mit audienceId plus entweder id oder email angesprochen.

create, get, list, update(id, payload) (oder update({ id, ... })), remove, send(id, { scheduledAt? }). Siehe Kampagnen.

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

Das zweite Argument von emails.send und batch.send nimmt { idempotencyKey } entgegen. Siehe Idempotenz-Schlüssel.

Du brauchst @spacepost/node nicht: Das offizielle Paket resend funktioniert unverändert mit SpacePost. Setze RESEND_BASE_URL=https://api.spacepost.dev oder übergib new Resend(key, { baseUrl: "https://api.spacepost.dev" }). Siehe Migration von Resend.

Zusatzfunktionen von SpacePost wie tenant_id sind nicht Teil der Methoden des Resend SDK, die unbekannte Felder verwerfen. Nutze @spacepost/node, resend.post("/emails", { ..., tenant_id }) oder Domains im Besitz eines Tenants; siehe Plattform-Modus.

Alle Typen für Anfragen und Antworten werden exportiert: import type { SendEmailOptions, Email, Domain, Tenant, WebhookPayload } from "@spacepost/node". E-Mails und Domains enthalten tenant_id: string | null.