Docs

Build in 5 minutes.

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

quickstart.ts
// No SDK to install — the API is one POST.
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: ["jane@user.io"],
    subject: "Hello",
    bodyText: "Welcome aboard.",
  }),
})
// 202 → { id, status: "Queued" }
const { id, status } = await res.json()
At a glance
Providers Gmail · M365 · SMTP
Send API POST /v1/mail/send
Delivery Async queue + retries
Credentials AES-256-GCM at rest
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
{
  "fromAccountId": "uuid",                  // optional — omit to use default mailbox
  "to":            ["user@example.com"],
  "cc":            [],                       // optional
  "bcc":           [],                       // optional
  "subject":       "Hello",
  "bodyHtml":      "<p>Hi</p>",             // bodyHtml or bodyText — at least one required
  "bodyText":      "Hi",
  "idempotencyKey": "order-123",            // optional, dedup per app
  "attachments":   []                        // optional
}
Response · 202 Accepted
{
  "id":     "uuid",
  "status": "Queued"
}
Error codes
400 — Invalid body (missing bodyHtml/bodyText, bad address, no mailbox)
401 — Missing or invalid API key
404 fromAccountId not found in this workspace
429 — Rate limit (100/min), monthly quota, or blocked subscription

A repeated idempotencyKey is not an error — the original 202 response is replayed and no second mail goes out.

GET /v1/mail/{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": ["user@example.com"], "subject": "Hello from cURL", "bodyText": "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: ["user@example.com"], subject: "Hello", bodyText: "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": ["user@example.com"], "subject": "Hello", "bodyText": "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[] { "user@example.com" }, subject = "Hello", bodyText = "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...
X-JS2Mail-Delivery-Id: 3f2504e0-4f89-11d3-9a0c-...
X-JS2Mail-Event-Type:  MailDelivered

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

Note the header spells the event in PascalCase (MailDelivered) while the JSON body's type field uses the dotted wire name (mail.delivered). Switch on the body field.

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"); const a = Buffer.from(sig); const b = Buffer.from(parts.v1 ?? ""); // timingSafeEqual throws on length mismatch — check first. if (a.length !== b.length) return false; return timingSafeEqual(a, b); }
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

Two independent limits return 429: a per-minute request rate limit, and your plan's monthly send quota.

Request rate limit

POST /v1/mail/send accepts 100 requests per minute per API key, measured as a sliding window. Over the limit the request is rejected with 429 before it reaches the queue — nothing is enqueued, so retrying the same call is safe.

This limit is separate from the monthly quota below. Both surface as 429, but a rate-limit rejection clears within a minute while a quota rejection lasts until the next billing period. A blocked subscription (unpaid, canceled, or expired trial) also returns 429 with an explanatory message — check the response body to tell them apart.

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 3 attempts with exponential backoff (30s → 60s). Auth-class provider errors (expired or revoked mailbox token) skip the retry queue and fail immediately, so the “reconnect this mailbox” signal surfaces right away. Each transient failure emits mail.deferred; 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 (the bot still gets a success response, so it never learns).

Submissions are capped at 5 per minute per IP (2 in test mode, where the origin allowlist is off) — over that the endpoint returns 429. Attachments are accepted on multipart/form-data submissions: up to 5 files, 5 MB each, 10 MB total.
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:dmarc@yourdomain.com  (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. The master key lives in the deployment environment, never in the database, and every encrypted value carries a version tag so keys can be rotated without re-encrypting by hand.

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, recipients, subject) are kept for the life of your workspace — deleting the workspace removes them. Message bodies are purged the moment a send succeeds: the body is stored on the queued row only so a retry can resend it, then dropped when the send reaches Sent. Failed and deferred sends keep their body so you can see what went wrong.

GDPR & data processing

JS2Mail acts as a data processor — you remain the controller. Data is processed and stored in Istanbul, Türkiye. If your compliance posture requires EU-only processing, talk to us before you integrate. You can delete your workspace and everything in it at any time from Dashboard → Settings → Danger zone; for a data export, email support@js2mail.dev.

Responsible disclosure. Found a security issue? Email security@js2mail.dev 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.