Runs examples for real against your account. Test keys only; kept in this tab, never stored on our side. Get a key
ZapSMTP / Docs / Guides

Migrating to ZapSMTP

Move sending from Resend, SendGrid, Mailgun, Postmark or Amazon SES without a big-bang switch. Map your request fields and webhook events, convert templates, import suppressions, publish DNS next to your current records, then move traffic a percentage at a time with a rollback ready.

The fastest path needs no code: if your application already sends over SMTP, change the host and credentials (SMTP relay). For the API, the tables below map every provider's fields and events one by one. Sends, batches and reads below run with a test key (zap_test_…) against simulator addresses. Setup and configuration requests (domains, templates, webhooks, suppressions, API keys) change the account and need a full-access live key: a test key gets 403 test_key_restricted.

Order of work

  1. Add your domain and publish DKIM and SPF next to your existing records.
  2. Create a test key (zap_test_…) and port the send call using the provider sections below.
  3. Convert templates and connect webhooks.
  4. Import suppressions before the first live email.
  5. Shadow, then roll out by percentage, then cut over, keeping a rollback open.

Zero-code path: SMTP relay

Applications, CMS plugins and frameworks that send through SMTP move by changing configuration only. SMTP and the API share one pipeline: the same email log, events, webhooks, suppressions and limits. Create an SMTP credential per application; its username starts with smtp_, it can only send, and you can revoke it on its own. Choose the stream per message with the X-Zap-Stream: broadcast header (default transactional). Full details are in SMTP relay.

Point your mailer at ZapSMTP
SMTP_HOST=zapsmtp.xyz
SMTP_PORT=587            # STARTTLS; 2525 also works, 465 for implicit TLS
SMTP_USER=smtp_xxxxxxxxxxxx
SMTP_PASSWORD=…          # shown once when you create the credential
Ports 587 and 2525 use STARTTLS, 465 uses TLS; 40 MB and 50 recipients per message.

SMTP is also the route for anything that sends prebuilt MIME, such as SES SendRawEmail or Mailgun’s MIME endpoint.

From Resend

Request fields, the batch endpoint, idempotency keys and event names match, and error bodies also carry Resend’s flat statusCode, name and message, so Resend SDKs parse ZapSMTP errors. In the Node SDK, set RESEND_BASE_URL and use a ZapSMTP key. Test the SDK features your code uses (scheduling, templates, attachments) with a test key before switching.

Request fields: Resend → ZapSMTP
from, to, cc, bcc, subject, html, textSame names and shapes. Up to 50 recipients across to, cc and bcc.
reply_to (SDK: replyTo)reply_to
scheduled_at (SDK: scheduledAt)scheduled_at: RFC 3339 or in 30 minutes, up to 7 days ahead.
tags [{name, value}]tags, same shape. Names and values match [A-Za-z0-9_-].
headers, attachmentsSame. Attachments take filename with content (base64) or path (HTTPS URL). The whole JSON request is capped at 40 MB, base64 included (about a third larger than the files), and a batch shares that one limit; larger requests get 413 payload_too_large.
Idempotency-Key headerSame header, kept 24 hours per API key.
POST /emails/batchSame endpoint, up to 100 emails.
Authorization: Bearer re_…Authorization: Bearer zap_…
Webhook events: Resend → ZapSMTP
email.sent, email.delivered, email.delivery_delayedSame names.
email.bounced, email.complained, email.failedSame names. Bounces carry bounce.type and bounce.subType.
email.opened, email.clickedSame names.
email.receivedSame name. Metadata only unless the endpoint has include_content.
(no equivalent)email.suppressed: the send was skipped because the address is on your suppression list.
Node.js: Resend → ZapSMTP
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

await resend.emails.send({
  from: 'Acme <hello@acme.com>',
  to: ['jane@example.com'],
  subject: 'Your receipt',
  html: '<p>Thanks for your order.</p>',
  replyTo: 'support@acme.com',
  tags: [{ name: 'category', value: 'receipt' }],
});
Python: Resend → ZapSMTP
import os, resend

resend.api_key = os.environ["RESEND_API_KEY"]
resend.Emails.send({
    "from": "Acme <hello@acme.com>",
    "to": ["jane@example.com"],
    "subject": "Your receipt",
    "html": "<p>Thanks for your order.</p>",
    "reply_to": "support@acme.com",
})

Webhooks. Resend signs with svix-id, svix-timestamp and svix-signature headers (base64 signatures, base64-decoded secret). ZapSMTP sends Zap-Signature: t=…,v1=…: a hex HMAC-SHA256 over <t>.<raw body>, keyed with the whole whsec_… string, not decoded. Deduplicate on Zap-Event-Id. Replace the verification step; the payload handling can stay.

The same request, sent to ZapSMTP

POST/v1/emails

A typical Resend-style send. Field names are unchanged; unknown fields are rejected with 400 validation_error, so remove anything ZapSMTP does not document.

Requires a full-access or sending-access key. Live keys: From domain must be verified by the account. Test keys: any From domain, nothing delivered, monthly quota not charged.

Recipients are deduplicated and lowercased; max 50 across to+cc+bcc. Message-ID is <id>@<hostname>, or <id>.<thread_id>@<hostname> for a reply that continues a conversation.

The response also carries thread_id: the conversation this email belongs to. An email with In-Reply-To / References headers (set them in headers) joins the thread of the message it answers; otherwise it starts a new one.

failed_recipients: addresses the mail server refused while it took the others; they are not sent and not charged. unconfirmed: true: the mail server took the message but its answer was lost; it counts as sent and must not be retried (its events tell the outcome).

Scheduled emails (scheduled_at): at most 10,000 (256 MB) may wait per account. At send time the account and the key are checked again: a suspended account or a revoked or expired key fails the email.

With reply tracking on, the response carries reply_to: the tracked address the email carries in place of its own Reply-To. Human replies are forwarded to the domain’s reply_forward_to, else to the email’s first Reply-To, else to its From.

Headers

Idempotency-Keystring
optional, 1-256 bytes (see Idempotency)

Body parameters

fromstring
RFC 5322 address, e.g. "Acme <hello@acme.com>". Domain must be verified (live keys). When only an address is given, the domain's from_name (brand settings) is used as display name. May be omitted when template is used and the template defines from. Limits: missing → 422 missing_required_field; unparsable → 422 invalid_parameter "invalid from address"
tostring | string[]required
Recipient(s). Limits: at least one; each must parse as an address (422 invalid_parameter "invalid to address ...")
ccstring | string[]
Cc recipients.
bccstring | string[]
Bcc recipients.
reply_tostring | string[]
Reply-To address(es).
subjectstring
Subject line. May be omitted when template is used and the template defines a subject. Limits: blank → 422 missing_required_field
htmlstring
HTML body. At least one of html/text is required (unless template is used). Limits: both empty → 422 missing_required_field "html or text is required"; cannot be combined with template (422 invalid_parameter)
textstring
Plain-text body. When only html is given, a plain-text part is generated from it (readable text, links as text (url), hidden elements such as the preheader left out).
headersobject<string,string>
Custom headers. Limits: Reserved headers are rejected (422 invalid_parameter "header ... cannot be set"): From, To, Cc, Bcc, Reply-To, Subject, Date, Message-Id, Mime-Version, Content-Type, Content-Transfer-Encoding, DKIM-Signature, Return-Path, Received, any X-Zap-*, X-ZapRef. Names must be printable ASCII without spaces or ':'.
attachmentsobject[]
Attachments; 40 MB total. Limits: 422 invalid_attachment with message attachments[i]: set either content or path, not both | content must be base64 | content or path is required | filename is required | path must be an http(s) URL | fetching path returned HTTP <n> | attachments exceed 40MB in total
Show 5 fields
filenamestringrequired
File name shown to the recipient.
contentstring
File content, base64-encoded. Set content or path, not both.
pathstring
An HTTPS URL we download the file from instead (private and internal addresses are refused).
content_typestring
MIME type. Detected from the file name when omitted.
content_idstring
Makes the file inline: reference it in the HTML as cid:<content_id>.
tagsobject[]
Returned in webhooks as data.tags. Limits: at most 20 tags per email; name and value must match ^[A-Za-z0-9_-]{1,256}$ (422 invalid_parameter)
Show 2 fields
namestringrequired
Letters, digits, _ and -, up to 256 characters.
valuestringrequired
Same characters, up to 256.
scheduled_atstring
RFC 3339 time (e.g. 2026-10-21T15:00:00Z) or relative "in <n> <unit>" with unit min|mins|minute|minutes|h|hour|hours|d|day|days. Times in the past send immediately. Limits: unparsable → 422 invalid_parameter; more than 7 days ahead → 422 invalid_parameter "scheduled_at must be within 7 days"
metadataobject<string,string>
Returned in webhooks and GET /emails; filter with GET /emails?metadata[key]=value. Limits: max 10 keys; keys ^[A-Za-z0-9_-]{1,40}$; values max 500 bytes (UTF-8) (422 invalid_parameter)
streamstring
Limits: other values → 422 invalid_parameter; conflicting with the key's stream → 403 restricted_api_key One of transactional, broadcast. Default transactional (or the key's stream).
trackingobject
Overrides the From domain's open, click and reply tracking defaults for this email.
Show 3 fields
opensboolean
Track opens for this email.
clicksboolean
Track link clicks for this email.
repliesboolean
Reply tracking: the email carries a signed Reply-To on the domain’s reply_domain, so replies come back tied to this email (email.replied). Needs a reply domain on the sending domain (see Update a domain), else 422 invalid_parameter.
templateobject
Render a stored, published template: id = template id or alias. Request subject/from override the template's. Cannot be combined with html/text. Limits: missing id → 422 missing_required_field "template.id is required"; unknown → 422 invalid_parameter "template <id> not found"; never published → 422 invalid_parameter; variables.global → 422 invalid_parameter (reserved); a value of the wrong type → 422 template_variables_invalid; a required variable without value or default → 422 template_variables_missing; anything else the template uses still absent → 422 template_render_error "missing template variables: a, b"
Show 2 fields
idstringrequired
Template id or alias. The template must be published.
variablesobject
Values for the template’s own variables. Checked against the published variable schema: defaults fill absent values, optional variables without one render empty, names the schema does not declare are passed through. global is reserved: global variables come from the account.
tlsstring
required or verified: deliver only over TLS (verified: with a valid certificate for the receiving server). Raises the domain's policy, never lowers it.

