Email API

Send transactional email from your own domain with one HTTP call, and see every message's delivery in the dashboard.

Overview

The API sends through WS Mail's own mail servers, signed with DKIM for your domain. Requests and responses follow the same shape as Resend, so most integrations move over by changing the base URL and the key.

New here? The Email API overview explains plans and limits, and there are comparisons with Resend, Postmark and SendGrid.

API keys

Live keys, scoped to sending or full access.

Your domain

Send from any address on a verified domain.

Delivery status

Per-recipient status and an event timeline.

Built-in safety

Limits, suppression and checks that protect your reputation.

Base URL: https://api.wsmailpro.com

Quickstart

  1. Create a free account at wsmailpro.com and open the developer dashboard.
  2. Under API keys, create a key. It is shown once, so copy it somewhere safe.
  3. Send your first email from onboarding@wsmailpro.com to your own account's email address. This works before you have set up any DNS:
curl
curl -X POST https://api.wsmailpro.com/v1/emails \
  -H "Authorization: Bearer ws_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <onboarding@wsmailpro.com>",
    "to": "you@yourcompany.com",
    "subject": "Hello",
    "html": "<p>It works</p>"
  }'

To send to anyone, add your domain under Domains, publish the DNS records shown, and press Verify. From then on you can send from any address on that domain, within your plan's limits:

Node.js (fetch)
const res = await fetch('https://api.wsmailpro.com/v1/emails', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.WS_MAIL_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': `order-${order.id}`,
  },
  body: JSON.stringify({
    from: 'Acme <orders@yourdomain.com>',
    to: [customer.email],
    subject: `Order ${order.id} confirmed`,
    html: renderOrderEmail(order),
  }),
})
const data = await res.json()
if (!res.ok) throw new Error(`${data.name}: ${data.message}`)
console.log('queued', data.id)
Python (requests)
import os, requests

r = requests.post(
    "https://api.wsmailpro.com/v1/emails",
    headers={"Authorization": f"Bearer {os.environ['WS_MAIL_API_KEY']}"},
    json={
        "from": "Acme <hello@yourdomain.com>",
        "to": ["someone@example.com"],
        "subject": "Hello",
        "text": "It works",
    },
    timeout=30,
)
r.raise_for_status()
print(r.json()["id"])

Authentication

Send your key as a bearer token: Authorization: Bearer ws_live_…. Every key starts with ws_live_ and sends real mail. A key is shown once when it is created; only a hash is stored.

  • Sending keys can send, read emails and list domains.
  • Full access keys can also manage domains and keys.
  • A key can be restricted to one domain.
  • Keep keys on your server. The API does not accept keys from browsers.

Send an email

POST /v1/emails accepts:

fromstring"Name <address>" or "address" on a verified domain. Required.
to, cc, bccstring or string[]Up to 50 recipients in total. to is required.
subjectstringUp to 998 characters.
html, textstringAt least one. A text part is generated from html when omitted.
reply_tostring or string[]Reply-To addresses.
headersobjectOnly X-* headers and List-Unsubscribe / List-Unsubscribe-Post.
attachmentsarrayUp to 10. Each has filename plus content (base64, 4 MB total) or path (https URL, 10 MB each).
tagsarrayUp to 20 { name, value } pairs, returned with the email.

The response is { "id": "…" } once the message is queued on our mail server. Recipients who previously bounced or complained are dropped and listed in asuppressed array. Add scheduled_at (ISO 8601, up to 30 days ahead) to send later; the response then also carries scheduled_at.

Custom headers may be X-* (except the reserved X-WS-*), List-Unsubscribe, and In-Reply-To / References (so a reply threads under the message it answers). For an inline image, give the attachment a content_id and use <img src="cid:<content_id>"> in the html.

Delivery status

GET /v1/emails/{id} returns the message with a recipients list (each with queued, sent, delivered, delivery_delayed, bounced, complained or failed) and an events timeline (email.sent, email.delivered, email.delivery_delayed, email.bounced, email.failed). The message's own status is derived from its recipients. Subscribe to the same events with webhooks.

Webhooks

