Scribase Mail docs

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.

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

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, templatesnot yet

The whole walk-through in Migrate from Resend.