Errors

validation_errorHTTP 400
invalid JSON / unknown field
invalid_idempotency_keyHTTP 400
Idempotency-Key must be 1 to 256 bytes
domain_not_verifiedHTTP 403
sender domain is not verified for this account
restricted_api_keyHTTP 403
key restricted to another domain or stream
account_suspendedHTTP 403
account is suspended
idempotency_key_reusedHTTP 409
idempotency_request_in_progressHTTP 409
payload_too_largeHTTP 413
request body exceeds 40MB
misdirected_cellHTTP 421
the account is served by another cell: "this account is served by cell <cell> at <url>", Zap-Cell: <cell>; send to that URL
missing_required_fieldHTTP 422
invalid_parameterHTTP 422
e.g. too many recipients (max 50), invalid address, bad tags/metadata/stream/scheduled_at, "at least one recipient is required"
invalid_attachmentHTTP 422
template_render_errorHTTP 422
template_variables_invalidHTTP 422
template variables of the wrong type: a, b (checked against the published variable schema)
template_variables_missingHTTP 422
missing required template variables: a, b (required, no value and no default)
rejectedHTTP 422
the mail transfer agent refused the message: <reason>
rate_limit_exceededHTTP 429
rate limit exceeded
monthly_quota_exceededHTTP 429
monthly quota exceeded
daily_quota_exceededHTTP 429
daily sending limit reached; it resets at 00:00 UTC
overloadedHTTP 429
server at capacity ("the server is at capacity; retry shortly", Retry-After: 1) or event pipeline backed up ("the event pipeline is backed up; retry shortly", Retry-After: 5); nothing sent, retry
service_unavailableHTTP 503
a backing store failing over ("a backing service is failing over; retry shortly", Retry-After: 2); MTA unreachable ("mail transfer agent temporarily unavailable, retry"), suppression list being rebuilt ("suppression list is being rebuilt, retry shortly"), or too many large request bodies in flight (Retry-After: 5)
POST/v1/emails
curl -X POST 'https://zapsmtp.xyz/v1/emails' \
  -H "Authorization: Bearer $ZAP_API_KEY" \
  -H 'Idempotency-Key: order-1042-receipt' \
  -H 'Content-Type: application/json' \
  -d '{
  "from": "Acme <hello@acme.com>",
  "to": [
    "delivered@simulator.zapsmtp.xyz"
  ],
  "subject": "Your receipt",
  "html": "<p>Thanks for your order.</p>",
  "reply_to": "support@acme.com",
  "tags": [
    {
      "name": "category",
      "value": "receipt"
    }
  ]
}'
Set ZAP_API_KEY in your environment.
Response 200 OK
{
  "id": "019a1f2e-7c3b-7d41-9a52-0c4b8e6f1a23"
}

From SendGrid

SendGrid’s v3 mail/send nests recipients in personalizations. A single personalization maps to one ZapSMTP email; several personalizations that should each be a separate message map to POST /emails/batch. Note that all addresses in one ZapSMTP to see each other, as in any normal email.

Request fields: SendGrid → ZapSMTP
from {email, name}from: "Acme <hello@acme.com>"
personalizations[].to / cc / bccto, cc, bcc. Each personalization that should be a separate email becomes one item in POST /emails/batch.
subjectsubject
content [{type: text/plain | text/html, value}]text and html
reply_to {email, name}reply_to
attachments [{content, filename, type, content_id}]attachments [{content, filename, content_type, content_id}]
categories []tags [{name, value}], e.g. {name: "category", value: "receipt"}
custom_args {}metadata: at most 10 string pairs, returned on the delivery events of that email.
send_at (Unix time)scheduled_at (RFC 3339), up to 7 days ahead.
template_id + dynamic_template_datatemplate: {id, variables}; see Templates.
tracking_settingstracking: {opens, clicks}
Event Webhook: SendGrid → ZapSMTP
processedemail.sent
deliveredemail.delivered
deferredemail.delivery_delayed
bounceemail.bounced
droppedemail.suppressed when the address is suppressed, otherwise email.failed
spamreportemail.complained
open, clickemail.opened, email.clicked
unsubscribeNo event: the address is added to suppressions with reason unsubscribe.
SendGrid → ZapSMTP
import sgMail from '@sendgrid/mail';

sgMail.setApiKey(process.env.SENDGRID_API_KEY);
await sgMail.send({
  to: 'jane@example.com',
  from: { email: 'hello@acme.com', name: 'Acme' },
  subject: 'Your receipt',
  html: '<p>Thanks for your order.</p>',
  categories: ['receipt'],
  customArgs: { order_id: 'A-1042' },
});

Webhooks. SendGrid posts arrays of events and signs them with an ECDSA key (the X-Twilio-Email-Event-Webhook-Signature header). ZapSMTP posts one event per request with an HMAC-SHA256 Zap-Signature, so drop the array loop and the public-key check. custom_args come back as data.metadata.

Templates. Dynamic templates use Handlebars; convert conditionals and loops to Mustache sections (table below). Suppression groups have no direct equivalent: an unsubscribe applies to the whole account’s broadcast mail (transactional mail still goes out), and broadcast mail carries a one-click List-Unsubscribe header automatically.

Personalizations as a batch

POST/v1/emails/batch

Several SendGrid personalizations (or Mailgun recipient variables) become one batch: each item is its own email with its own recipient, subject and metadata. Up to 100 per call.

Requires a full-access or sending-access key. Test keys follow the same rules as for sending.

Per-email error codes use the same classification as POST /emails (domain_not_verified, restricted_api_key, rate_limit_exceeded, monthly_quota_exceeded, etc. appear inside errors[] rather than as the HTTP status). Emails are sent concurrently (up to 16 at a time); each email may have its own scheduled_at.

Body: length 1-100 else 422 invalid_parameter "batch must contain 1 to 100 emails"

An item of data carries reply_to when reply tracking is on for that email.

Headers

x-batch-validationstring
strict (default) | permissive (case-insensitive). strict: if any email fails validation nothing is sent and 422 is returned. permissive: valid emails are sent; invalid ones are reported in errors.
Idempotency-Keystring
optional, 1-256 bytes

Body parameters

[ ]object[]required
1–100 emails. Each item takes the fields of Send an email.

Errors

validation_errorHTTP 400
invalid JSON or x-batch-validation not strict/permissive
invalid_idempotency_keyHTTP 400
Idempotency-Key must be 1 to 256 bytes
idempotency_key_reusedHTTP 409
idempotency_request_in_progressHTTP 409
payload_too_largeHTTP 413
invalid_parameterHTTP 422
batch size outside 1-100
validation_errorHTTP 422
strict mode, any email invalid
service_unavailableHTTP 503
too many large bodies in flight
POST/v1/emails/batch
curl -X POST 'https://zapsmtp.xyz/v1/emails/batch' \
  -H "Authorization: Bearer $ZAP_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '[
  {
    "from": "Acme <hello@acme.com>",
    "to": [
      "delivered@simulator.zapsmtp.xyz"
    ],
    "subject": "Your order A-1042 shipped",
    "text": "Hi Ann, it is on its way.",
    "metadata": {
      "order_id": "A-1042"
    }
  },
  {
    "from": "Acme <hello@acme.com>",
    "to": [
      "delivered+bob@simulator.zapsmtp.xyz"
    ],
    "subject": "Your order A-1043 shipped",
    "text": "Hi Bob, it is on its way.",
    "metadata": {
      "order_id": "A-1043"
    }
  }
]'
Set ZAP_API_KEY in your environment.
Response 200 OK
{
  "data": [
    {
      "index": 0,
      "id": "019a1f2e-7c3b-7d41-9a52-0c4b8e6f1a23"
    },
    {
      "index": 2,
      "id": "019a1f2e-7c3c-7a10-8b21-4d5e6f7a8b9c",
      "suppressed": [
        "old@example.org"
      ]
    }
  ],
  "errors": [
    {
      "index": 1,
      "code": "missing_required_field",
      "message": "subject is required"
    }
  ]
}