Add an endpoint in the dashboard or with POST /v1/webhooks. We POST each event to it as JSON: { "type", "created_at", "data" }. Events: email.sent, email.delivered, email.delivery_delayed, email.bounced, email.failed, email.complained, email.scheduled, email.canceled, email.held, email.unsubscribed, email.received and domain.verified.

Every request is signed per the Standard Webhooks spec, the same scheme Resend and svix use, so their verification libraries work unchanged. Headers: webhook-id, webhook-timestamp, webhook-signature (also sent as svix-id, svix-timestamp, svix-signature).

Verify in Node.js (use the raw request body)
import crypto from 'node:crypto'

export function verifyWsMailWebhook(secret, headers, rawBody) {
  const id = headers['webhook-id']
  const ts = Number(headers['webhook-timestamp'])
  if (!id || Math.abs(Date.now() / 1000 - ts) > 300) return false
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
  const expected = Buffer.from(crypto.createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest('base64'))
  return headers['webhook-signature'].split(' ').some((s) => {
    const given = Buffer.from(s.split(',')[1] ?? '')
    return given.length === expected.length && crypto.timingSafeEqual(given, expected)
  })
}

Respond with any 2xx within 10 seconds. Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours and 24 hours, then marked failed; you can replay any delivery from the dashboard. Redirects are not followed. An endpoint that has failed every delivery for 7 days is disabled and your admins are told. Endpoints must be public HTTPS URLs.

Receiving

Turn receiving on for a verified domain in the dashboard or with PATCH /v1/domains/{id} { "receiving_enabled": true }. Mail sent to any address on that domain that has no WS Mail mailbox is then accepted for you; addresses that are real mailboxes keep receiving their own mail as before. If the domain already forwards unknown addresses somewhere, the call returns 409 invalid_state naming it; repeat it with "replace_catch_all": true to take them over, and turning receiving off restores the old setting.

Each message arrives within about a minute as an email.received webhook carrying its id, sender, recipients, subject and attachment list. Fetch the full message (text, HTML, headers and SPF/DKIM/DMARC results) with GET /v1/emails/receiving/{id}, attachments by index, or the original .eml. Received mail is kept for your retention setting (at least one day) and then deleted.

Reading received mail needs a key with full access; sending-only keys get 403. Each message carries spam: { flagged, score } from our filter, and flagged mail does not count toward your usage. Messages over 10 MB are kept as the original .eml only, and messages over 40 MB are recorded (sender, recipients, subject) but not stored.

Scheduling and batches

Send later with scheduled_at. Until it goes out you can move it with PATCH /v1/emails/{id} or stop it with POST /v1/emails/{id}/cancel; both return 409 invalid_state once it is sending. Every limit and check runs at send time, so a message whose domain stopped being verified in the meantime fails with a clear reason.

POST /v1/emails/batch takes an array of up to 100 emails, validated all-or-nothing, and returns their ids in order. Batches cannot contain scheduled_at. An Idempotency-Key covers the whole batch.

Broadcast and unsubscribe

Newsletters and other mail people subscribe to go on the broadcast stream: send with "stream": "broadcast". It opens once your account is established, is limited to half your hourly cap, and keeps bulk mail away from the addresses that carry your one-to-one mail.

Each broadcast email goes to one person (one address in to, no cc or bcc); send many at once with POST /v1/emails/batch. Put {{unsubscribe_url}} in the html or text where the unsubscribe link belongs. We replace it with that person's own link and add the List-Unsubscribe and List-Unsubscribe-Post headers, so Gmail, Yahoo and Outlook show their one-click unsubscribe button.

When someone unsubscribes you receive email.unsubscribed, and later broadcast sends to them fail with recipient_unsubscribed. Role addresses (such as abuse@ and noreply@) and throwaway-inbox domains are refused on this stream. Complaints reported through the mailbox providers' feedback loops arrive as email.complained and suppress the address.

Idempotency

