SDKs

Node.js

Use the Resend Node SDK with Sooner by setting the base URL, send and manage resources, handle errors, and verify webhooks.

Sooner implements Resend's API, so the official resend package works against it. You change the API key and the base URL, and everything else stays as it is in your code.

Install and configure

Shell
pnpm add resend

Create the client with a Sooner key and the Sooner base URL:

TypeScript
import { Resend } from "resend";

export const resend = new Resend(process.env.SOONER_API_KEY, {
  baseUrl: "https://api.sooner.dev",
});

Keep the key in an environment variable. Use a sending_access key if this code only sends email. Sooner keys start with sn_, and a Resend key (re_...) is rejected with 403 invalid_api_key. See Authentication.

If you would rather not touch your code, existing Resend setups can also point at Sooner with RESEND_API_KEY and RESEND_BASE_URL set to your Sooner key and https://api.sooner.dev. See Migrate from Resend.

Send an email

TypeScript
const { data, error } = await resend.emails.send(
  {
    from: "Acme <orders@mail.yourcompany.com>",
    to: ["ada@example.com"],
    subject: "Your order is confirmed",
    html: "<p>Thanks for your order.</p>",
    text: "Thanks for your order.",
    tags: [{ name: "category", value: "order_confirmation" }],
  },
  { idempotencyKey: "order-1042-confirmation" },
);

if (error) {
  console.error(error.name, error.message);
} else {
  console.log("queued", data.id);
}

The from domain must be verified in the project that owns the key. See Send an email for every field.

Batches, scheduling and cancel

TypeScript
const { data } = await resend.batch.send([
  { from: "Acme <hello@mail.yourcompany.com>", to: ["ada@example.com"], subject: "Hi Ada", html: "<p>Hi</p>" },
  { from: "Acme <hello@mail.yourcompany.com>", to: ["grace@example.com"], subject: "Hi Grace", html: "<p>Hi</p>" },
]);

const scheduled = await resend.emails.send({
  from: "Acme <hello@mail.yourcompany.com>",
  to: ["ada@example.com"],
  subject: "Your trial ends tomorrow",
  html: "<p>Your trial ends tomorrow.</p>",
  scheduledAt: "in 2 days",
});

await resend.emails.update({ id: scheduled.data!.id, scheduledAt: "2026-11-21T09:00:00Z" });
await resend.emails.cancel(scheduled.data!.id);

Batches cannot include attachments or scheduledAt. See Batch sending and Scheduling.

Retrieve an email

TypeScript
const { data: email } = await resend.emails.get("01jcz8w3f2x9k4m7n6p5q2r1s0");
console.log(email?.status, email?.last_event);

This needs a full_access key.

React emails

The SDK renders the react field to HTML in your own process, then sends the HTML. It works with Sooner because Sooner never receives the React element:

TypeScript
import { WelcomeEmail } from "./emails/welcome";

await resend.emails.send({
  from: "Acme <hello@mail.yourcompany.com>",
  to: ["ada@example.com"],
  subject: "Welcome",
  react: WelcomeEmail({ name: "Ada" }),
});

Other resources

The same client covers the rest of the API with a full_access key:

TypeScript
const { data: domain } = await resend.domains.create({ name: "mail.yourcompany.com" });
await resend.domains.verify(domain!.id);

const { data: key } = await resend.apiKeys.create({ name: "marketing-site", permission: "sending_access" });
console.log(key?.token); // shown once

Contacts, segments, topics, broadcasts, templates, suppressions and webhooks follow the paths in the API reference. Field names are the ones in that reference. If a method or option differs in your SDK version, call the endpoint directly with fetch, as shown below.

Handle errors

Calls return { data, error } and do not throw for API errors. When error is set, branch on error.name, which is the error name from Errors.

TypeScript
async function sendWithRetry(payload: Parameters<typeof resend.emails.send>[0], key: string, attempts = 5) {
  for (let i = 0; i < attempts; i++) {
    const { data, error } = await resend.emails.send(payload, { idempotencyKey: key });
    if (!error) return data;

    const retryable =
      error.name === "rate_limit_exceeded" ||
      error.name === "daily_quota_exceeded" ||
      error.name === "internal_server_error";
    if (!retryable || i === attempts - 1) throw new Error(`${error.name}: ${error.message}`);

    await new Promise((r) => setTimeout(r, 2 ** i * 1000));
  }
}

Do not retry monthly_quota_exceeded, validation_error or invalid_from_address. Always pass an idempotency key so a retry cannot send the email twice. See Idempotency.

Network failures can surface as an error object too, or as a thrown exception depending on the SDK version, so wrap calls in try and catch as well.

Without the SDK

Plain fetch works the same way and is useful when you need an endpoint the SDK does not expose:

TypeScript
const res = await fetch("https://api.sooner.dev/usage", {
  headers: { Authorization: `Bearer ${process.env.SOONER_API_KEY}` },
});
const usage = await res.json();
if (!res.ok) throw new Error(`${usage.name}: ${usage.message}`);
console.log(usage.emails.monthly);

Verify webhooks

Use the svix package with the webhook's signing secret and the raw request body:

TypeScript
import { Webhook } from "svix";

const wh = new Webhook(process.env.SOONER_WEBHOOK_SECRET!);

export async function POST(req: Request) {
  const rawBody = await req.text();
  try {
    const event = wh.verify(rawBody, {
      "svix-id": req.headers.get("svix-id")!,
      "svix-timestamp": req.headers.get("svix-timestamp")!,
      "svix-signature": req.headers.get("svix-signature")!,
    }) as { type: string; data: Record<string, unknown> };

    if (event.type === "email.bounced") {
      // mark the address as undeliverable in your own system
    }
    return new Response("ok");
  } catch {
    return new Response("invalid signature", { status: 400 });
  }
}

See Verify signatures and Webhooks overview.