From Mailgun

Mailgun’s /v3/<domain>/messages takes form fields with o:, h: and v: prefixes and HTTP basic auth. ZapSMTP takes JSON with a bearer key, and the sending domain comes from from instead of the URL.

Request fields: Mailgun → ZapSMTP
from, to, cc, bcc, subject, text, htmlSame names, sent as JSON instead of form fields.
h:Reply-Toreply_to
h:X-My-Headerheaders: {"X-My-Header": "…"}
attachment / inline (files)attachments; inline images set content_id.
o:tagtags [{name, value}]
v:my-var (custom variables)metadata
o:deliverytimescheduled_at
o:tracking-opens, o:tracking-clickstracking: {opens, clicks}
template + template variablestemplate: {id, variables}
recipient-variables (batch sending)One item per recipient in POST /emails/batch.
Webhook events: Mailgun → ZapSMTP
acceptedemail.sent
deliveredemail.delivered
failed (temporary)email.delivery_delayed
failed (permanent)email.bounced, or email.suppressed for a suppressed address
complainedemail.complained
opened, clickedemail.opened, email.clicked
unsubscribedNo event: suppression with reason unsubscribe.
Mailgun → ZapSMTP
const form = new FormData();
form.append('from', 'Acme <hello@acme.com>');
form.append('to', 'jane@example.com');
form.append('subject', 'Your receipt');
form.append('html', '<p>Thanks for your order.</p>');
form.append('o:tag', 'receipt');
form.append('v:order_id', 'A-1042');

await fetch('https://api.mailgun.net/v3/mg.acme.com/messages', {
  method: 'POST',
  headers: { Authorization: 'Basic ' + btoa('api:' + process.env.MAILGUN_API_KEY) },
  body: form,
});

Webhooks. Mailgun puts a signature object (timestamp, token, signature) inside the JSON body and the event in event-data. ZapSMTP signs in the Zap-Signature header and puts the event fields in data. Permanent and temporary failures are separate event types in ZapSMTP instead of a severity field.

Tags, metadata and scheduling

POST/v1/emails

o:tag, v: variables and o:deliverytime map to tags, metadata and scheduled_at. Metadata comes back on the delivery events of the email.

Requires a full-access or sending-access key. Live keys: From domain must be verified by the account. Test keys: any From domain, nothing delivered, monthly quota not charged.

Recipients are deduplicated and lowercased; max 50 across to+cc+bcc. Message-ID is <id>@<hostname>, or <id>.<thread_id>@<hostname> for a reply that continues a conversation.

The response also carries thread_id: the conversation this email belongs to. An email with In-Reply-To / References headers (set them in headers) joins the thread of the message it answers; otherwise it starts a new one.

failed_recipients: addresses the mail server refused while it took the others; they are not sent and not charged. unconfirmed: true: the mail server took the message but its answer was lost; it counts as sent and must not be retried (its events tell the outcome).

Scheduled emails (scheduled_at): at most 10,000 (256 MB) may wait per account. At send time the account and the key are checked again: a suspended account or a revoked or expired key fails the email.

With reply tracking on, the response carries reply_to: the tracked address the email carries in place of its own Reply-To. Human replies are forwarded to the domain’s reply_forward_to, else to the email’s first Reply-To, else to its From.

Headers

Idempotency-Keystring
optional, 1-256 bytes (see Idempotency)

Body parameters

fromstring
RFC 5322 address, e.g. "Acme <hello@acme.com>". Domain must be verified (live keys). When only an address is given, the domain's from_name (brand settings) is used as display name. May be omitted when template is used and the template defines from. Limits: missing → 422 missing_required_field; unparsable → 422 invalid_parameter "invalid from address"
tostring | string[]required
Recipient(s). Limits: at least one; each must parse as an address (422 invalid_parameter "invalid to address ...")
ccstring | string[]
Cc recipients.
bccstring | string[]
Bcc recipients.
reply_tostring | string[]
Reply-To address(es).
subjectstring
Subject line. May be omitted when template is used and the template defines a subject. Limits: blank → 422 missing_required_field
htmlstring
HTML body. At least one of html/text is required (unless template is used). Limits: both empty → 422 missing_required_field "html or text is required"; cannot be combined with template (422 invalid_parameter)
textstring
Plain-text body. When only html is given, a plain-text part is generated from it (readable text, links as text (url), hidden elements such as the preheader left out).
headersobject<string,string>
Custom headers. Limits: Reserved headers are rejected (422 invalid_parameter "header ... cannot be set"): From, To, Cc, Bcc, Reply-To, Subject, Date, Message-Id, Mime-Version, Content-Type, Content-Transfer-Encoding, DKIM-Signature, Return-Path, Received, any X-Zap-*, X-ZapRef. Names must be printable ASCII without spaces or ':'.
attachmentsobject[]
Attachments; 40 MB total. Limits: 422 invalid_attachment with message attachments[i]: set either content or path, not both | content must be base64 | content or path is required | filename is required | path must be an http(s) URL | fetching path returned HTTP <n> | attachments exceed 40MB in total
Show 5 fields
filenamestringrequired
File name shown to the recipient.
contentstring
File content, base64-encoded. Set content or path, not both.
pathstring
An HTTPS URL we download the file from instead (private and internal addresses are refused).
content_typestring
MIME type. Detected from the file name when omitted.
content_idstring
Makes the file inline: reference it in the HTML as cid:<content_id>.
tagsobject[]
Returned in webhooks as data.tags. Limits: at most 20 tags per email; name and value must match ^[A-Za-z0-9_-]{1,256}$ (422 invalid_parameter)
Show 2 fields
namestringrequired
Letters, digits, _ and -, up to 256 characters.
valuestringrequired
Same characters, up to 256.
scheduled_atstring
RFC 3339 time (e.g. 2026-10-21T15:00:00Z) or relative "in <n> <unit>" with unit min|mins|minute|minutes|h|hour|hours|d|day|days. Times in the past send immediately. Limits: unparsable → 422 invalid_parameter; more than 7 days ahead → 422 invalid_parameter "scheduled_at must be within 7 days"
metadataobject<string,string>
Returned in webhooks and GET /emails; filter with GET /emails?metadata[key]=value. Limits: max 10 keys; keys ^[A-Za-z0-9_-]{1,40}$; values max 500 bytes (UTF-8) (422 invalid_parameter)
streamstring
Limits: other values → 422 invalid_parameter; conflicting with the key's stream → 403 restricted_api_key One of transactional, broadcast. Default transactional (or the key's stream).
trackingobject
Overrides the From domain's open, click and reply tracking defaults for this email.
Show 3 fields
opensboolean
Track opens for this email.
clicksboolean
Track link clicks for this email.
repliesboolean
Reply tracking: the email carries a signed Reply-To on the domain’s reply_domain, so replies come back tied to this email (email.replied). Needs a reply domain on the sending domain (see Update a domain), else 422 invalid_parameter.
templateobject
Render a stored, published template: id = template id or alias. Request subject/from override the template's. Cannot be combined with html/text. Limits: missing id → 422 missing_required_field "template.id is required"; unknown → 422 invalid_parameter "template <id> not found"; never published → 422 invalid_parameter; variables.global → 422 invalid_parameter (reserved); a value of the wrong type → 422 template_variables_invalid; a required variable without value or default → 422 template_variables_missing; anything else the template uses still absent → 422 template_render_error "missing template variables: a, b"
Show 2 fields
idstringrequired
Template id or alias. The template must be published.
variablesobject
Values for the template’s own variables. Checked against the published variable schema: defaults fill absent values, optional variables without one render empty, names the schema does not declare are passed through. global is reserved: global variables come from the account.
tlsstring
required or verified: deliver only over TLS (verified: with a valid certificate for the receiving server). Raises the domain's policy, never lowers it.

Errors