Send an Idempotency-Key header (up to 256 characters) to make retries safe. For 24 hours the same key and body return the original response withIdempotent-Replayed: true. The same key with a different body returns 409. While the first request is still running, a duplicate gets 409concurrent_idempotent_requests. Rate-limit and validation errors are not stored, so a retry after fixing them runs normally.

Sandbox and plans

Until your organization verifies a domain it is in sandbox: you can send only from onboarding@wsmailpro.com, and only to your own account's email address (the organization owner, the person who created the key, or the signed-in user).sink@blackhole.wsmailpro.com is accepted at any time: the message is accepted and tracked, then discarded, which makes it useful in CI.

As soon as a domain is verified you can send from any address on it to anyone, within your plan's limits. There is no approval step.

The email API has its own plans, separate from your mailbox plan:

FreeDeveloper
Price$0$10 a month per organization, billed separately from any mail plan; cancel any time
Emails a month3,00050,000
Daily limit100 a dayNone
Sending domains110
Webhook endpoints15
AI requests (writing assistant)None150 a month

Upgrade in the developer dashboard under Settings. Adding a domain or webhook endpoint beyond your plan returns plan_limit_reached.

Sending status and limits

StatusWhat you can doHow you move on
SandboxNo verified domain yet: from onboarding@wsmailpro.com to your own address and the sink; 20 an hour, 50 a day, 5 recipients per message.Verify a domain.
ProbationAnyone, from your verified domains; at most 200 an hour and 50 recipients per message, plus your plan's caps.14 days and 200+ sends with hard bounces under 2%.
EstablishedYour plan's caps only.Stays while bounces and complaints stay low.
Under review100 an hour, 500 a day, 25 per message.Recovers after 7 clean days; otherwise pauses.
PausedNo sending.Appeal through Support in the admin console.

Review starts at a 5% hard-bounce rate or 0.1% complaint rate, and sending pauses at 10% or 0.5%. Blocklist and policy rejections do not count as bounces. Monthly and daily caps come from your API plan (see Sandbox and plans). Each key may make 10 requests a second.

Held for review. A send that looks very unlike your normal traffic is accepted and stored with status held instead of going out: a burst far above your usual hourly volume, or a key created minutes ago that immediately sends at your maximum rate or to many different domains. You get an email.held event and a message in the admin console; our team reviews held mail within one business day, and it is sent the moment it is released. Held mail that is not released within 48 hours is not sent.

Waiting for capacity. While we warm up new sending addresses, or pause one to protect deliverability, a message may wait with status scheduled and a defer_reason. It goes out automatically; mail still waiting after 48 hours fails.

Domains

Add a domain in the dashboard or with POST /v1/domains, publish the MX, SPF, DKIM and DMARC records returned, then call POST /v1/domains/{id}/verify. Once verified you can send from any address on it. Free-mail and WS Mail platform domains cannot be registered.

Suppressions and events

Addresses that hard-bounce are suppressed automatically and dropped from later sends. Manage the list with GET/POST /v1/suppressions and DELETE /v1/suppressions/{email}. Everything that happens to your mail is also available as a feed at GET /v1/events.

Errors

Every error uses the same envelope:

{ "statusCode": 422, "name": "validation_error", "message": "html: html or text is required" }
missing_api_key401No Authorization header.
invalid_api_key401Unknown, malformed or revoked key.
restricted_api_key403The key lacks the scope or domain for this action.
sending_disabled403Sending is paused for your organization.
domain_not_verified403The from domain is not one of your verified domains.
sandbox_restricted403Before a domain is verified, a recipient is not your own address (or a broadcast was sent from onboarding@wsmailpro.com).
plan_limit_reached403You have reached your plan’s limit (domains or webhook endpoints). Upgrade to Developer.
not_found404No such resource in your organization.
invalid_idempotency_key409The key was reused with a different body.
concurrent_idempotent_requests409A request with this key is still running.
invalid_state409The email is no longer scheduled, so it cannot be canceled or moved.
message_too_large413A raw message is over 3 MB.
validation_error422The request body failed validation; the message says which field.
recipient_suppressed422Every recipient previously bounced or complained.
recipient_unsubscribed422The broadcast recipient unsubscribed from your mail.
recipient_undeliverable422A recipient domain has no mail server.
rate_limit_exceeded429Too many requests, or an hourly sending cap.
daily_quota_exceeded429A daily sending cap was reached.
quota_exceeded429Your plan’s monthly quota is used up.
application_error500Our side failed, or the mail server refused the message.

