TypeScript Quickstart
Send your first email from a Node.js or TypeScript project in under 5 minutes. Two SDKs available — pick the one that matches your goals.
1. Pick an SDK
You have two ways to talk to Mailstorm from JavaScript:
| Package | When to use |
|---|---|
@mailstorm/resend |
You already wrote code against Resend, or you want to. Same Resend class, same methods, same response envelopes — change one import. |
@mailstorm/sdk |
You're starting from scratch and want our native API surface (no Resend ergonomics, slightly leaner types). |
This guide uses @mailstorm/resend because it's what most developers will pick. Behavior is identical between the two — only the import and class name change.
2. Install
npm install @mailstorm/resend
# or pnpm / yarn / bun3. Get an API key
Sign up at mailstorm.dev. After verifying your email, generate a key at /api-keys. Two types:
ms_test_*— sandbox. Captures the request, returns a realid, doesn't actually send. Perfect for local dev and CI.ms_live_*— production. Actually sends. Requires a verified FROM domain (see step 5).
Don't commit keys. Put the key in .env.local (gitignored) or your secret manager. Mailstorm will never email you the key after creation — if you lose it, generate a new one and rotate.
4. Send your first email
import { Resend } from "@mailstorm/resend"; const resend = new Resend(process.env.MAILSTORM_API_KEY!); const { data, error } = await resend.emails.send({ from: "onboarding@yourdomain.com", to: "user@example.com", subject: "Hello", html: "<p>It works.</p>", }); if (error) console.error(error); else console.log("sent:", data?.id);
With a ms_test_* key this returns immediately with an id; the row appears in /logs as captured. With a ms_live_* key plus a verified domain, it actually sends.
5. Verify your sending domain
Live sends require ownership of the FROM domain. In /domains, click Add domain and paste the DKIM, SPF, and DMARC records into your DNS provider. Verification polls automatically — typically green within 5 minutes after DNS propagation.
Until verified, live sends from that domain return:
{
"data": null,
"error": {
"name": "api_error",
"message": "domain yourdomain.com is registered but not verified...",
"statusCode": 403
}
}6. Send to multiple recipients
await resend.emails.send({ from: "alerts@yourdomain.com", to: ["a@example.com", "b@example.com"], cc: ["c@example.com"], reply_to: "support@yourdomain.com", subject: "System update", html: "<p>...</p>", });
v0.1 limit: The platform records the first recipient only. Multi-recipient fan-out is a follow-up; if you need it now, file an issue and we'll bump priority.
7. Send a batch
await resend.batch.send([ { from: "alerts@yourdomain.com", to: "a@example.com", subject: "x", html: "<p>1</p>" }, { from: "alerts@yourdomain.com", to: "b@example.com", subject: "y", html: "<p>2</p>" }, ]); // → { data: { data: [{id:"..."},{id:"..."}] }, error: null }
Up to 100 emails per batch. The whole batch is rejected with 429 if it would exceed your daily cap.
8. Handle errors
Every method returns { data, error }. Network errors and API errors both populate error — never check data without checking error first.
const { data, error } = await resend.emails.send({...}); if (error) { if (error.statusCode === 429) { // daily cap or rate limit } else if (error.statusCode === 403) { // unverified domain } throw new Error(error.message); }
9. Idempotency
Pass an Idempotency-Key header to make retries safe. Same key + same body within 24h returns the original response without sending twice.
await resend.emails.send( { from: "...", to: "...", subject: "x", html: "<p>...</p>" }, { idempotencyKey: "order-12345-confirmation" } );
What's next
- Webhooks — listen for delivered, bounced, opened, clicked, inbound
- Migrate from Resend — full compatibility matrix and the codemod
- API Reference — every endpoint with try-it-out