validation_errorHTTP 400
invalid JSON / unknown field
invalid_idempotency_keyHTTP 400
Idempotency-Key must be 1 to 256 bytes
domain_not_verifiedHTTP 403
sender domain is not verified for this account
restricted_api_keyHTTP 403
key restricted to another domain or stream
account_suspendedHTTP 403
account is suspended
idempotency_key_reusedHTTP 409
idempotency_request_in_progressHTTP 409
payload_too_largeHTTP 413
request body exceeds 40MB
misdirected_cellHTTP 421
the account is served by another cell: "this account is served by cell <cell> at <url>", Zap-Cell: <cell>; send to that URL
missing_required_fieldHTTP 422
invalid_parameterHTTP 422
e.g. too many recipients (max 50), invalid address, bad tags/metadata/stream/scheduled_at, "at least one recipient is required"
invalid_attachmentHTTP 422
template_render_errorHTTP 422
template_variables_invalidHTTP 422
template variables of the wrong type: a, b (checked against the published variable schema)
template_variables_missingHTTP 422
missing required template variables: a, b (required, no value and no default)
rejectedHTTP 422
the mail transfer agent refused the message: <reason>
rate_limit_exceededHTTP 429
rate limit exceeded
monthly_quota_exceededHTTP 429
monthly quota exceeded
daily_quota_exceededHTTP 429
daily sending limit reached; it resets at 00:00 UTC
overloadedHTTP 429
server at capacity ("the server is at capacity; retry shortly", Retry-After: 1) or event pipeline backed up ("the event pipeline is backed up; retry shortly", Retry-After: 5); nothing sent, retry
service_unavailableHTTP 503
a backing store failing over ("a backing service is failing over; retry shortly", Retry-After: 2); MTA unreachable ("mail transfer agent temporarily unavailable, retry"), suppression list being rebuilt ("suppression list is being rebuilt, retry shortly"), or too many large request bodies in flight (Retry-After: 5)
POST/v1/emails
curl -X POST 'https://zapsmtp.xyz/v1/emails' \
  -H "Authorization: Bearer $ZAP_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
  "from": "Acme <hello@acme.com>",
  "to": "delivered@simulator.zapsmtp.xyz",
  "subject": "Your weekly summary",
  "html": "<p>Here is your week.</p>",
  "tags": [
    {
      "name": "tag",
      "value": "weekly-summary"
    }
  ],
  "metadata": {
    "user_id": "u_42"
  },
  "scheduled_at": "in 10 minutes",
  "tracking": {
    "opens": true,
    "clicks": true
  }
}'
Set ZAP_API_KEY in your environment.
Response 200 OK
{
  "id": "019a1f2e-7c3b-7d41-9a52-0c4b8e6f1a23"
}

From Postmark

Postmark uses PascalCase fields; ZapSMTP uses snake_case. Message streams map to stream: transactional or broadcast, each on its own IP pool. Postmark templates are Mustache-based, so most of them move with few or no changes.

Request fields: Postmark → ZapSMTP
From, To, Cc, Bcc, Subjectfrom, to, cc, bcc, subject. Comma-separated lists become arrays.
ReplyToreply_to
HtmlBody, TextBodyhtml, text
Headers [{Name, Value}]headers: {"Name": "Value"}
Attachments [{Name, Content, ContentType, ContentID}]attachments [{filename, content, content_type, content_id}]
Tagtags: [{name: "tag", value: "…"}]. Values allow letters, digits, _ and - only.
Metadatametadata (at most 10 keys)
MessageStreamstream: transactional or broadcast
TrackOpens, TrackLinkstracking: {opens, clicks}
TemplateAlias / TemplateId + TemplateModeltemplate: {id, variables}; id accepts a template id or alias.
X-Postmark-Server-Token headerAuthorization: Bearer zap_…
Webhooks: Postmark RecordType → ZapSMTP
Deliveryemail.delivered
Bounce (hard)email.bounced with bounce.type: "Permanent"
Bounce (soft / transient)email.delivery_delayed while retrying; email.bounced when retries run out
SpamComplaintemail.complained
Open, Clickemail.opened, email.clicked
SubscriptionChangeNo event: suppression with reason unsubscribe.
Postmark → ZapSMTP
import { ServerClient } from 'postmark';

const client = new ServerClient(process.env.POSTMARK_SERVER_TOKEN);
await client.sendEmailWithTemplate({
  From: 'Acme <hello@acme.com>',
  To: 'jane@example.com',
  TemplateAlias: 'receipt',
  TemplateModel: { name: 'Jane', order_id: 'A-1042' },
  MessageStream: 'outbound',
});

Webhooks. ZapSMTP signs every request; verify Zap-Signature as shown in Webhook events instead of relying on URL credentials. Switch your handler from RecordType to type.

Send with a stored template

POST/v1/emails

Postmark TemplateAlias + TemplateModel, SendGrid template_id + dynamic_template_data and SES TemplateName + TemplateData all become template: {id, variables}. The template must be published, and every variable it substitutes must be supplied; a missing section condition simply counts as false.

Requires a full-access or sending-access key. Live keys: From domain must be verified by the account. Test keys: any From domain, nothing delivered, monthly quota not charged.

Recipients are deduplicated and lowercased; max 50 across to+cc+bcc. Message-ID is <id>@<hostname>, or <id>.<thread_id>@<hostname> for a reply that continues a conversation.

The response also carries thread_id: the conversation this email belongs to. An email with In-Reply-To / References headers (set them in headers) joins the thread of the message it answers; otherwise it starts a new one.

failed_recipients: addresses the mail server refused while it took the others; they are not sent and not charged. unconfirmed: true: the mail server took the message but its answer was lost; it counts as sent and must not be retried (its events tell the outcome).

Scheduled emails (scheduled_at): at most 10,000 (256 MB) may wait per account. At send time the account and the key are checked again: a suspended account or a revoked or expired key fails the email.

With reply tracking on, the response carries reply_to: the tracked address the email carries in place of its own Reply-To. Human replies are forwarded to the domain’s reply_forward_to, else to the email’s first Reply-To, else to its From.

Headers

Idempotency-Keystring
optional, 1-256 bytes (see Idempotency)

Body parameters

fromstring
RFC 5322 address, e.g. "Acme <hello@acme.com>". Domain must be verified (live keys). When only an address is given, the domain's from_name (brand settings) is used as display name. May be omitted when template is used and the template defines from. Limits: missing → 422 missing_required_field; unparsable → 422 invalid_parameter "invalid from address"
tostring | string[]required
Recipient(s). Limits: at least one; each must parse as an address (422 invalid_parameter "invalid to address ...")
ccstring | string[]
Cc recipients.
bccstring | string[]
Bcc recipients.
reply_tostring | string[]
Reply-To address(es).
subjectstring
Subject line. May be omitted when template is used and the template defines a subject. Limits: blank → 422 missing_required_field
htmlstring
HTML body. At least one of html/text is required (unless template is used). Limits: both empty → 422 missing_required_field "html or text is required"; cannot be combined with template (422 invalid_parameter)
textstring
Plain-text body. When only html is given, a plain-text part is generated from it (readable text, links as text (url), hidden elements such as the preheader left out).
headersobject<string,string>
Custom headers. Limits: Reserved headers are rejected (422 invalid_parameter "header ... cannot be set"): From, To, Cc, Bcc, Reply-To, Subject, Date, Message-Id, Mime-Version, Content-Type, Content-Transfer-Encoding, DKIM-Signature, Return-Path, Received, any X-Zap-*, X-ZapRef. Names must be printable ASCII without spaces or ':'.
attachmentsobject[]
Attachments; 40 MB total. Limits: 422 invalid_attachment with message attachments[i]: set either content or path, not both | content must be base64 | content or path is required | filename is required | path must be an http(s) URL | fetching path returned HTTP <n> | attachments exceed 40MB in total
Show 5 fields
filenamestringrequired
File name shown to the recipient.
contentstring
File content, base64-encoded. Set content or path, not both.
pathstring
An HTTPS URL we download the file from instead (private and internal addresses are refused).
content_typestring
MIME type. Detected from the file name when omitted.
content_idstring
Makes the file inline: reference it in the HTML as cid:<content_id>.
tagsobject[]
Returned in webhooks as data.tags. Limits: at most 20 tags per email; name and value must match ^[A-Za-z0-9_-]{1,256}$ (422 invalid_parameter)
Show 2 fields
namestringrequired
Letters, digits, _ and -, up to 256 characters.
valuestringrequired
Same characters, up to 256.
scheduled_atstring
RFC 3339 time (e.g. 2026-10-21T15:00:00Z) or relative "in <n> <unit>" with unit min|mins|minute|minutes|h|hour|hours|d|day|days. Times in the past send immediately. Limits: unparsable → 422 invalid_parameter; more than 7 days ahead → 422 invalid_parameter "scheduled_at must be within 7 days"
metadataobject<string,string>
Returned in webhooks and GET /emails; filter with GET /emails?metadata[key]=value. Limits: max 10 keys; keys ^[A-Za-z0-9_-]{1,40}$; values max 500 bytes (UTF-8) (422 invalid_parameter)
streamstring
Limits: other values → 422 invalid_parameter; conflicting with the key's stream → 403 restricted_api_key One of transactional, broadcast. Default transactional (or the key's stream).
trackingobject
Overrides the From domain's open, click and reply tracking defaults for this email.
Show 3 fields
opensboolean
Track opens for this email.
clicksboolean
Track link clicks for this email.
repliesboolean
Reply tracking: the email carries a signed Reply-To on the domain’s reply_domain, so replies come back tied to this email (email.replied). Needs a reply domain on the sending domain (see Update a domain), else 422 invalid_parameter.
templateobject
Render a stored, published template: id = template id or alias. Request subject/from override the template's. Cannot be combined with html/text. Limits: missing id → 422 missing_required_field "template.id is required"; unknown → 422 invalid_parameter "template <id> not found"; never published → 422 invalid_parameter; variables.global → 422 invalid_parameter (reserved); a value of the wrong type → 422 template_variables_invalid; a required variable without value or default → 422 template_variables_missing; anything else the template uses still absent → 422 template_render_error "missing template variables: a, b"
Show 2 fields
idstringrequired
Template id or alias. The template must be published.
variablesobject
Values for the template’s own variables. Checked against the published variable schema: defaults fill absent values, optional variables without one render empty, names the schema does not declare are passed through. global is reserved: global variables come from the account.
tlsstring
required or verified: deliver only over TLS (verified: with a valid certificate for the receiving server). Raises the domain's policy, never lowers it.