Endpoint reference

The full machine-readable description is at https://api.wsmailpro.com/v1/openapi.json (OpenAPI 3.1): import it into Postman or Insomnia, or generate a client for any language.

GET/v1API name, version and docs link. No key needed.
GET/v1/openapi.jsonThis OpenAPI document. No key needed.
GET/v1/ai-promptAn integration brief to paste into an AI coding assistant. No key needed.
POST/v1/emailsSend an email (or schedule it with scheduled_at).
POST/v1/emails/batchSend up to 100 emails in one request, all or nothing.
POST/v1/emails/rawSend a complete MIME message, 3 MB max (what the SMTP gateway uses).
GET/v1/emailsList sent emails, newest first.
GET/v1/emails/{id}One email with its recipients and event timeline.
PATCH/v1/emails/{id}Move a scheduled email to a new time.
POST/v1/emails/{id}/cancelCancel a scheduled email.
GET/v1/emails/receivingList received emails, newest first. Full-access key.
GET/v1/emails/receiving/{id}A received email with text, HTML and attachment details. Full-access key.
GET/v1/emails/receiving/{id}/attachments/{index}Download one attachment. Full-access key.
GET/v1/emails/receiving/{id}/rawDownload the original message (.eml). Full-access key.
GET/v1/domainsList your sending domains.
POST/v1/domainsAdd a domain and get the DNS records to publish. Full-access key.
GET/v1/domains/{id}One domain with its DNS records and status.
POST/v1/domains/{id}/verifyCheck DNS and mark the domain verified. Full-access key.
PATCH/v1/domains/{id}Turn receiving on or off. Full-access key.
POST/v1/api-keysCreate an API key. The secret is shown once. Full-access key.
GET/v1/api-keysList API keys (never their secrets). Full-access key.
DELETE/v1/api-keys/{id}Revoke a key. Mail it scheduled is canceled. Full-access key.
POST/v1/api-keys/{id}/rotateIssue a replacement; the old key works for 24 more hours. Full-access key.
GET/v1/webhooksList webhook endpoints. Full-access key.
POST/v1/webhooksAdd an endpoint. The signing secret is shown once. Full-access key.
GET/v1/webhooks/{id}One endpoint. Full-access key.
PATCH/v1/webhooks/{id}Change the URL, events, description, or pause it. Full-access key.
DELETE/v1/webhooks/{id}Delete an endpoint. Full-access key.
GET/v1/webhooks/{id}/deliveriesRecent delivery attempts to this endpoint. Full-access key.
POST/v1/webhooks/{id}/deliveries/{deliveryId}/replayDeliver one event again. Full-access key.
POST/v1/webhooks/{id}/testSend a signed webhook.test event to the endpoint now. Full-access key.
GET/v1/suppressionsAddresses that will not be sent to.
POST/v1/suppressionsSuppress an address yourself. Full-access key.
DELETE/v1/suppressions/{email}Remove an address from the list. Full-access key.
GET/v1/eventsThe event log webhooks are made from.
GET/v1/logsYour recent API requests. Full-access key.
GET/v1/statsDaily sent, delivered, bounced and complaint counts.
GET/v1/accountAPI plan and usage, sending status, limits and reputation.
PATCH/v1/accountChange body retention (or add a note about what you send). Full-access key.

Node SDK

@wsmailpro/sdk is a typed client for Node 18+, Deno, Bun and edge runtimes with no dependencies. Every method resolves to { data, error } and does not throw for API errors, the same contract as Resend's SDK.

The SDK is being published to npm. Until it is listed at npmjs.com/package/@wsmailpro/sdk, call the REST API directly as in the examples above; do not install a package with this name from anywhere else.

Install and send
npm install @wsmailpro/sdk

import { WSMail } from '@wsmailpro/sdk'

