TypeScript SDK
The @scribase/mail npm package: zero dependencies, ESM and CommonJS, Node 18+, Bun, Deno and Cloudflare Workers. Send email, manage domains, keys and webhooks, verify webhook signatures, and run magic-link and OTP login with JWT verification.
Not published yet available soon: @scribase/mail is not on npm today. Until it is, use the resend package with a new base URL or plain fetch against the API reference. The page below describes the package as it will ship.
@scribase/mail is a thin, typed client over the HTTP API. It has no runtime dependencies, uses fetch and WebCrypto, ships ESM and CommonJS with type definitions, and returns { data, error } from every call so it drops in where the Resend SDK was.
npm install @scribase/mail
# or: bun add @scribase/mail ยท pnpm add @scribase/mail
Create a client
import { ScribaseMail } from "@scribase/mail";
const mail = new ScribaseMail(process.env.SCRIBASE_MAIL_API_KEY, {
baseUrl: "https://mail-api.scribase.com", // default; or SCRIBASE_MAIL_BASE_URL
});
Options: baseUrl, adminToken (for auth.admin.*), fetch (custom implementation), userAgent. The API key also reads from SCRIBASE_MAIL_API_KEY when omitted.
Results and errors
Nothing throws on HTTP errors. Check error first:
const { data, error } = await mail.emails.send({ ... });
if (error) {
// error = { statusCode: 422, name: "validation_error", message: "..." }
return;
}
data.id;
Error names are the API's: missing_api_key, validation_error, missing_required_field, restricted_api_key, rate_limit_exceeded, not_found, invalid_idempotent_request, application_error; a failed connection yields network_error with statusCode: 0. The auth endpoints' { error, message } bodies are normalized into the same shape.
Emails
// send (camelCase or snake_case field names both work)
const { data } = await mail.emails.send(
{
from: "Acme <hello@acme.com>",
to: ["jane@example.com"],
cc: "ops@acme.com",
replyTo: "support@acme.com",
subject: "Your receipt",
html: "<p>Paid.</p>",
text: "Paid.",
headers: { "X-Entity-Ref-ID": "inv_123" },
tags: [{ name: "category", value: "receipt" }],
attachments: [{ filename: "receipt.pdf", content: base64Pdf, contentType: "application/pdf" }],
scheduledAt: "2026-11-13T10:00:00Z", // ISO 8601, up to 30 days ahead
},
{ idempotencyKey: "inv_123-receipt" },
);
await mail.emails.batch([{ ... }, { ... }]); // up to 100, no attachments
await mail.batch.send([{ ... }]); // Resend-style alias
await mail.emails.get(id); // full email incl. last_event
await mail.emails.list({ limit: 20, after: id }); // full-access keys only
await mail.emails.update({ id, scheduledAt: "2026-11-13T11:00:00Z" });
await mail.emails.cancel(id);
await mail.emails.reportEvent(id, { type: "bounced", recipient: "x@y.com", code: 550 });
Domains
const { data: domain } = await mail.domains.create({ name: "acme.com", returnPath: "send" });
domain.records; // [{ record: "DKIM", type: "TXT", name: "scribase._domainkey", value: "v=DKIM1; ..." }, ...]
await mail.domains.verify(domain.id); // real DNS lookups
await mail.domains.get(domain.id); // per-record status, overall status
await mail.domains.list();
await mail.domains.update(domain.id, { openTracking: true, clickTracking: true });
await mail.domains.remove(domain.id);
API keys
const { data: key } = await mail.apiKeys.create({
name: "app-server",
permission: "sending_access",
domainId: domain.id,
});
key.token; // "sk_..." shown once
await mail.apiKeys.list();
await mail.apiKeys.remove(key.id);
Webhooks
const { data: hook } = await mail.webhooks.create({
endpoint: "https://acme.com/hooks/mail",
events: ["email.delivered", "email.bounced", "email.complained"],
});
hook.signing_secret; // "whsec_..."
Verify a delivery with the raw request body and headers. The check is constant-time (crypto.subtle.verify) and rejects timestamps older than 5 minutes by default:
import { WebhookVerificationError } from "@scribase/mail";
export async function POST(req: Request) {
const raw = await req.text();
try {
const event = await mail.webhooks.verify(raw, req.headers, process.env.MAIL_WEBHOOK_SECRET!);
if (event.type === "email.bounced") suppress(event.data.to);
return new Response("{}", { status: 200 });
} catch (e) {
if (e instanceof WebhookVerificationError) return new Response("bad signature", { status: 400 });
throw e;
}
}
headers may be a Headers object, a Node IncomingHttpHeaders record, or any { get(name) }. Both webhook-* and svix-* header names are accepted. A standalone verifyWebhookSignature(payload, headers, secret) export does the same without a client. Details: Webhooks.
Auth: magic links, OTP, JWT
The auth endpoints need no API key; they are addressed by project slug.
const auth = mail.auth;
await auth.sendMagicLink({ email, redirectTo: "https://acme.com/welcome", project: "acme" });
await auth.sendOtp({ email, project: "acme" });
const { data: session } = await auth.verifyOtp({ email, project: "acme", code });
// session = { access_token, token_type: "Bearer", expires_in, purpose, user: { id, email, email_verified_at } }
await auth.sendEmailVerification({ email, redirectTo, project });
await auth.sendPasswordReset({ email, redirectTo, project });
const { data } = await auth.verifyMagicLink(tokenFromQuery); // JSON instead of the redirect
Verify the session JWT yourself (WebCrypto Ed25519 against the JWKS, cached 5 minutes), or ask the server:
import { verifyJwt } from "@scribase/mail";
const claims = await auth.verifyJwt(token, { audience: "acme" });
// or without a client:
const claims2 = await verifyJwt(token, {
jwksUrl: "https://mail-api.scribase.com/.well-known/jwks.json",
audience: "acme",
issuer: "scribase-mail-auth",
});
// claims = { iss, sub, aud, email, email_verified, project, purpose, iat, exp, jti }
await auth.verifyToken({ token, audience: "acme" }); // server-side, { valid, claims, expires_in }
verifyJwt throws JwtVerificationError on a bad signature, wrong kid, expiry, audience or issuer mismatch. decodeJwt(token) reads claims without verifying; use it for logging only.
Project management (needs adminToken):
const admin = new ScribaseMail(undefined, { baseUrl, adminToken: process.env.AUTH_ADMIN_TOKEN }).auth.admin;
await admin.createProject({ slug: "acme", app_name: "Acme", from_address: "login@acme.com", allowed_redirect_origins: ["https://acme.com"] });
await admin.listProjects();
await admin.updateProject(id, { allowed_redirect_origins: ["https://acme.com", "https://app.acme.com"] });
Flow details and framework examples: Magic links and OTP.
Runtime notes
- Node 18: WebCrypto is loaded from
node:cryptowhenglobalThis.cryptois missing; nothing to configure. - Cloudflare Workers / Deno / Bun: use the ESM build;
fetchandcrypto.subtleare native. - Edge functions with a bound fetch: pass it in
new ScribaseMail(key, { fetch }). - Bundle size: about 19 KB unminified, tree-shakeable,
sideEffects: false.
Coming from resend
resend | @scribase/mail |
|---|---|
new Resend(key) | new ScribaseMail(key) |
resend.emails.send(p, { idempotencyKey }) | same |
resend.batch.send([...]) | same, or mail.emails.batch([...]) |
resend.emails.update({ id, scheduledAt }) | same |
resend.domains.remove(id) | same |
resend.webhooks.verify({ payload, headers: { id, timestamp, signature }, webhookSecret }) | await mail.webhooks.verify(payload, headers, secret) |
resend.contacts, audiences, broadcasts, templates | not yet |
The whole walk-through in Migrate from Resend.