Errors

validation_errorHTTP 400
invalid JSON / unknown field
invalid_idempotency_keyHTTP 400
Idempotency-Key must be 1 to 256 bytes
domain_not_verifiedHTTP 403
sender domain is not verified for this account
restricted_api_keyHTTP 403
key restricted to another domain or stream
account_suspendedHTTP 403
account is suspended
idempotency_key_reusedHTTP 409
idempotency_request_in_progressHTTP 409
payload_too_largeHTTP 413
request body exceeds 40MB
misdirected_cellHTTP 421
the account is served by another cell: "this account is served by cell <cell> at <url>", Zap-Cell: <cell>; send to that URL
missing_required_fieldHTTP 422
invalid_parameterHTTP 422
e.g. too many recipients (max 50), invalid address, bad tags/metadata/stream/scheduled_at, "at least one recipient is required"
invalid_attachmentHTTP 422
template_render_errorHTTP 422
template_variables_invalidHTTP 422
template variables of the wrong type: a, b (checked against the published variable schema)
template_variables_missingHTTP 422
missing required template variables: a, b (required, no value and no default)
rejectedHTTP 422
the mail transfer agent refused the message: <reason>
rate_limit_exceededHTTP 429
rate limit exceeded
monthly_quota_exceededHTTP 429
monthly quota exceeded
daily_quota_exceededHTTP 429
daily sending limit reached; it resets at 00:00 UTC
overloadedHTTP 429
server at capacity ("the server is at capacity; retry shortly", Retry-After: 1) or event pipeline backed up ("the event pipeline is backed up; retry shortly", Retry-After: 5); nothing sent, retry
service_unavailableHTTP 503
a backing store failing over ("a backing service is failing over; retry shortly", Retry-After: 2); MTA unreachable ("mail transfer agent temporarily unavailable, retry"), suppression list being rebuilt ("suppression list is being rebuilt, retry shortly"), or too many large request bodies in flight (Retry-After: 5)
POST/v1/emails
curl -X POST 'https://zapsmtp.xyz/v1/emails' \
  -H "Authorization: Bearer $ZAP_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
  "from": "Acme <hello@acme.com>",
  "to": "delivered@simulator.zapsmtp.xyz",
  "template": {
    "id": "welcome",
    "variables": {
      "first_name": "Ann"
    }
  }
}'
Set ZAP_API_KEY in your environment.
Response 200 OK
{
  "id": "019a1f2e-7c3b-7d41-9a52-0c4b8e6f1a23"
}

From Amazon SES

SES v2 SendEmail wraps content in Content.Simple, Content.Raw or Content.Template and is signed with AWS credentials. ZapSMTP uses a flat JSON body and an API key. Events arrive as webhooks directly, without a configuration set or an SNS topic in between.

Request fields: SES v2 → ZapSMTP
FromEmailAddressfrom
Destination.ToAddresses / CcAddresses / BccAddressesto, cc, bcc
ReplyToAddressesreply_to
Content.Simple.Subject / Body.Html / Body.Textsubject, html, text
Content.Raw (MIME)No raw MIME field in the API: send prebuilt MIME through the SMTP relay.
Content.Template (TemplateName, TemplateData)template: {id, variables}; variables is an object, not a JSON string.
EmailTags [{Name, Value}]tags [{name, value}]
ConfigurationSetNameNot needed: events go to the webhooks you subscribe; tracking is set per domain or with tracking.
Event types: SES → ZapSMTP
Sendemail.sent
Deliveryemail.delivered
DeliveryDelayemail.delivery_delayed
Bounceemail.bounced
Complaintemail.complained
Reject, Rendering Failureemail.failed; template errors are returned synchronously as 422 template_render_error
Open, Clickemail.opened, email.clicked
Amazon SES v2 → ZapSMTP
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';

const ses = new SESv2Client({ region: 'us-east-1' });
await ses.send(new SendEmailCommand({
  FromEmailAddress: 'Acme <hello@acme.com>',
  Destination: { ToAddresses: ['jane@example.com'] },
  Content: {
    Simple: {
      Subject: { Data: 'Your receipt' },
      Body: { Html: { Data: '<p>Thanks for your order.</p>' } },
    },
  },
  EmailTags: [{ Name: 'category', Value: 'receipt' }],
}));

Webhooks. Instead of confirming an SNS subscription and validating SNS message signatures, verify one HMAC header. Bounce details move from bounce.bounceType / bounceSubType to data.bounce.type (Permanent, Transient, Undetermined) and data.bounce.subType; see the bounce classification.

Suppressions. Export the account-level suppression list (bounces and complaints) and import it as shown below.

Templates