const ws = new WSMail(process.env.WSMAIL_API_KEY)
const { data, error } = await ws.emails.send(
  { from: 'Acme <orders@acme.com>', to: 'jane@example.com', subject: 'Your receipt', html: '<p>Thanks!</p>' },
  { idempotencyKey: 'receipt-1234' },
)
if (error) console.error(error.name, error.message)
else console.log('queued', data.id)
Verify a webhook
import { verifyWebhook } from '@wsmailpro/sdk'

// rawBody: the request body exactly as received (not re-serialised JSON)
const event = await verifyWebhook(req.headers, rawBody, process.env.WSMAIL_WEBHOOK_SECRET)
if (event.type === 'email.bounced') markUndeliverable(event.data.recipient)

Also available: emails.batch, emails.sendRaw, emails.get/list/update/cancel, receiving.list/get/attachment/raw, domains.*, apiKeys.*, webhooks.*, suppressions.* and events.list.

SMTP and raw MIME

Apps that can only send over SMTP (WordPress, many CMSs and older frameworks) can use the SMTP gateway. Every message still goes through the same checks, limits and tracking as an API send.

Hostsmtp.wsmailpro.com
Port2587 with STARTTLS, or 2465 with TLS
Usernameanything, for example wsmail
Passwordan API key (ws_live_…)
Size3 MB per message, attachments included

A wrong key is refused at login. Rate limits and our own outages answer with a temporary (4xx) code, so your mail server retries; policy and validation refusals answer with a permanent (5xx) code and the reason. Blind copies travel only in the SMTP envelope, never in the headers. Inline images (cid:) stay inline, and In-Reply-To and References are kept so replies thread; the Message-ID is always our own. At most 20 connections at once from one address.

To send a message you already built as MIME from code, use POST /v1/emails/raw with { "raw": "<base64>", "envelope": { "to": [...] } }. The envelope decides who receives it; addresses in it that are not in To or Cc become blind copies.

AI coding assistants

https://api.wsmailpro.com/v1/ai-prompt returns a complete integration brief (endpoints, idempotency, webhook verification, error handling and the rules that keep keys safe). Paste it into Claude, Cursor or Copilot, or use Copy AI prompt on the dashboard's Overview. Add ?app_name= to put your app's name in it. It contains no secrets.

Coming from Resend

Field names, the error envelope, the idempotency header, batch sends, scheduling and webhook signatures match Resend's. Moving over takes four steps.

  1. Add and verify your domain in the dashboard (Domains). Publish the DNS records it shows.
  2. Create a key (API keys) and replace RESEND_API_KEY with it.
  3. Change the base URL from https://api.resend.com to https://api.wsmailpro.com, or swap the SDK import.
  4. Recreate your webhook endpoints and use the new signing secrets; the signature check stays the same.
SDK swap
- import { Resend } from 'resend'
- const resend = new Resend(process.env.RESEND_API_KEY)
- await resend.emails.send({ from, to, subject, html })
+ import { WSMail } from '@wsmailpro/sdk'
+ const ws = new WSMail(process.env.WSMAIL_API_KEY)
+ await ws.emails.send({ from, to, subject, html })
ResendWS Mail
from, to, cc, bcc, reply_to, subject, html, textSame names and shapes.
attachments[].content / path / filenameSame; content_type instead of contentType.
tags, headersSame; custom headers must start with X- (or be List-Unsubscribe).
scheduled_atISO 8601 up to 30 days ahead; natural-language times are not accepted.
Idempotency-KeySame header, kept 24 hours.
POST /emails/batchSame, up to 100 emails, all or nothing.
svix-* webhook headersSent too, alongside webhook-*.
react (React Email)Render to HTML first (render() from @react-email/render) and send it as html.

Not available: Audiences, Contacts and Broadcasts (campaign management), and templates stored on our side. For newsletters, send each subscriber an email with "stream": "broadcast" through the batch endpoint; unsubscribes are handled for you. Newly verified senders also start on probation, with an hourly cap that lifts after clean sending: see Sending status and limits.

Ready to send?

Create a free account, then open the developer dashboard.