Docs

Build in 5 minutes.

Pick your language, paste your API key, send your first email.

quickstart.ts
// 1. Install
$ npm install js2mail
// 2. Send
import { js2mail } from "js2mail"
const client = js2mail("pk_live_...")
await client.send({
  from: "[email protected]",
  subject: "Hello",
  text: "Welcome aboard.",
})
Status
API uptime · 30d 99.998%
p50 latency 234ms
p99 latency 812ms
Active connectors 4 providers
Authentication

API keys

Every request to /v1/* must carry an API key. Keys are scoped to a workspace + application pair and never expire unless revoked.

Key format
js2_live_<prefix:8>_<secret:32>

Prefix is stored plain (fast lookup). Secret is stored as SHA-256 — we can't recover it after creation. Copy it when generated; rotate via Dashboard → API Keys if lost.

Passing the key
X-Api-Key: js2_live_...

# or equivalently:
Authorization: Bearer js2_live_...

Both headers are accepted. X-Api-Key is preferred for clarity.

Where to get a key. Sign in → Dashboard → API Keys. Each workspace can have multiple Applications; each Application can have multiple keys. Revoke individual keys without interrupting others under the same Application.
API Reference

Send endpoints

Base URL: https://js2mail.dev

POST /v1/mail/send Queue an outbound email
Request body
{
  "from":          "[email protected]",          // optional — uses default mailbox
  "fromAccountId": "uuid",                  // optional override
  "to":            ["[email protected]"],
  "cc":            [],                       // optional
  "bcc":           [],                       // optional
  "subject":       "Hello",
  "html":          "<p>Hi</p>",             // html or text (or both)
  "text":          "Hi",
  "idempotencyKey": "order-123",            // optional, dedup per app
  "scheduledAt":   "2026-06-01T09:00:00Z"  // optional, ISO 8601
}
Response · 202 Accepted
{
  "id":     "uuid",
  "status": "Queued"
}
Error codes
401 — Missing or invalid API key
409 — Duplicate idempotency key
429 — Monthly send quota exceeded
GET /v1/mail/send/{id} Poll send status
Path param
id  UUID returned by POST
Response · 200
{
  "id":       "uuid",
  "status":   "Sent | Queued | Processing | Failed | Deferred",
  "sentAt":   "2026-06-01T09:00:12Z",  // if Sent
  "error":    "..."                     // if Failed
}
SDKs

Send from any language

No official SDK package yet — the API is a single POST. These copy-paste snippets are all you need.

send.sh cURL
curl -X POST https://js2mail.dev/v1/mail/send \ -H "X-Api-Key: js2_live_..." \ -H "Content-Type: application/json" \ -d '{ "to": ["[email protected]"], "subject": "Hello from cURL", "text": "It works." }'
send.ts TypeScript / Node
const res = await fetch("https://js2mail.dev/v1/mail/send", { method: "POST", headers: { "X-Api-Key": "js2_live_...", "Content-Type": "application/json", }, body: JSON.stringify({ to: ["[email protected]"], subject: "Hello", text: "It works.", }), }); const { id, status } = await res.json();
send.py Python
import requests res = requests.post( "https://js2mail.dev/v1/mail/send", headers={"X-Api-Key": "js2_live_..."}, json={ "to": ["[email protected]"], "subject": "Hello", "text": "It works.", }, ) data = res.json() # { "id": "...", "status": "Queued" }
send.cs .NET (C#)
using var http = new HttpClient(); http.DefaultRequestHeaders.Add("X-Api-Key", "js2_live_..."); var res = await http.PostAsJsonAsync( "https://js2mail.dev/v1/mail/send", new { to = new[] { "[email protected]" }, subject = "Hello", text = "It works." }); res.EnsureSuccessStatusCode();
Webhooks

Verifying webhook signatures

Every delivery carries an HMAC-SHA256 signature so you can confirm the request came from JS2Mail and wasn't tampered with in transit.

Signature header
X-JS2Mail-Signature: t=1748000000,v1=abc123...

t = Unix timestamp of delivery. v1 = HMAC-SHA256 hex of t + "." + body using the webhook secret.

Replay protection

Reject deliveries where t is more than 5 minutes old — this prevents replay attacks using captured valid signatures.

Each delivery also carries a stable X-JS2Mail-Delivery-Id UUID — store it and deduplicate on your side for idempotent processing.

verify.ts TypeScript
import { createHmac, timingSafeEqual } from "crypto"; function verify(secret: string, header: string, body: string) { const parts = Object.fromEntries( header.split(",").map(p => p.split("=")) ); const sig = createHmac("sha256", secret) .update(`${parts.t}.${body}`) .digest("hex"); return timingSafeEqual( Buffer.from(sig), Buffer.from(parts.v1) ); }
verify.py Python
import hmac, hashlib, secrets def verify(secret, header, body): parts = dict(p.split("=", 1) for p in header.split(",")) sig = hmac.new( secret.encode(), f"{parts['t']}.{body}".encode(), hashlib.sha256 ).hexdigest() return secrets.compare_digest(sig, parts["v1"])
Idempotency

Safe retries without duplicate sends

How it works

Pass "idempotencyKey": "your-key" in the request body. If a request with the same key has already been accepted for your Application, the API returns the original response — no second mail goes out.

The key is scoped to (workspace, application) — the same key value is independent across different applications.

Good key choices
// ✅ domain-specific, naturally unique
"order-confirmation-order_789"
"password-reset-user_42-2026-06-01"

// ✅ UUID generated before the HTTP call
"3f2504e0-4f89-11d3-9a0c-0305e82c3301"

// ❌ too generic — collides across users
"welcome-email"
Rate limits & backoff

Plan quotas and retry strategy

Plan Monthly sends On quota exceeded
Hobby 10,000 429 — upgrade plan
Team 250,000 429 — upgrade plan
Business 2,000,000 429 — upgrade plan
Scale Custom Contact sales
Exponential backoff

When you receive a 429, wait before retrying. Recommended strategy: start at 1s, double on each attempt, cap at 60s, jitter ±20%.

delay = min(1 * 2^attempt, 60) * (0.8 + random() * 0.4)
Delivery retries (server-side)

JS2Mail retries failed sends automatically — up to 6 attempts with exponential backoff (1m → 5m → 25m → 2h → 10h → 24h). Permanent provider errors (4xx) skip the retry queue. A mail.failed webhook fires on terminal failure.

Form snippets

Drop a form into any HTML page — your styling stays.

Three flavors of the exact same form. Only the CSS layer differs. Replace YOUR_PUBLIC_ID with the id you get from /Dashboard/Forms or the MCP createFormEndpoint tool.

contact.html Plain HTML
<form action="https://api.js2mail.dev/f/YOUR_PUBLIC_ID" method="POST"> <label>Name <input name="name" required></label> <label>Email <input name="email" type="email" required></label> <label>Message <textarea name="message" required></textarea></label> <input name="_honey" style="display:none" tabindex="-1" autocomplete="off"> <button type="submit">Send</button> </form>
Zero framework, default browser styling. Paste into anything.
contact.html Tailwind
<form action="https://api.js2mail.dev/f/YOUR_PUBLIC_ID" method="POST" class="max-w-md mx-auto space-y-4 p-6 bg-white rounded-xl shadow"> <input name="name" placeholder="Your name" class="w-full px-3 py-2 border rounded-lg" required> <input name="email" type="email" placeholder="Your email" class="w-full px-3 py-2 border rounded-lg" required> <textarea name="message" placeholder="Message" rows="4" class="w-full px-3 py-2 border rounded-lg" required></textarea> <input name="_honey" class="hidden" tabindex="-1" autocomplete="off"> <button type="submit" class="w-full py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"> Send </button> </form>
Same form, utility-class layout. AI-generated sites usually output this.
contact.html Bootstrap 5
<form action="https://api.js2mail.dev/f/YOUR_PUBLIC_ID" method="POST" class="needs-validation"> <div class="mb-3"> <label class="form-label">Name</label> <input name="name" class="form-control" required> </div> <div class="mb-3"> <label class="form-label">Email</label> <input name="email" type="email" class="form-control" required> </div> <div class="mb-3"> <label class="form-label">Message</label> <textarea name="message" class="form-control" rows="4" required></textarea> </div> <input name="_honey" class="d-none" tabindex="-1" autocomplete="off"> <button type="submit" class="btn btn-primary w-100">Send</button> </form>
Same form again, this time with Bootstrap utilities and form-control.
Schemaless fields. Any name="X" you put on an input becomes available as {{X}} inside your Subject and Body templates. The hidden _honey input traps bots — if it's filled, the request silently drops.
AI Platforms

Built with Lovable, v0, or Bolt?

Get a working contact form in under 30 seconds. No backend, no SDK, no build step — grab your form public ID from Dashboard → Forms and paste it into the prompt below for your platform.

01
Create a form endpoint
Sign up → Dashboard → Forms → New form. Give it a name and connect your mailbox.
02
Copy your public ID
The dashboard shows your endpoint URL: api.js2mail.dev/f/YOUR_ID. Copy the ID part.
03
Paste into your prompt
Use one of the platform prompts below. Submissions land in your inbox as real email.
Lovable prompt lovable.dev
Add a contact form to the page. Requirements: - form action="https://api.js2mail.dev/f/YOUR_PUBLIC_ID" method="POST" - Fields: name (text), email (email, required), message (textarea, required) - Hidden spam trap: <input type="text" name="_honey" style="display:none" /> - On submit: use fetch() to POST the FormData, show a success message on 200, show the error message from the JSON response on failure. - Keep all existing styles — only add the form, do not change anything else.
v0 prompt v0.dev
Create a contact form component. Use a plain HTML form (no server actions, no API routes): <form action="https://api.js2mail.dev/f/YOUR_PUBLIC_ID" method="POST"> Add fields for name, email (required), and message (textarea, required). Include <input type="text" name="_honey" className="hidden" /> for spam protection. Style with Tailwind. Show a success or error state after submission using fetch().
Bolt prompt bolt.new
Add a working contact form. Do NOT create any backend routes or API handlers. Use a plain HTML form that posts to an external endpoint: action="https://api.js2mail.dev/f/YOUR_PUBLIC_ID" method="POST" encType="multipart/form-data" Fields: full name, work email, message. Hidden: <input type="text" name="_honey" style={{{{display:'none'}}}} /> Handle the response with fetch and show inline success/error feedback.
contact.html Cursor / Windsurf / plain HTML
<form id="contact" action="https://api.js2mail.dev/f/YOUR_PUBLIC_ID" method="POST"> <input name="name" placeholder="Your name" required> <input name="email" type="email" required> <textarea name="message" required></textarea> <!-- spam trap: bots fill it, humans don't see it --> <input name="_honey" style="display:none"> <button type="submit">Send</button> </form>
Tip: want AJAX instead of a full-page redirect? Add Accept: application/json to your fetch headers and JS2Mail returns {"status":"queued"} instead of a redirect. Perfect for SPA and React-based projects where you control the UX after submission.
Connecting a mailbox

What we need from your side.

We never read your inbox — connecting a mailbox is only about authorizing JS2Mail to send on its behalf. Different providers ask for different setup steps. Pick the one that matches your mailbox below.

G
Gmail (personal or Workspace)
OAuth, one click. No DNS, no app passwords, no IP allowlist on your side.
  1. Click Connect → Continue with Google
    From /Dashboard/Connections pick the Gmail provider. You'll be bounced to Google's consent screen.
  2. Approve the gmail.send scope
    We only ask for permission to send mail. We never request inbox read access, message metadata, or anything else.
  3. Land back on Connections
    Once Google redirects you, your mailbox row appears with a green dot. You can start sending immediately.
Google Workspace admins can block third-party OAuth apps tenant-wide. If you see "your admin hasn't approved this app", ask them to allow JS2Mail under Workspace Admin → Security → API controls.
M
Microsoft 365 (Graph)
OAuth via Microsoft Graph. One click for personal accounts; corporate tenants may need an admin nod.
  1. Click Connect → Continue with Microsoft
    Picks up your Microsoft 365 or Outlook.com identity. Personal accounts grant consent in-place; corporate tenants follow their tenant's consent rules.
  2. Approve Mail.Send + offline_access
    Mail.Send lets JS2Mail submit messages through Graph. offline_access lets us refresh tokens silently — without it, you'd have to re-consent every hour.
  3. (Corporate tenants only) Admin consent
    If your Azure AD has "Allow users to consent to apps from non-verified publishers" disabled, the request goes to your tenant admin. They approve once for the whole org.
Some corporate tenants run a Conditional Access policy that blocks all external apps — your admin needs to whitelist JS2Mail's app id before any user can connect.
Microsoft 365 SMTP AUTH is disabled by default since 2022. If you'd rather use SMTP than Graph, your admin must enable SMTP AUTH per-mailbox in Exchange Admin Center — and then a per-account app password is still required. We recommend the Graph path.
S
Custom SMTP (Exchange / Postfix / hosting relay)
Bring host, port, username, password. Several real-world snags worth knowing about up front.
  1. Generate an app password (if the account has 2FA)
    Gmail SMTP, Outlook.com SMTP, iCloud SMTP, and many corporate Exchange tenants require an app-specific password when 2FA is on. Your normal account password won't authenticate — the SMTP server rejects it. Generate one from the provider's security settings and hand THAT to JS2Mail.
  2. Whitelist JS2Mail's outbound IP on your SMTP server
    If your SMTP relay restricts inbound connections by source IP (most corporate Exchange setups do), add JS2Mail's static egress IP to its allowed list. Without this we get connection-refused at TCP, and the connection won't even reach the login stage.
  3. Pick the right encryption mode
    Port 465 = SSL on connect. Port 587 = STARTTLS. Port 25 = anonymous relay (typically only allowed inside corporate networks). "Auto" lets MailKit pick by port; explicit is safer if you know the answer.
  4. Enter credentials in the SMTP connect modal
    Host (smtp.example.com), Port, Username, App-password / Password, Encryption. We store the password AES-256-GCM encrypted before it leaves the request thread.
Anonymous SMTP relays (no username/password) are supported — leave Username and Password blank and the adapter skips the AUTH step. The IP allowlist is then your only authentication layer, so keep it tight.
JS2Mail's outbound IP needs to be stable for your allowlist to keep working. Check our current egress under /Dashboard/Connections → Help → Outbound IP (coming soon). If we rotate IPs without notice, your sends start failing — file an issue and we'll roll back.
Deliverability

DNS records that decide spam vs inbox.

These live on your domain's DNS — JS2Mail can't set them for you, but the receiving mail server checks them before deciding where your message lands. Most Workspace / Microsoft 365 customers already have SPF + DKIM auto-configured by their provider; custom SMTP setups usually need manual records.

SPF
Tells receivers which IPs are authorized to send mail with your domain as the From: address. Without it, Gmail / Outlook flag the message as unauthenticated.
v=spf1 include:_spf.google.com ~all  (Gmail / Workspace)
v=spf1 include:spf.protection.outlook.com -all  (Microsoft 365)
For custom SMTP, your hosting provider gives you the include: directive.
DKIM
Cryptographic signature on every outbound message that proves the From: domain authorized the send. Receivers check this against your DNS-published public key.
Gmail Workspace + Microsoft 365 both auto-generate DKIM keys you publish at default._domainkey.<your-domain>. For custom SMTP, your provider documents the selector + record.
DMARC
Policy that says "if SPF or DKIM fails, do X" (none / quarantine / reject). Aligned DMARC pushes receivers to trust your mail and is increasingly required by Gmail + Yahoo for bulk senders.
v=DMARC1; p=none; rua=mailto:[email protected]  (start with p=none + rua to monitor, tighten to p=quarantine then p=reject after a week of clean reports)
Why JS2Mail doesn't set these for you. We use your mailbox (Gmail / Microsoft 365 / your SMTP server) to actually push the message — the receiving server's SPF / DKIM / DMARC checks happen against your domain, not ours. The tradeoff: you keep your domain reputation and threading; you also keep responsibility for the DNS records.
Security

What we store, how we protect it.

JS2Mail is built around minimal data touch. We store only what's necessary to deliver your mail, encrypted at rest, and never share it with third parties.

Credential encryption

OAuth refresh tokens and SMTP passwords are encrypted with AES-256-GCM before being written to the database. The plaintext never touches disk. Keys are rotated quarterly and stored separately from the encrypted data.

OAuth scopes — send only

Gmail: we request gmail.send only. Microsoft 365: Mail.Send + offline_access. We never request inbox read, contacts, calendar, or any other access beyond what's required to dispatch a message.

Log & data retention

Send logs (status, timestamp, recipient domain) are retained for 30 days on the free plan, 90 days on paid. Message body and attachments are never stored after dispatch — we pass them through to the provider and discard them immediately.

GDPR & data processing

JS2Mail acts as a data processor under GDPR — you remain the controller. Data is processed in the EU (Frankfurt). You can request full data export or account deletion at any time from Dashboard → Settings → Data. We respond within 72 hours.

Responsible disclosure. Found a security issue? Email [email protected] with a description and reproduction steps. We acknowledge within 24 hours and resolve critical issues within 72 hours. We do not have a bug bounty program yet — but we will credit you publicly if you'd like.