ZapSMTP templates are logic-less Mustache. Templates from Postmark (Mustache-based) usually carry over; Handlebars templates from SendGrid, Mailgun and SES need their conditionals, loops and helpers rewritten. Templates have a draft and a published version, and sends always use the published one: create with "publish": true or publish after review. Handlebars tags are refused on save: {{#each items}}, {{> footer}} or {{first name}} return 422 template_render_error html: line L, column C: {{…}} is not a variable name, with the position of the first one to rewrite. A required variable must be supplied on send: a missing one returns 422 template_variables_missing instead of an empty string. A missing section condition ({{#vip}}) counts as false, variables inside a section that is skipped are not evaluated, and zap: placeholders are never required. Rendering works with a test key; creating and publishing templates needs a full-access live key.

Handlebars-style → ZapSMTP
{{name}} / {{{html}}}Same: escaped and raw.
{{user.first_name}}Same: dotted paths work.
{{#if vip}}…{{/if}}{{#vip}}…{{/vip}}: a section renders when the value is truthy.
{{else}} / {{#unless x}}An inverted section: {{^x}}…{{/x}}.
{{#each items}}{{this.name}}{{/each}}{{#items}}{{name}}{{/items}}; {{.}} is the current item of a list of strings.
Helpers ({{formatDate …}}, {{#equals}} …)None: templates are logic-less. Format values before you send them.
%recipient.name% (Mailgun batch variables){{name}} with a separate batch item per recipient.
Unsubscribe placeholders{{{zap:unsubscribe_url}}} becomes the recipient’s signed one-click unsubscribe link.
Convert a template
<p>Hi {{customer.first_name}},</p>
{{#if vip}}<p>Your VIP discount is applied.</p>{{else}}<p>Join VIP for 10% off.</p>{{/if}}
<ul>
{{#each items}}<li>{{this.name}} × {{this.qty}}</li>{{/each}}
</ul>

Check a converted template

POST/v1/templates/render

Render converted source with sample data before you save it. Syntax errors and the resulting subject, HTML and text come back without sending anything. Rendering works with a test key; saving templates needs a full-access live key.

Requires a full-access key.

Body parameters

subjectstring
htmlstring
textstring
variablesobject
Default {}.

Errors

restricted_api_keyHTTP 403
template_render_errorHTTP 422
<field>: <syntax error>
POST/v1/templates/render
curl -X POST 'https://zapsmtp.xyz/v1/templates/render' \
  -H "Authorization: Bearer $ZAP_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
  "subject": "Order {{order_id}}",
  "html": "<p>Hi {{customer.first_name}},</p>{{#vip}}<p>VIP discount applied.</p>{{/vip}}{{^vip}}<p>Join VIP for 10% off.</p>{{/vip}}<ul>{{#items}}<li>{{name}} × {{qty}}</li>{{/items}}</ul>",
  "variables": {
    "order_id": "A-1042",
    "customer": {
      "first_name": "Ann"
    },
    "vip": false,
    "items": [
      {
        "name": "Mug",
        "qty": 2
      },
      {
        "name": "Tea",
        "qty": 1
      }
    ]
  }
}'
Set ZAP_API_KEY in your environment.
Response 200 OK
{
  "subject": "Hello Jane",
  "html": "<p>Welcome, Jane!</p>",
  "text": "",
  "missing_variables": [
    "plan"
  ],
  "variable_names": [
    "name",
    "plan"
  ],
  "design": null
}

Webhooks during the transition

Run both providers’ endpoints side by side and normalize events into the shape your application already uses. ZapSMTP sends one event per recipient (data.to is a single address), retries failures for about 28 hours and keeps the event id stable in Zap-Event-Id. The delivery events of an email you sent (email.sent through email.suppressed) carry its tags and metadata. email.replied carries the tags but not the metadata, and inbound and conversation events (email.received, thread.*) have their own payloads; see Webhook events. Creating a webhook needs a live key.

Normalize ZapSMTP events
// One handler for both providers while traffic is split.
// ZapSMTP posts one JSON event per request and per recipient; verify
// Zap-Signature first (see /docs/webhook-events/#signatures).
const ZAP = {
  'email.delivered': 'delivered',
  'email.delivery_delayed': 'deferred',
  'email.bounced': 'bounced',
  'email.complained': 'complained',
  'email.suppressed': 'dropped',
  'email.failed': 'dropped',
  'email.opened': 'opened',
  'email.clicked': 'clicked',
};

export function fromZap(event) {
  const kind = ZAP[event.type];
  if (!kind) return null;
  return {
    kind,
    email: event.data.to,                 // a single address
    messageId: event.data.email_id,
    permanent: event.data.bounce?.type === 'Permanent',
    meta: event.data.metadata ?? {},      // your former custom args
  };
}
Verify Zap-Signature before parsing.

Subscribe your webhook endpoint

POST/v1/webhooks

Point a new endpoint, or a new path on your existing handler, at ZapSMTP and keep the old provider’s endpoint running until cutover. The response contains the whsec_… signing secret.

Requires a full-access live key; test keys get 403 test_key_restricted.

The secret is also readable later via GET /webhooks/{id}.

To send clicks of one button to this endpoint: {"events": ["email.clicked"], "filter": {"actions": ["confirm_order"]}}. Endpoints always return filter, with an empty list when there is none.

Body parameters

urlstringrequired
Absolute https:// URL. Limits: 422 invalid_parameter "url must be an absolute https:// URL"
eventsstring[]
Limits: unknown type → 422 invalid_parameter "unknown event type <x>; allowed: ..." One of email.sent, email.delivered, email.delivery_delayed, email.bounced, email.complained, email.failed, email.opened, email.clicked, email.suppressed, email.received, thread.created, thread.replied, email.replied. Default all event types.
include_contentboolean
email.received carries the message: text, html, headers, and download links (valid 24 hours) for the attachments and the raw .eml; text and html are cut at 1 MB in total (truncated: true). Default false.
batchboolean
Send events up to 100 per request (or every second) as a JSON array, signed as one body; Zap-Event-Id is the batch id, each event keeps its own id. email.received with the full message still arrives alone. Default false.
filterobject
Narrows email.clicked to some click actions; other event types are not affected. Empty or absent: every click. Default {"actions": []}.
Show 1 fields
actionsstring[]
Action names (lowercase letters, digits, _, . and -, starting with a letter or digit, up to 64 characters). The endpoint then receives email.clicked only for links with one of these actions. Limits: more than 50 → 422 invalid_parameter "filter.actions: at most 50"; a bad name → 422 invalid_parameter "filter.actions: <name> is not an action name"

Errors

restricted_api_keyHTTP 403
test_key_restrictedHTTP 403
test-mode keys can send test email and read, not change the account: use a live key
invalid_parameterHTTP 422
POST/v1/webhooks
curl -X POST 'https://zapsmtp.xyz/v1/webhooks' \
  -H "Authorization: Bearer $ZAP_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
  "url": "https://acme.com/hooks/zapsmtp",
  "events": [
    "email.delivered",
    "email.delivery_delayed",
    "email.bounced",
    "email.complained",
    "email.suppressed",
    "email.failed"
  ]
}'
Set ZAP_API_KEY in your environment.
Response 201 Created
{
  "id": "019a1f22-6e7f-7091-8a2b-3c4d5e6f7a8b",
  "url": "https://acme.com/hooks/zapsmtp",
  "secret": "whsec_Q7v2Lk9Xp4Rb1Tz8Mw3Nc6Hy0Fd5Gs2J",
  "events": [
    "email.delivered",
    "email.bounced",
    "email.complained"
  ],
  "enabled": true,
  "disabled_reason": null,
  "created_at": "2026-10-21T15:40:00Z",
  "include_content": false,
  "batch": false,
  "filter": {
    "actions": []
  }
}

Import suppressions

Bring over hard bounces, complaints and unsubscribes before the first live email, so nobody who opted out or bounced is mailed again. Export them from your current provider (dashboard or API) as one address per line. POST /suppressions/batch takes up to 1,000 addresses per call and needs a full-access live key (test keys cannot change suppressions); imported entries use reason manual (stops every stream) or unsubscribe (stops broadcast mail only), while hard_bounce and complaint are set only by ZapSMTP itself. Sends to a suppressed address are skipped and reported as email.suppressed.

Import a suppression export
// node import-suppressions.mjs suppressed.txt  (one address per line)
import { readFileSync } from 'node:fs';

const API = 'https://zapsmtp.xyz/v1';
const emails = [...new Set(readFileSync(process.argv[2], 'utf8')
  .split(/\r?\n/).map((s) => s.trim().toLowerCase()).filter(Boolean))];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

for (let i = 0; i < emails.length; i += 1000) {
  const chunk = emails.slice(i, i + 1000);
  for (;;) {
    const res = await fetch(API + '/suppressions/batch', {
      method: 'POST',
      headers: { Authorization: 'Bearer ' + process.env.ZAP_API_KEY, 'Content-Type': 'application/json' },
      body: JSON.stringify({ emails: chunk, reason: 'manual' }),
    });
    if (res.status === 429 || res.status >= 500) {
      await sleep(1000 * Number(res.headers.get('Retry-After') ?? 1));
      continue;
    }
    if (!res.ok) throw new Error(res.status + ' ' + (await res.text()));
    const { added } = await res.json();
    console.log('rows', i, '-', i + chunk.length, 'added', added);
    break;
  }
  await sleep(200); // stay well under the rate limit
}
On 429 the script waits for Retry-After and retries the same chunk.

Import suppressions in bulk

POST/v1/suppressions/batch

Up to 1,000 addresses per call. Duplicates and addresses already on the list are kept as they are (except that a manual entry replaces an unsubscribe), so re-running an import is safe. reason is manual, which stops every stream, or unsubscribe, which stops broadcast mail only.

Requires a full-access live key; test keys get 403 test_key_restricted.

Body parameters

emailsstring[]required
1–1,000 addresses.
reasonstring
manual: no email at all; unsubscribe: no broadcast email (transactional email still goes out). One of manual, unsubscribe. Default manual.

Errors

test_key_restrictedHTTP 403
test-mode keys can send test email and read, not change the account: use a live key
invalid_parameterHTTP 422
Empty or over 1,000 addresses, an invalid address, or an unknown reason.
POST/v1/suppressions/batch
curl -X POST 'https://zapsmtp.xyz/v1/suppressions/batch' \
  -H "Authorization: Bearer $ZAP_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
  "emails": [
    "old-bounce@example.com",
    "complainer@example.net"
  ],
  "reason": "manual"
}'
Set ZAP_API_KEY in your environment.
Response 201 Created
{
  "object": "suppression_batch",
  "added": 2,
  "reason": "manual",
  "emails": [
    "ann@example.com",
    "bob@example.com"
  ]
}

Import unsubscribes

POST/v1/suppressions/batch

Import marketing opt-outs with reason: "unsubscribe": they then stop broadcast mail only, so receipts and password resets still reach those people. Import bounces and complaints as manual.

Requires a full-access live key; test keys get 403 test_key_restricted.

Body parameters

emailsstring[]required
1–1,000 addresses.
reasonstring
manual: no email at all; unsubscribe: no broadcast email (transactional email still goes out). One of manual, unsubscribe. Default manual.

Errors

test_key_restrictedHTTP 403
test-mode keys can send test email and read, not change the account: use a live key
invalid_parameterHTTP 422
Empty or over 1,000 addresses, an invalid address, or an unknown reason.
POST/v1/suppressions/batch
curl -X POST 'https://zapsmtp.xyz/v1/suppressions/batch' \
  -H "Authorization: Bearer $ZAP_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
  "emails": [
    "opted-out@example.com"
  ],
  "reason": "unsubscribe"
}'
Set ZAP_API_KEY in your environment.
Response 201 Created
{
  "object": "suppression_batch",
  "added": 2,
  "reason": "manual",
  "emails": [
    "ann@example.com",
    "bob@example.com"
  ]
}

Migration plan

1. DNS side by side

Both providers can sign and send for the same domain at the same time. Adding and verifying a domain needs a full-access live key. Add ZapSMTP’s DKIM key under its own zap selector and leave the old provider’s key in place. A domain has one SPF record, so add the ZapSMTP include to it rather than publishing a second record, and keep the old include until the old provider sends nothing. Keep your DMARC record as it is. Live keys can send only from verified domains.

Zone records through the migration
acme.com.                TXT    "v=spf1 include:<old provider> ~all"
<old selector>._domainkey.acme.com.  CNAME/TXT  <old provider's DKIM key>
_dmarc.acme.com.         TXT    "v=DMARC1; p=quarantine; rua=mailto:dmarc@acme.com"
Use the exact values returned for your domain.

Add your sending domain

POST/v1/domains

Returns the DKIM, SPF and DMARC records. The DKIM selector is zap, so it sits next to your current provider’s key without conflict.

Requires a full-access live key; test keys get 403 test_key_restricted.

Body parameters

namestringrequired
Domain name (lowercased, trailing dot removed). Limits: valid hostname <=253 chars, else 422 invalid_parameter "invalid domain name"

Errors

plan_limit_reachedHTTP 403
the <Plan> plan includes <n> domains; upgrade your plan to add more
restricted_api_keyHTTP 403
test_key_restrictedHTTP 403
test-mode keys can send test email and read, not change the account: use a live key
conflictHTTP 409
domain already added
invalid_parameterHTTP 422
invalid domain name
POST/v1/domains
curl -X POST 'https://zapsmtp.xyz/v1/domains' \
  -H "Authorization: Bearer $ZAP_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
  "name": "acme.com"
}'
Set ZAP_API_KEY in your environment.
Response 201 Created
{
  "id": "019a1f10-8a10-7b22-8c31-5d2e9f0a4b17",
  "name": "acme.com",
  "status": "pending",
  "dkim_selector": "zap",
  "dkim_verified": false,
  "spf_verified": false,
  "dmarc_verified": false,
  "open_tracking": false,
  "click_tracking": false,
  "from_name": "",
  "tls": "opportunistic",
  "reply_tracking": false,
  "reply_domain": "",
  "reply_forward_to": "",
  "brand_updated_at": null,
  "last_checked_at": null,
  "verified_at": null,
  "created_at": "2026-10-21T13:58:02Z",
  "zone": "acme.com",
  "records": [
    {
      "record": "DKIM",
      "type": "TXT",
      "name": "zap._domainkey.acme.com",
      "value": "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...",
      "required": true,
      "status": "pending"
    },
    {
      "record": "SPF",
      "type": "TXT",
      "name": "acme.com",
      "value": "v=spf1 include:_spf.zapsmtp.xyz ~all",
      "required": false,
      "status": "pending"
    },
    {
      "record": "DMARC",
      "type": "TXT",
      "name": "_dmarc.acme.com",
      "value": "v=DMARC1; p=none;",
      "required": false,
      "status": "pending"
    }
  ]
}

2. Test keys, simulator and shadow traffic

A test key can send and read, but not change the account. It accepts any From domain, runs validation, stored templates, the email log, events and webhooks exactly like a live key, never delivers and does not count against your plan. Use it first against simulator addresses to exercise every webhook path, then mirror production requests to catch fields or templates that do not convert. Filter shadow traffic with GET /emails?mode=test and the request log.

Test before any traffic moves

POST/v1/emails

Send with a test key (zap_test_…) to simulator addresses: bounces, complaints and delays reach your webhook within seconds without touching real recipients or your reputation.

Requires a full-access or sending-access key. Live keys: From domain must be verified by the account. Test keys: any From domain, nothing delivered, monthly quota not charged.

Recipients are deduplicated and lowercased; max 50 across to+cc+bcc. Message-ID is <id>@<hostname>, or <id>.<thread_id>@<hostname> for a reply that continues a conversation.

The response also carries thread_id: the conversation this email belongs to. An email with In-Reply-To / References headers (set them in headers) joins the thread of the message it answers; otherwise it starts a new one.

failed_recipients: addresses the mail server refused while it took the others; they are not sent and not charged. unconfirmed: true: the mail server took the message but its answer was lost; it counts as sent and must not be retried (its events tell the outcome).

Scheduled emails (scheduled_at): at most 10,000 (256 MB) may wait per account. At send time the account and the key are checked again: a suspended account or a revoked or expired key fails the email.

With reply tracking on, the response carries reply_to: the tracked address the email carries in place of its own Reply-To. Human replies are forwarded to the domain’s reply_forward_to, else to the email’s first Reply-To, else to its From.

Headers

Idempotency-Keystring
optional, 1-256 bytes (see Idempotency)

Body parameters

fromstring
RFC 5322 address, e.g. "Acme <hello@acme.com>". Domain must be verified (live keys). When only an address is given, the domain's from_name (brand settings) is used as display name. May be omitted when template is used and the template defines from. Limits: missing → 422 missing_required_field; unparsable → 422 invalid_parameter "invalid from address"
tostring | string[]required
Recipient(s). Limits: at least one; each must parse as an address (422 invalid_parameter "invalid to address ...")
ccstring | string[]
Cc recipients.
bccstring | string[]
Bcc recipients.
reply_tostring | string[]
Reply-To address(es).
subjectstring
Subject line. May be omitted when template is used and the template defines a subject. Limits: blank → 422 missing_required_field
htmlstring
HTML body. At least one of html/text is required (unless template is used). Limits: both empty → 422 missing_required_field "html or text is required"; cannot be combined with template (422 invalid_parameter)
textstring
Plain-text body. When only html is given, a plain-text part is generated from it (readable text, links as text (url), hidden elements such as the preheader left out).
headersobject<string,string>
Custom headers. Limits: Reserved headers are rejected (422 invalid_parameter "header ... cannot be set"): From, To, Cc, Bcc, Reply-To, Subject, Date, Message-Id, Mime-Version, Content-Type, Content-Transfer-Encoding, DKIM-Signature, Return-Path, Received, any X-Zap-*, X-ZapRef. Names must be printable ASCII without spaces or ':'.
attachmentsobject[]
Attachments; 40 MB total. Limits: 422 invalid_attachment with message attachments[i]: set either content or path, not both | content must be base64 | content or path is required | filename is required | path must be an http(s) URL | fetching path returned HTTP <n> | attachments exceed 40MB in total
Show 5 fields
filenamestringrequired
File name shown to the recipient.
contentstring
File content, base64-encoded. Set content or path, not both.
pathstring
An HTTPS URL we download the file from instead (private and internal addresses are refused).
content_typestring
MIME type. Detected from the file name when omitted.
content_idstring
Makes the file inline: reference it in the HTML as cid:<content_id>.
tagsobject[]
Returned in webhooks as data.tags. Limits: at most 20 tags per email; name and value must match ^[A-Za-z0-9_-]{1,256}$ (422 invalid_parameter)
Show 2 fields
namestringrequired
Letters, digits, _ and -, up to 256 characters.
valuestringrequired
Same characters, up to 256.
scheduled_atstring
RFC 3339 time (e.g. 2026-10-21T15:00:00Z) or relative "in <n> <unit>" with unit min|mins|minute|minutes|h|hour|hours|d|day|days. Times in the past send immediately. Limits: unparsable → 422 invalid_parameter; more than 7 days ahead → 422 invalid_parameter "scheduled_at must be within 7 days"
metadataobject<string,string>
Returned in webhooks and GET /emails; filter with GET /emails?metadata[key]=value. Limits: max 10 keys; keys ^[A-Za-z0-9_-]{1,40}$; values max 500 bytes (UTF-8) (422 invalid_parameter)
streamstring
Limits: other values → 422 invalid_parameter; conflicting with the key's stream → 403 restricted_api_key One of transactional, broadcast. Default transactional (or the key's stream).
trackingobject
Overrides the From domain's open, click and reply tracking defaults for this email.
Show 3 fields
opensboolean
Track opens for this email.
clicksboolean
Track link clicks for this email.
repliesboolean
Reply tracking: the email carries a signed Reply-To on the domain’s reply_domain, so replies come back tied to this email (email.replied). Needs a reply domain on the sending domain (see Update a domain), else 422 invalid_parameter.
templateobject
Render a stored, published template: id = template id or alias. Request subject/from override the template's. Cannot be combined with html/text. Limits: missing id → 422 missing_required_field "template.id is required"; unknown → 422 invalid_parameter "template <id> not found"; never published → 422 invalid_parameter; variables.global → 422 invalid_parameter (reserved); a value of the wrong type → 422 template_variables_invalid; a required variable without value or default → 422 template_variables_missing; anything else the template uses still absent → 422 template_render_error "missing template variables: a, b"
Show 2 fields
idstringrequired
Template id or alias. The template must be published.
variablesobject
Values for the template’s own variables. Checked against the published variable schema: defaults fill absent values, optional variables without one render empty, names the schema does not declare are passed through. global is reserved: global variables come from the account.
tlsstring
required or verified: deliver only over TLS (verified: with a valid certificate for the receiving server). Raises the domain's policy, never lowers it.

Errors

validation_errorHTTP 400
invalid JSON / unknown field
invalid_idempotency_keyHTTP 400
Idempotency-Key must be 1 to 256 bytes
domain_not_verifiedHTTP 403
sender domain is not verified for this account
restricted_api_keyHTTP 403
key restricted to another domain or stream
account_suspendedHTTP 403
account is suspended
idempotency_key_reusedHTTP 409
idempotency_request_in_progressHTTP 409
payload_too_largeHTTP 413
request body exceeds 40MB
misdirected_cellHTTP 421
the account is served by another cell: "this account is served by cell <cell> at <url>", Zap-Cell: <cell>; send to that URL
missing_required_fieldHTTP 422
invalid_parameterHTTP 422
e.g. too many recipients (max 50), invalid address, bad tags/metadata/stream/scheduled_at, "at least one recipient is required"
invalid_attachmentHTTP 422
template_render_errorHTTP 422
template_variables_invalidHTTP 422
template variables of the wrong type: a, b (checked against the published variable schema)
template_variables_missingHTTP 422
missing required template variables: a, b (required, no value and no default)
rejectedHTTP 422
the mail transfer agent refused the message: <reason>
rate_limit_exceededHTTP 429
rate limit exceeded
monthly_quota_exceededHTTP 429
monthly quota exceeded
daily_quota_exceededHTTP 429
daily sending limit reached; it resets at 00:00 UTC
overloadedHTTP 429
server at capacity ("the server is at capacity; retry shortly", Retry-After: 1) or event pipeline backed up ("the event pipeline is backed up; retry shortly", Retry-After: 5); nothing sent, retry
service_unavailableHTTP 503
a backing store failing over ("a backing service is failing over; retry shortly", Retry-After: 2); MTA unreachable ("mail transfer agent temporarily unavailable, retry"), suppression list being rebuilt ("suppression list is being rebuilt, retry shortly"), or too many large request bodies in flight (Retry-After: 5)
POST/v1/emails
curl -X POST 'https://zapsmtp.xyz/v1/emails' \
  -H "Authorization: Bearer $ZAP_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
  "from": "Acme <hello@acme.com>",
  "to": [
    "delivered@simulator.zapsmtp.xyz",
    "bounced@simulator.zapsmtp.xyz",
    "complained@simulator.zapsmtp.xyz"
  ],
  "subject": "Migration check",
  "text": "Webhook and suppression handling test."
}'
Set ZAP_API_KEY in your environment.
Response 200 OK
{
  "id": "019a1f2e-7c3b-7d41-9a52-0c4b8e6f1a23"
}
Mirror production sends with a test key
// Shadow: keep sending live through the old provider and replay every
// request to ZapSMTP with a test key (zap_test_…). Test keys run validation,
// templates, the email log and webhooks, deliver nothing and are not counted.
export async function send(msg) {
  const result = await oldProvider.send(msg);           // still the source of truth
  fetch('https://zapsmtp.xyz/v1/emails', {
    method: 'POST',
    headers: { Authorization: 'Bearer ' + process.env.ZAP_TEST_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify(toZap(msg)),
  })
    .then(async (r) => { if (!r.ok) log.warn('zapsmtp shadow', r.status, await r.text()); })
    .catch((e) => log.warn('zapsmtp shadow', e.message));  // never affects the user
  return result;
}
toZap() is your field mapping from the sections above.

3. Percentage rollout

Switch a growing share of recipients to a live key: 1%, 10%, 50%, then all. Bucketing by recipient keeps each address on one provider, which keeps its events and suppressions in one place. Send an Idempotency-Key so a retried request never sends twice; a 429 or 5xx means nothing was accepted, so a retry or fallback is safe. A 200 with unconfirmed: true counts as sent and must not be retried.

Route a percentage of traffic
import { createHash } from 'node:crypto';

// ZAP_PERCENT: 0 → 1 → 10 → 50 → 100. Hashing the recipient keeps each
// address on one provider, so its events and suppressions stay in one place.
const bucket = (email) => createHash('sha256').update(email.toLowerCase()).digest().readUInt16BE(0) % 100;

export async function send(msg, idempotencyKey) {
  if (bucket(msg.to) >= Number(process.env.ZAP_PERCENT ?? 0)) return oldProvider.send(msg);
  const res = await fetch('https://zapsmtp.xyz/v1/emails', {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + process.env.ZAP_API_KEY,
      'Content-Type': 'application/json',
      'Idempotency-Key': idempotencyKey,   // safe to retry
    },
    body: JSON.stringify(toZap(msg)),
  });
  if (res.ok) return res.json();
  // 429 and 5xx mean nothing was accepted: retry later, or fall back once
  if (res.status === 429 || res.status >= 500) return oldProvider.send(msg);
  throw new Error('ZapSMTP ' + res.status + ': ' + (await res.text())); // 4xx: fix the request
}

Between steps, compare delivery, bounce and complaint rates for the two providers using statistics, and check the deliverability guide if you send high volumes from new IPs.

4. Cutover checklist

  • Domain verified: DKIM shows verified, SPF includes ZapSMTP, DMARC reports show ZapSMTP mail passing.
  • Live API keys created per application and environment; SMTP credentials per application.
  • Every template converted, rendered with real data and published.
  • Webhook endpoint subscribed, signatures verified, duplicates skipped by Zap-Event-Id, a test event received (send one).
  • Suppressions imported, including any added on the old provider during the rollout.
  • Error handling covers 422 validation errors, 429 with Retry-After, and quota errors; see Errors, limits & paging.
  • Rollout at 100% for a full sending cycle (daily and weekly jobs included) with stable rates.
  • Then remove the old provider’s SPF include and DKIM record, and revoke its keys.

5. Rollback

Until the old provider’s DNS and keys are removed, rollback is a configuration change: set the rollout percentage to 0 (or point the SMTP host back). Before you resume on the old provider, copy over the suppressions ZapSMTP collected (bounces, complaints and one-click unsubscribes) so those recipients stay excluded. Webhook retries for emails already sent through ZapSMTP keep arriving, so leave the ZapSMTP endpoint running until they finish.

Export suppressions for a rollback

GET/v1/suppressions

Page through the list with after=next_cursor and add the addresses to the previous provider, so recipients who bounced, complained or unsubscribed on ZapSMTP stay excluded there too.

Requires a full-access key.

reason is hard_bounce, complaint, unsubscribe or manual; source_id is the email that caused it (null when added by hand). Pass next_cursor as after for the next page.

unsubscribe stops only broadcast email (stream: broadcast); transactional email to the address still goes out. Every other reason stops all email to it.

Query parameters

limitinteger
1-1000; out-of-range falls back to 100. Default 100.
afterstring
Opaque cursor from next_cursor. Limits: 422 invalid_parameter "invalid after cursor"
beforestring
Legacy: RFC 3339 timestamp; returns entries created before it. Ignored when after is set. Limits: 422 invalid_parameter "before must be RFC3339"

Errors

restricted_api_keyHTTP 403
invalid_parameterHTTP 422
GET/v1/suppressions
curl 'https://zapsmtp.xyz/v1/suppressions?limit=1000' \
  -H "Authorization: Bearer $ZAP_API_KEY"
Set ZAP_API_KEY in your environment.
Response 200 OK
{
  "object": "list",
  "has_more": true,
  "data": [
    {
      "email": "old@example.org",
      "reason": "hard_bounce",
      "source_id": "019a1f2e-7c3b-7d41-9a52-0c4b8e6f1a23",
      "created_at": "2026-10-21T15:04:13Z"
    },
    {
      "email": "angry@example.net",
      "reason": "complaint",
      "source_id": "019a1f2a-1b2c-7d3e-8f4a-5b6c7d8e9f00",
      "created_at": "2026-10-21T11:20:40Z"
    }
  ],
  "next_cursor": "MTc2MTA0NzI0MDAwMDAwMDAwMHxhbmdyeUBleGFtcGxlLm5ldA"
}