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
- Create a free account at wsmailpro.com and open the developer dashboard.
- Under API keys, create a key. It is shown once, so copy it somewhere safe.
- Send your first email from
onboarding@wsmailpro.comto your own account's email address. This works before you have set up any DNS:
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:
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)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:
| from | string | "Name <address>" or "address" on a verified domain. Required. |
| to, cc, bcc | string or string[] | Up to 50 recipients in total. to is required. |
| subject | string | Up to 998 characters. |
| html, text | string | At least one. A text part is generated from html when omitted. |
| reply_to | string or string[] | Reply-To addresses. |
| headers | object | Only X-* headers and List-Unsubscribe / List-Unsubscribe-Post. |
| attachments | array | Up to 10. Each has filename plus content (base64, 4 MB total) or path (https URL, 10 MB each). |
| tags | array | Up 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).
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:
| Free | Developer | |
|---|---|---|
| Price | $0 | $10 a month per organization, billed separately from any mail plan; cancel any time |
| Emails a month | 3,000 | 50,000 |
| Daily limit | 100 a day | None |
| Sending domains | 1 | 10 |
| Webhook endpoints | 1 | 5 |
| AI requests (writing assistant) | None | 150 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
| Status | What you can do | How you move on |
|---|---|---|
| Sandbox | No 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. |
| Probation | Anyone, 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%. |
| Established | Your plan's caps only. | Stays while bounces and complaints stay low. |
| Under review | 100 an hour, 500 a day, 25 per message. | Recovers after 7 clean days; otherwise pauses. |
| Paused | No 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_key | 401 | No Authorization header. |
| invalid_api_key | 401 | Unknown, malformed or revoked key. |
| restricted_api_key | 403 | The key lacks the scope or domain for this action. |
| sending_disabled | 403 | Sending is paused for your organization. |
| domain_not_verified | 403 | The from domain is not one of your verified domains. |
| sandbox_restricted | 403 | Before a domain is verified, a recipient is not your own address (or a broadcast was sent from onboarding@wsmailpro.com). |
| plan_limit_reached | 403 | You have reached your plan’s limit (domains or webhook endpoints). Upgrade to Developer. |
| not_found | 404 | No such resource in your organization. |
| invalid_idempotency_key | 409 | The key was reused with a different body. |
| concurrent_idempotent_requests | 409 | A request with this key is still running. |
| invalid_state | 409 | The email is no longer scheduled, so it cannot be canceled or moved. |
| message_too_large | 413 | A raw message is over 3 MB. |
| validation_error | 422 | The request body failed validation; the message says which field. |
| recipient_suppressed | 422 | Every recipient previously bounced or complained. |
| recipient_unsubscribed | 422 | The broadcast recipient unsubscribed from your mail. |
| recipient_undeliverable | 422 | A recipient domain has no mail server. |
| rate_limit_exceeded | 429 | Too many requests, or an hourly sending cap. |
| daily_quota_exceeded | 429 | A daily sending cap was reached. |
| quota_exceeded | 429 | Your plan’s monthly quota is used up. |
| application_error | 500 | Our 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 | /v1 | API name, version and docs link. No key needed. |
| GET | /v1/openapi.json | This OpenAPI document. No key needed. |
| GET | /v1/ai-prompt | An integration brief to paste into an AI coding assistant. No key needed. |
| POST | /v1/emails | Send an email (or schedule it with scheduled_at). |
| POST | /v1/emails/batch | Send up to 100 emails in one request, all or nothing. |
| POST | /v1/emails/raw | Send a complete MIME message, 3 MB max (what the SMTP gateway uses). |
| GET | /v1/emails | List 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}/cancel | Cancel a scheduled email. |
| GET | /v1/emails/receiving | List 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}/raw | Download the original message (.eml). Full-access key. |
| GET | /v1/domains | List your sending domains. |
| POST | /v1/domains | Add 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}/verify | Check DNS and mark the domain verified. Full-access key. |
| PATCH | /v1/domains/{id} | Turn receiving on or off. Full-access key. |
| POST | /v1/api-keys | Create an API key. The secret is shown once. Full-access key. |
| GET | /v1/api-keys | List 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}/rotate | Issue a replacement; the old key works for 24 more hours. Full-access key. |
| GET | /v1/webhooks | List webhook endpoints. Full-access key. |
| POST | /v1/webhooks | Add 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}/deliveries | Recent delivery attempts to this endpoint. Full-access key. |
| POST | /v1/webhooks/{id}/deliveries/{deliveryId}/replay | Deliver one event again. Full-access key. |
| POST | /v1/webhooks/{id}/test | Send a signed webhook.test event to the endpoint now. Full-access key. |
| GET | /v1/suppressions | Addresses that will not be sent to. |
| POST | /v1/suppressions | Suppress an address yourself. Full-access key. |
| DELETE | /v1/suppressions/{email} | Remove an address from the list. Full-access key. |
| GET | /v1/events | The event log webhooks are made from. |
| GET | /v1/logs | Your recent API requests. Full-access key. |
| GET | /v1/stats | Daily sent, delivered, bounced and complaint counts. |
| GET | /v1/account | API plan and usage, sending status, limits and reputation. |
| PATCH | /v1/account | Change 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.
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)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.
| Host | smtp.wsmailpro.com |
| Port | 2587 with STARTTLS, or 2465 with TLS |
| Username | anything, for example wsmail |
| Password | an API key (ws_live_…) |
| Size | 3 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.
- Add and verify your domain in the dashboard (Domains). Publish the DNS records it shows.
- Create a key (API keys) and replace
RESEND_API_KEYwith it. - Change the base URL from
https://api.resend.comtohttps://api.wsmailpro.com, or swap the SDK import. - Recreate your webhook endpoints and use the new signing secrets; the signature check stays the same.
- 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 })| Resend | WS Mail |
|---|---|
| from, to, cc, bcc, reply_to, subject, html, text | Same names and shapes. |
| attachments[].content / path / filename | Same; content_type instead of contentType. |
| tags, headers | Same; custom headers must start with X- (or be List-Unsubscribe). |
| scheduled_at | ISO 8601 up to 30 days ahead; natural-language times are not accepted. |
| Idempotency-Key | Same header, kept 24 hours. |
| POST /emails/batch | Same, up to 100 emails, all or nothing. |
| svix-* webhook headers | Sent 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.