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
pnpm add resendCreate the client with a Sooner key and the Sooner base URL:
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
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
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
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:
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:
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 onceContacts, 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.
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:
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:
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.