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
- Add your domain and publish DKIM and SPF next to your existing records.
- Create a test key (
zap_test_…) and port the send call using the provider sections below. - Convert templates and connect webhooks.
- Import suppressions before the first live email.
- 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.
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 credentialMAIL_MAILER=smtp
MAIL_HOST=zapsmtp.xyz
MAIL_PORT=587
MAIL_ENCRYPTION=tls
MAIL_USERNAME=smtp_xxxxxxxxxxxx
MAIL_PASSWORD=…EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "zapsmtp.xyz"
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_USER = os.environ["ZAP_SMTP_USER"]
EMAIL_HOST_PASSWORD = os.environ["ZAP_SMTP_PASSWORD"]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.
from, to, cc, bcc, subject, html, text | Same 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, attachments | Same. 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 header | Same header, kept 24 hours per API key. |
POST /emails/batch | Same endpoint, up to 100 emails. |
Authorization: Bearer re_… | Authorization: Bearer zap_… |
email.sent, email.delivered, email.delivery_delayed | Same names. |
|---|---|
email.bounced, email.complained, email.failed | Same names. Bounces carry bounce.type and bounce.subType. |
email.opened, email.clicked | Same names. |
email.received | Same 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. |
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' }],
});# Server environment: only these two values change
RESEND_BASE_URL=https://zapsmtp.xyz/v1
RESEND_API_KEY=zap_your_api_key// Unchanged: the SDK reads RESEND_BASE_URL and sends to 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' }],
});const res = await fetch('https://zapsmtp.xyz/v1/emails', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.ZAP_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'Acme <hello@acme.com>',
to: ['jane@example.com'],
subject: 'Your receipt',
html: '<p>Thanks for your order.</p>',
reply_to: 'support@acme.com',
tags: [{ name: 'category', value: 'receipt' }],
}),
});
const { id } = await res.json();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",
})import os, requests
r = requests.post(
"https://zapsmtp.xyz/v1/emails",
headers={"Authorization": f"Bearer {os.environ['ZAP_API_KEY']}"},
json={
"from": "Acme <hello@acme.com>",
"to": ["jane@example.com"],
"subject": "Your receipt",
"html": "<p>Thanks for your order.</p>",
"reply_to": "support@acme.com",
},
timeout=30,
)
r.raise_for_status()
email_id = r.json()["id"]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
htmlis given, a plain-text part is generated from it (readable text, links astext (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
contentorpath, 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. Defaulttransactional (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.
globalis reserved: global variables come from the account.
tlsstringrequiredorverified: 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 409idempotency_request_in_progressHTTP 409payload_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 422invalid_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 422template_render_errorHTTP 422template_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)
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"
}
]
}'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': 'order-1042-receipt',
},
body: JSON.stringify({
"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"
}
]
}),
});
// 204 No Content (e.g. a delete) has no body
const data = res.status === 204 ? null : await res.json();
if (!res.ok) throw new Error(`${data.name}: ${data.message}`);
console.log(data);import os
import requests
res = requests.post(
"https://zapsmtp.xyz/v1/emails",
headers={"Authorization": f"Bearer {os.environ['ZAP_API_KEY']}", "Idempotency-Key": "order-1042-receipt"},
json={
"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",
},
],
},
timeout=30,
)
res.raise_for_status()
print(res.json() if res.status_code != 204 else "deleted")<?php
$ch = curl_init('https://zapsmtp.xyz/v1/emails');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('ZAP_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: order-1042-receipt',
],
CURLOPT_POSTFIELDS => json_encode([
'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',
],
],
]),
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException($data['message']);
}
print_r($data);package main
import (
"fmt"
"io"
"net/http"
"os"
"strings"
)
func main() {
body := strings.NewReader(`{
"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"
}
]
}`)
req, err := http.NewRequest(
"POST",
"https://zapsmtp.xyz/v1/emails",
body,
)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("ZAP_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "order-1042-receipt")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(out))
}require 'net/http'
require 'json'
uri = URI('https://zapsmtp.xyz/v1/emails')
req = Net::HTTP::Post.new(uri)
req['Authorization'] = "Bearer #{ENV.fetch('ZAP_API_KEY')}"
req["Idempotency-Key"] = "order-1042-receipt"
req['Content-Type'] = 'application/json'
req.body = {
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"
}
]
}.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
puts res.code, (JSON.parse(res.body) unless res.body.to_s.empty?)import java.net.URI;
import java.net.http.*;
public class ZapRequest {
public static void main(String[] args) throws Exception {
var request = HttpRequest.newBuilder(URI.create("https://zapsmtp.xyz/v1/emails"))
.header("Authorization", "Bearer " + System.getenv("ZAP_API_KEY"))
.header("Content-Type", "application/json")
.header("Idempotency-Key", "order-1042-receipt")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{
"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"
}
]
}"""))
.build();
var response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode() + " " + response.body());
}
}using System.Net.Http.Headers;
using System.Text;
using var http = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://zapsmtp.xyz/v1/emails");
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("ZAP_API_KEY"));
request.Headers.Add("Idempotency-Key", "order-1042-receipt");
request.Content = new StringContent("""
{
"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"
}
]
}
""", Encoding.UTF8, "application/json");
var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");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.
from {email, name} | from: "Acme <hello@acme.com>" |
|---|---|
personalizations[].to / cc / bcc | to, cc, bcc. Each personalization that should be a separate email becomes one item in POST /emails/batch. |
subject | subject |
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_data | template: {id, variables}; see Templates. |
tracking_settings | tracking: {opens, clicks} |
processed | email.sent |
|---|---|
delivered | email.delivered |
deferred | email.delivery_delayed |
bounce | email.bounced |
dropped | email.suppressed when the address is suppressed, otherwise email.failed |
spamreport | email.complained |
open, click | email.opened, email.clicked |
unsubscribe | No event: the address is added to suppressions with reason unsubscribe. |
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' },
});await fetch('https://zapsmtp.xyz/v1/emails', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.ZAP_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
from: 'Acme <hello@acme.com>',
to: 'jane@example.com',
subject: 'Your receipt',
html: '<p>Thanks for your order.</p>',
tags: [{ name: 'category', value: 'receipt' }],
metadata: { order_id: 'A-1042' },
}),
});import os
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail
message = Mail(
from_email=("hello@acme.com", "Acme"),
to_emails="jane@example.com",
subject="Your receipt",
html_content="<p>Thanks for your order.</p>",
)
SendGridAPIClient(os.environ["SENDGRID_API_KEY"]).send(message)import os, requests
requests.post(
"https://zapsmtp.xyz/v1/emails",
headers={"Authorization": f"Bearer {os.environ['ZAP_API_KEY']}"},
json={
"from": "Acme <hello@acme.com>",
"to": "jane@example.com",
"subject": "Your receipt",
"html": "<p>Thanks for your order.</p>",
"tags": [{"name": "category", "value": "receipt"}],
"metadata": {"order_id": "A-1042"},
},
timeout=30,
).raise_for_status()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 409idempotency_request_in_progressHTTP 409payload_too_largeHTTP 413invalid_parameterHTTP 422- batch size outside 1-100
validation_errorHTTP 422- strict mode, any email invalid
service_unavailableHTTP 503- too many large bodies in flight
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"
}
}
]'const res = await fetch('https://zapsmtp.xyz/v1/emails/batch', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZAP_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify([
{
"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"
}
}
]),
});
// 204 No Content (e.g. a delete) has no body
const data = res.status === 204 ? null : await res.json();
if (!res.ok) throw new Error(`${data.name}: ${data.message}`);
console.log(data);import os
import requests
res = requests.post(
"https://zapsmtp.xyz/v1/emails/batch",
headers={"Authorization": f"Bearer {os.environ['ZAP_API_KEY']}"},
json=[
{
"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",
},
},
],
timeout=30,
)
res.raise_for_status()
print(res.json() if res.status_code != 204 else "deleted")<?php
$ch = curl_init('https://zapsmtp.xyz/v1/emails/batch');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('ZAP_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
[
'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',
],
],
]),
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException($data['message']);
}
print_r($data);package main
import (
"fmt"
"io"
"net/http"
"os"
"strings"
)
func main() {
body := strings.NewReader(`[
{
"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"
}
}
]`)
req, err := http.NewRequest(
"POST",
"https://zapsmtp.xyz/v1/emails/batch",
body,
)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("ZAP_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(out))
}require 'net/http'
require 'json'
uri = URI('https://zapsmtp.xyz/v1/emails/batch')
req = Net::HTTP::Post.new(uri)
req['Authorization'] = "Bearer #{ENV.fetch('ZAP_API_KEY')}"
req['Content-Type'] = 'application/json'
req.body = [
{
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"
}
}
].to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
puts res.code, (JSON.parse(res.body) unless res.body.to_s.empty?)import java.net.URI;
import java.net.http.*;
public class ZapRequest {
public static void main(String[] args) throws Exception {
var request = HttpRequest.newBuilder(URI.create("https://zapsmtp.xyz/v1/emails/batch"))
.header("Authorization", "Bearer " + System.getenv("ZAP_API_KEY"))
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
[
{
"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"
}
}
]"""))
.build();
var response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode() + " " + response.body());
}
}using System.Net.Http.Headers;
using System.Text;
using var http = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://zapsmtp.xyz/v1/emails/batch");
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("ZAP_API_KEY"));
request.Content = new StringContent("""
[
{
"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"
}
}
]
""", Encoding.UTF8, "application/json");
var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");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.
from, to, cc, bcc, subject, text, html | Same names, sent as JSON instead of form fields. |
|---|---|
h:Reply-To | reply_to |
h:X-My-Header | headers: {"X-My-Header": "…"} |
attachment / inline (files) | attachments; inline images set content_id. |
o:tag | tags [{name, value}] |
v:my-var (custom variables) | metadata |
o:deliverytime | scheduled_at |
o:tracking-opens, o:tracking-clicks | tracking: {opens, clicks} |
template + template variables | template: {id, variables} |
recipient-variables (batch sending) | One item per recipient in POST /emails/batch. |
accepted | email.sent |
|---|---|
delivered | email.delivered |
failed (temporary) | email.delivery_delayed |
failed (permanent) | email.bounced, or email.suppressed for a suppressed address |
complained | email.complained |
opened, clicked | email.opened, email.clicked |
unsubscribed | No event: suppression with reason unsubscribe. |
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,
});await fetch('https://zapsmtp.xyz/v1/emails', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.ZAP_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
from: 'Acme <hello@acme.com>',
to: 'jane@example.com',
subject: 'Your receipt',
html: '<p>Thanks for your order.</p>',
tags: [{ name: 'tag', value: 'receipt' }],
metadata: { order_id: 'A-1042' },
}),
});import os, requests
requests.post(
"https://api.mailgun.net/v3/mg.acme.com/messages",
auth=("api", os.environ["MAILGUN_API_KEY"]),
data={
"from": "Acme <hello@acme.com>",
"to": "jane@example.com",
"subject": "Your receipt",
"html": "<p>Thanks for your order.</p>",
"o:tag": "receipt",
"v:order_id": "A-1042",
},
)import os, requests
requests.post(
"https://zapsmtp.xyz/v1/emails",
headers={"Authorization": f"Bearer {os.environ['ZAP_API_KEY']}"},
json={
"from": "Acme <hello@acme.com>",
"to": "jane@example.com",
"subject": "Your receipt",
"html": "<p>Thanks for your order.</p>",
"tags": [{"name": "tag", "value": "receipt"}],
"metadata": {"order_id": "A-1042"},
},
timeout=30,
).raise_for_status()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
htmlis given, a plain-text part is generated from it (readable text, links astext (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
contentorpath, 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. Defaulttransactional (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.
globalis reserved: global variables come from the account.
tlsstringrequiredorverified: 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 409idempotency_request_in_progressHTTP 409payload_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 422invalid_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 422template_render_errorHTTP 422template_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)
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
}
}'const res = await fetch('https://zapsmtp.xyz/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZAP_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"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
}
}),
});
// 204 No Content (e.g. a delete) has no body
const data = res.status === 204 ? null : await res.json();
if (!res.ok) throw new Error(`${data.name}: ${data.message}`);
console.log(data);import os
import requests
res = requests.post(
"https://zapsmtp.xyz/v1/emails",
headers={"Authorization": f"Bearer {os.environ['ZAP_API_KEY']}"},
json={
"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,
},
},
timeout=30,
)
res.raise_for_status()
print(res.json() if res.status_code != 204 else "deleted")<?php
$ch = curl_init('https://zapsmtp.xyz/v1/emails');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('ZAP_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'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,
],
]),
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException($data['message']);
}
print_r($data);package main
import (
"fmt"
"io"
"net/http"
"os"
"strings"
)
func main() {
body := strings.NewReader(`{
"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
}
}`)
req, err := http.NewRequest(
"POST",
"https://zapsmtp.xyz/v1/emails",
body,
)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("ZAP_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(out))
}require 'net/http'
require 'json'
uri = URI('https://zapsmtp.xyz/v1/emails')
req = Net::HTTP::Post.new(uri)
req['Authorization'] = "Bearer #{ENV.fetch('ZAP_API_KEY')}"
req['Content-Type'] = 'application/json'
req.body = {
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
}
}.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
puts res.code, (JSON.parse(res.body) unless res.body.to_s.empty?)import java.net.URI;
import java.net.http.*;
public class ZapRequest {
public static void main(String[] args) throws Exception {
var request = HttpRequest.newBuilder(URI.create("https://zapsmtp.xyz/v1/emails"))
.header("Authorization", "Bearer " + System.getenv("ZAP_API_KEY"))
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{
"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
}
}"""))
.build();
var response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode() + " " + response.body());
}
}using System.Net.Http.Headers;
using System.Text;
using var http = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://zapsmtp.xyz/v1/emails");
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("ZAP_API_KEY"));
request.Content = new StringContent("""
{
"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
}
}
""", Encoding.UTF8, "application/json");
var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");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.
From, To, Cc, Bcc, Subject | from, to, cc, bcc, subject. Comma-separated lists become arrays. |
|---|---|
ReplyTo | reply_to |
HtmlBody, TextBody | html, text |
Headers [{Name, Value}] | headers: {"Name": "Value"} |
Attachments [{Name, Content, ContentType, ContentID}] | attachments [{filename, content, content_type, content_id}] |
Tag | tags: [{name: "tag", value: "…"}]. Values allow letters, digits, _ and - only. |
Metadata | metadata (at most 10 keys) |
MessageStream | stream: transactional or broadcast |
TrackOpens, TrackLinks | tracking: {opens, clicks} |
TemplateAlias / TemplateId + TemplateModel | template: {id, variables}; id accepts a template id or alias. |
X-Postmark-Server-Token header | Authorization: Bearer zap_… |
Delivery | email.delivered |
|---|---|
Bounce (hard) | email.bounced with bounce.type: "Permanent" |
Bounce (soft / transient) | email.delivery_delayed while retrying; email.bounced when retries run out |
SpamComplaint | email.complained |
Open, Click | email.opened, email.clicked |
SubscriptionChange | No event: suppression with reason unsubscribe. |
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',
});await fetch('https://zapsmtp.xyz/v1/emails', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.ZAP_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
from: 'Acme <hello@acme.com>',
to: 'jane@example.com',
template: { id: 'receipt', variables: { name: 'Jane', order_id: 'A-1042' } },
stream: 'transactional',
}),
});import os, requests
requests.post(
"https://api.postmarkapp.com/email/withTemplate",
headers={"X-Postmark-Server-Token": os.environ["POSTMARK_SERVER_TOKEN"]},
json={
"From": "Acme <hello@acme.com>",
"To": "jane@example.com",
"TemplateAlias": "receipt",
"TemplateModel": {"name": "Jane", "order_id": "A-1042"},
},
)import os, requests
requests.post(
"https://zapsmtp.xyz/v1/emails",
headers={"Authorization": f"Bearer {os.environ['ZAP_API_KEY']}"},
json={
"from": "Acme <hello@acme.com>",
"to": "jane@example.com",
"template": {"id": "receipt", "variables": {"name": "Jane", "order_id": "A-1042"}},
},
timeout=30,
).raise_for_status()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
htmlis given, a plain-text part is generated from it (readable text, links astext (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
contentorpath, 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. Defaulttransactional (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.
globalis reserved: global variables come from the account.
tlsstringrequiredorverified: 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 409idempotency_request_in_progressHTTP 409payload_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 422invalid_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 422template_render_errorHTTP 422template_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)
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"
}
}
}'const res = await fetch('https://zapsmtp.xyz/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZAP_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"from": "Acme <hello@acme.com>",
"to": "delivered@simulator.zapsmtp.xyz",
"template": {
"id": "welcome",
"variables": {
"first_name": "Ann"
}
}
}),
});
// 204 No Content (e.g. a delete) has no body
const data = res.status === 204 ? null : await res.json();
if (!res.ok) throw new Error(`${data.name}: ${data.message}`);
console.log(data);import os
import requests
res = requests.post(
"https://zapsmtp.xyz/v1/emails",
headers={"Authorization": f"Bearer {os.environ['ZAP_API_KEY']}"},
json={
"from": "Acme <hello@acme.com>",
"to": "delivered@simulator.zapsmtp.xyz",
"template": {
"id": "welcome",
"variables": {
"first_name": "Ann",
},
},
},
timeout=30,
)
res.raise_for_status()
print(res.json() if res.status_code != 204 else "deleted")<?php
$ch = curl_init('https://zapsmtp.xyz/v1/emails');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('ZAP_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'from' => 'Acme <hello@acme.com>',
'to' => 'delivered@simulator.zapsmtp.xyz',
'template' => [
'id' => 'welcome',
'variables' => [
'first_name' => 'Ann',
],
],
]),
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException($data['message']);
}
print_r($data);package main
import (
"fmt"
"io"
"net/http"
"os"
"strings"
)
func main() {
body := strings.NewReader(`{
"from": "Acme <hello@acme.com>",
"to": "delivered@simulator.zapsmtp.xyz",
"template": {
"id": "welcome",
"variables": {
"first_name": "Ann"
}
}
}`)
req, err := http.NewRequest(
"POST",
"https://zapsmtp.xyz/v1/emails",
body,
)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("ZAP_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(out))
}require 'net/http'
require 'json'
uri = URI('https://zapsmtp.xyz/v1/emails')
req = Net::HTTP::Post.new(uri)
req['Authorization'] = "Bearer #{ENV.fetch('ZAP_API_KEY')}"
req['Content-Type'] = 'application/json'
req.body = {
from: "Acme <hello@acme.com>",
to: "delivered@simulator.zapsmtp.xyz",
template: {
id: "welcome",
variables: {
first_name: "Ann"
}
}
}.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
puts res.code, (JSON.parse(res.body) unless res.body.to_s.empty?)import java.net.URI;
import java.net.http.*;
public class ZapRequest {
public static void main(String[] args) throws Exception {
var request = HttpRequest.newBuilder(URI.create("https://zapsmtp.xyz/v1/emails"))
.header("Authorization", "Bearer " + System.getenv("ZAP_API_KEY"))
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{
"from": "Acme <hello@acme.com>",
"to": "delivered@simulator.zapsmtp.xyz",
"template": {
"id": "welcome",
"variables": {
"first_name": "Ann"
}
}
}"""))
.build();
var response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode() + " " + response.body());
}
}using System.Net.Http.Headers;
using System.Text;
using var http = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://zapsmtp.xyz/v1/emails");
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("ZAP_API_KEY"));
request.Content = new StringContent("""
{
"from": "Acme <hello@acme.com>",
"to": "delivered@simulator.zapsmtp.xyz",
"template": {
"id": "welcome",
"variables": {
"first_name": "Ann"
}
}
}
""", Encoding.UTF8, "application/json");
var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");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.
FromEmailAddress | from |
|---|---|
Destination.ToAddresses / CcAddresses / BccAddresses | to, cc, bcc |
ReplyToAddresses | reply_to |
Content.Simple.Subject / Body.Html / Body.Text | subject, 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}] |
ConfigurationSetName | Not needed: events go to the webhooks you subscribe; tracking is set per domain or with tracking. |
Send | email.sent |
|---|---|
Delivery | email.delivered |
DeliveryDelay | email.delivery_delayed |
Bounce | email.bounced |
Complaint | email.complained |
Reject, Rendering Failure | email.failed; template errors are returned synchronously as 422 template_render_error |
Open, Click | email.opened, email.clicked |
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' }],
}));await fetch('https://zapsmtp.xyz/v1/emails', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.ZAP_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
from: 'Acme <hello@acme.com>',
to: ['jane@example.com'],
subject: 'Your receipt',
html: '<p>Thanks for your order.</p>',
tags: [{ name: 'category', value: 'receipt' }],
}),
});import boto3
ses = boto3.client("sesv2", region_name="us-east-1")
ses.send_email(
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"}],
)import os, requests
requests.post(
"https://zapsmtp.xyz/v1/emails",
headers={"Authorization": f"Bearer {os.environ['ZAP_API_KEY']}"},
json={
"from": "Acme <hello@acme.com>",
"to": ["jane@example.com"],
"subject": "Your receipt",
"html": "<p>Thanks for your order.</p>",
"tags": [{"name": "category", "value": "receipt"}],
},
timeout=30,
).raise_for_status()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.
{{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. |
<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><p>Hi {{customer.first_name}},</p>
{{#vip}}<p>Your VIP discount is applied.</p>{{/vip}}{{^vip}}<p>Join VIP for 10% off.</p>{{/vip}}
<ul>
{{#items}}<li>{{name}} × {{qty}}</li>{{/items}}
</ul>
<p><a href="{{{zap:unsubscribe_url}}}">Unsubscribe</a></p>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
subjectstringhtmlstringtextstringvariablesobject- Default
{}.
Errors
restricted_api_keyHTTP 403template_render_errorHTTP 422- <field>: <syntax error>
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
}
]
}
}'const res = await fetch('https://zapsmtp.xyz/v1/templates/render', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZAP_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"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
}
]
}
}),
});
// 204 No Content (e.g. a delete) has no body
const data = res.status === 204 ? null : await res.json();
if (!res.ok) throw new Error(`${data.name}: ${data.message}`);
console.log(data);import os
import requests
res = requests.post(
"https://zapsmtp.xyz/v1/templates/render",
headers={"Authorization": f"Bearer {os.environ['ZAP_API_KEY']}"},
json={
"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,
},
],
},
},
timeout=30,
)
res.raise_for_status()
print(res.json() if res.status_code != 204 else "deleted")<?php
$ch = curl_init('https://zapsmtp.xyz/v1/templates/render');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('ZAP_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'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,
],
],
],
]),
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException($data['message']);
}
print_r($data);package main
import (
"fmt"
"io"
"net/http"
"os"
"strings"
)
func main() {
body := strings.NewReader(`{
"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
}
]
}
}`)
req, err := http.NewRequest(
"POST",
"https://zapsmtp.xyz/v1/templates/render",
body,
)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("ZAP_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(out))
}require 'net/http'
require 'json'
uri = URI('https://zapsmtp.xyz/v1/templates/render')
req = Net::HTTP::Post.new(uri)
req['Authorization'] = "Bearer #{ENV.fetch('ZAP_API_KEY')}"
req['Content-Type'] = 'application/json'
req.body = {
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
}
]
}
}.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
puts res.code, (JSON.parse(res.body) unless res.body.to_s.empty?)import java.net.URI;
import java.net.http.*;
public class ZapRequest {
public static void main(String[] args) throws Exception {
var request = HttpRequest.newBuilder(URI.create("https://zapsmtp.xyz/v1/templates/render"))
.header("Authorization", "Bearer " + System.getenv("ZAP_API_KEY"))
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{
"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
}
]
}
}"""))
.build();
var response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode() + " " + response.body());
}
}using System.Net.Http.Headers;
using System.Text;
using var http = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://zapsmtp.xyz/v1/templates/render");
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("ZAP_API_KEY"));
request.Content = new StringContent("""
{
"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
}
]
}
}
""", Encoding.UTF8, "application/json");
var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");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.
// 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
};
}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",
}
def from_zap(event: dict) -> dict | None:
kind = ZAP.get(event["type"])
if kind is None:
return None
data = event["data"]
return {
"kind": kind,
"email": data["to"], # a single address
"message_id": data["email_id"],
"permanent": (data.get("bounce") or {}).get("type") == "Permanent",
"meta": data.get("metadata") or {},
}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. Defaultall event types. include_contentbooleanemail.receivedcarries 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). Defaultfalse.batchboolean- Send events up to 100 per request (or every second) as a JSON array, signed as one body;
Zap-Event-Idis the batch id, each event keeps its ownid.email.receivedwith the full message still arrives alone. Defaultfalse. filterobject- Narrows
email.clickedto 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 receivesemail.clickedonly 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 403test_key_restrictedHTTP 403- test-mode keys can send test email and read, not change the account: use a live key
invalid_parameterHTTP 422
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"
]
}'const res = await fetch('https://zapsmtp.xyz/v1/webhooks', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZAP_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"url": "https://acme.com/hooks/zapsmtp",
"events": [
"email.delivered",
"email.delivery_delayed",
"email.bounced",
"email.complained",
"email.suppressed",
"email.failed"
]
}),
});
// 204 No Content (e.g. a delete) has no body
const data = res.status === 204 ? null : await res.json();
if (!res.ok) throw new Error(`${data.name}: ${data.message}`);
console.log(data);import os
import requests
res = requests.post(
"https://zapsmtp.xyz/v1/webhooks",
headers={"Authorization": f"Bearer {os.environ['ZAP_API_KEY']}"},
json={
"url": "https://acme.com/hooks/zapsmtp",
"events": [
"email.delivered",
"email.delivery_delayed",
"email.bounced",
"email.complained",
"email.suppressed",
"email.failed",
],
},
timeout=30,
)
res.raise_for_status()
print(res.json() if res.status_code != 204 else "deleted")<?php
$ch = curl_init('https://zapsmtp.xyz/v1/webhooks');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('ZAP_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'url' => 'https://acme.com/hooks/zapsmtp',
'events' => [
'email.delivered',
'email.delivery_delayed',
'email.bounced',
'email.complained',
'email.suppressed',
'email.failed',
],
]),
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException($data['message']);
}
print_r($data);package main
import (
"fmt"
"io"
"net/http"
"os"
"strings"
)
func main() {
body := strings.NewReader(`{
"url": "https://acme.com/hooks/zapsmtp",
"events": [
"email.delivered",
"email.delivery_delayed",
"email.bounced",
"email.complained",
"email.suppressed",
"email.failed"
]
}`)
req, err := http.NewRequest(
"POST",
"https://zapsmtp.xyz/v1/webhooks",
body,
)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("ZAP_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(out))
}require 'net/http'
require 'json'
uri = URI('https://zapsmtp.xyz/v1/webhooks')
req = Net::HTTP::Post.new(uri)
req['Authorization'] = "Bearer #{ENV.fetch('ZAP_API_KEY')}"
req['Content-Type'] = 'application/json'
req.body = {
url: "https://acme.com/hooks/zapsmtp",
events: [
"email.delivered",
"email.delivery_delayed",
"email.bounced",
"email.complained",
"email.suppressed",
"email.failed"
]
}.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
puts res.code, (JSON.parse(res.body) unless res.body.to_s.empty?)import java.net.URI;
import java.net.http.*;
public class ZapRequest {
public static void main(String[] args) throws Exception {
var request = HttpRequest.newBuilder(URI.create("https://zapsmtp.xyz/v1/webhooks"))
.header("Authorization", "Bearer " + System.getenv("ZAP_API_KEY"))
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{
"url": "https://acme.com/hooks/zapsmtp",
"events": [
"email.delivered",
"email.delivery_delayed",
"email.bounced",
"email.complained",
"email.suppressed",
"email.failed"
]
}"""))
.build();
var response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode() + " " + response.body());
}
}using System.Net.Http.Headers;
using System.Text;
using var http = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://zapsmtp.xyz/v1/webhooks");
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("ZAP_API_KEY"));
request.Content = new StringContent("""
{
"url": "https://acme.com/hooks/zapsmtp",
"events": [
"email.delivered",
"email.delivery_delayed",
"email.bounced",
"email.complained",
"email.suppressed",
"email.failed"
]
}
""", Encoding.UTF8, "application/json");
var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");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.
// 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
}# python import_suppressions.py suppressed.txt (one address per line)
import os, sys, time, requests
API = "https://zapsmtp.xyz/v1"
with open(sys.argv[1]) as f:
emails = sorted({line.strip().lower() for line in f if line.strip()})
s = requests.Session()
s.headers["Authorization"] = f"Bearer {os.environ['ZAP_API_KEY']}"
for i in range(0, len(emails), 1000):
chunk = emails[i:i + 1000]
while True:
r = s.post(f"{API}/suppressions/batch",
json={"emails": chunk, "reason": "manual"}, timeout=60)
if r.status_code == 429 or r.status_code >= 500:
time.sleep(int(r.headers.get("Retry-After", "1")))
continue
r.raise_for_status()
print(f"rows {i}-{i + len(chunk)} added {r.json()['added']}")
break
time.sleep(0.2) # stay well under the rate limitImport 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.
reasonstringmanual: no email at all;unsubscribe: no broadcast email (transactional email still goes out). One ofmanual,unsubscribe. Defaultmanual.
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.
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"
}'const res = await fetch('https://zapsmtp.xyz/v1/suppressions/batch', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZAP_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"emails": [
"old-bounce@example.com",
"complainer@example.net"
],
"reason": "manual"
}),
});
// 204 No Content (e.g. a delete) has no body
const data = res.status === 204 ? null : await res.json();
if (!res.ok) throw new Error(`${data.name}: ${data.message}`);
console.log(data);import os
import requests
res = requests.post(
"https://zapsmtp.xyz/v1/suppressions/batch",
headers={"Authorization": f"Bearer {os.environ['ZAP_API_KEY']}"},
json={
"emails": [
"old-bounce@example.com",
"complainer@example.net",
],
"reason": "manual",
},
timeout=30,
)
res.raise_for_status()
print(res.json() if res.status_code != 204 else "deleted")<?php
$ch = curl_init('https://zapsmtp.xyz/v1/suppressions/batch');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('ZAP_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'emails' => [
'old-bounce@example.com',
'complainer@example.net',
],
'reason' => 'manual',
]),
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException($data['message']);
}
print_r($data);package main
import (
"fmt"
"io"
"net/http"
"os"
"strings"
)
func main() {
body := strings.NewReader(`{
"emails": [
"old-bounce@example.com",
"complainer@example.net"
],
"reason": "manual"
}`)
req, err := http.NewRequest(
"POST",
"https://zapsmtp.xyz/v1/suppressions/batch",
body,
)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("ZAP_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(out))
}require 'net/http'
require 'json'
uri = URI('https://zapsmtp.xyz/v1/suppressions/batch')
req = Net::HTTP::Post.new(uri)
req['Authorization'] = "Bearer #{ENV.fetch('ZAP_API_KEY')}"
req['Content-Type'] = 'application/json'
req.body = {
emails: [
"old-bounce@example.com",
"complainer@example.net"
],
reason: "manual"
}.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
puts res.code, (JSON.parse(res.body) unless res.body.to_s.empty?)import java.net.URI;
import java.net.http.*;
public class ZapRequest {
public static void main(String[] args) throws Exception {
var request = HttpRequest.newBuilder(URI.create("https://zapsmtp.xyz/v1/suppressions/batch"))
.header("Authorization", "Bearer " + System.getenv("ZAP_API_KEY"))
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{
"emails": [
"old-bounce@example.com",
"complainer@example.net"
],
"reason": "manual"
}"""))
.build();
var response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode() + " " + response.body());
}
}using System.Net.Http.Headers;
using System.Text;
using var http = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://zapsmtp.xyz/v1/suppressions/batch");
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("ZAP_API_KEY"));
request.Content = new StringContent("""
{
"emails": [
"old-bounce@example.com",
"complainer@example.net"
],
"reason": "manual"
}
""", Encoding.UTF8, "application/json");
var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");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.
reasonstringmanual: no email at all;unsubscribe: no broadcast email (transactional email still goes out). One ofmanual,unsubscribe. Defaultmanual.
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.
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"
}'const res = await fetch('https://zapsmtp.xyz/v1/suppressions/batch', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZAP_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"emails": [
"opted-out@example.com"
],
"reason": "unsubscribe"
}),
});
// 204 No Content (e.g. a delete) has no body
const data = res.status === 204 ? null : await res.json();
if (!res.ok) throw new Error(`${data.name}: ${data.message}`);
console.log(data);import os
import requests
res = requests.post(
"https://zapsmtp.xyz/v1/suppressions/batch",
headers={"Authorization": f"Bearer {os.environ['ZAP_API_KEY']}"},
json={
"emails": [
"opted-out@example.com",
],
"reason": "unsubscribe",
},
timeout=30,
)
res.raise_for_status()
print(res.json() if res.status_code != 204 else "deleted")<?php
$ch = curl_init('https://zapsmtp.xyz/v1/suppressions/batch');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('ZAP_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'emails' => [
'opted-out@example.com',
],
'reason' => 'unsubscribe',
]),
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException($data['message']);
}
print_r($data);package main
import (
"fmt"
"io"
"net/http"
"os"
"strings"
)
func main() {
body := strings.NewReader(`{
"emails": [
"opted-out@example.com"
],
"reason": "unsubscribe"
}`)
req, err := http.NewRequest(
"POST",
"https://zapsmtp.xyz/v1/suppressions/batch",
body,
)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("ZAP_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(out))
}require 'net/http'
require 'json'
uri = URI('https://zapsmtp.xyz/v1/suppressions/batch')
req = Net::HTTP::Post.new(uri)
req['Authorization'] = "Bearer #{ENV.fetch('ZAP_API_KEY')}"
req['Content-Type'] = 'application/json'
req.body = {
emails: [
"opted-out@example.com"
],
reason: "unsubscribe"
}.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
puts res.code, (JSON.parse(res.body) unless res.body.to_s.empty?)import java.net.URI;
import java.net.http.*;
public class ZapRequest {
public static void main(String[] args) throws Exception {
var request = HttpRequest.newBuilder(URI.create("https://zapsmtp.xyz/v1/suppressions/batch"))
.header("Authorization", "Bearer " + System.getenv("ZAP_API_KEY"))
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{
"emails": [
"opted-out@example.com"
],
"reason": "unsubscribe"
}"""))
.build();
var response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode() + " " + response.body());
}
}using System.Net.Http.Headers;
using System.Text;
using var http = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://zapsmtp.xyz/v1/suppressions/batch");
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("ZAP_API_KEY"));
request.Content = new StringContent("""
{
"emails": [
"opted-out@example.com"
],
"reason": "unsubscribe"
}
""", Encoding.UTF8, "application/json");
var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");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.
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"; one SPF record, both includes (SPF allows one record per name)
acme.com. TXT "v=spf1 include:<old provider> include:_spf.zapsmtp.xyz ~all"
; keep the old DKIM key and add ZapSMTP's: different selectors coexist
<old selector>._domainkey.acme.com. CNAME/TXT <old provider's DKIM key>
zap._domainkey.acme.com. TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkq…"
; DMARC unchanged: keep your existing policy and reports
_dmarc.acme.com. TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@acme.com"acme.com. TXT "v=spf1 include:_spf.zapsmtp.xyz ~all"
zap._domainkey.acme.com. TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkq…"
_dmarc.acme.com. TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@acme.com"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 403test_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
curl -X POST 'https://zapsmtp.xyz/v1/domains' \
-H "Authorization: Bearer $ZAP_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"name": "acme.com"
}'const res = await fetch('https://zapsmtp.xyz/v1/domains', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZAP_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"name": "acme.com"
}),
});
// 204 No Content (e.g. a delete) has no body
const data = res.status === 204 ? null : await res.json();
if (!res.ok) throw new Error(`${data.name}: ${data.message}`);
console.log(data);import os
import requests
res = requests.post(
"https://zapsmtp.xyz/v1/domains",
headers={"Authorization": f"Bearer {os.environ['ZAP_API_KEY']}"},
json={
"name": "acme.com",
},
timeout=30,
)
res.raise_for_status()
print(res.json() if res.status_code != 204 else "deleted")<?php
$ch = curl_init('https://zapsmtp.xyz/v1/domains');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('ZAP_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'name' => 'acme.com',
]),
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException($data['message']);
}
print_r($data);package main
import (
"fmt"
"io"
"net/http"
"os"
"strings"
)
func main() {
body := strings.NewReader(`{
"name": "acme.com"
}`)
req, err := http.NewRequest(
"POST",
"https://zapsmtp.xyz/v1/domains",
body,
)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("ZAP_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(out))
}require 'net/http'
require 'json'
uri = URI('https://zapsmtp.xyz/v1/domains')
req = Net::HTTP::Post.new(uri)
req['Authorization'] = "Bearer #{ENV.fetch('ZAP_API_KEY')}"
req['Content-Type'] = 'application/json'
req.body = {
name: "acme.com"
}.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
puts res.code, (JSON.parse(res.body) unless res.body.to_s.empty?)import java.net.URI;
import java.net.http.*;
public class ZapRequest {
public static void main(String[] args) throws Exception {
var request = HttpRequest.newBuilder(URI.create("https://zapsmtp.xyz/v1/domains"))
.header("Authorization", "Bearer " + System.getenv("ZAP_API_KEY"))
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{
"name": "acme.com"
}"""))
.build();
var response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode() + " " + response.body());
}
}using System.Net.Http.Headers;
using System.Text;
using var http = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://zapsmtp.xyz/v1/domains");
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("ZAP_API_KEY"));
request.Content = new StringContent("""
{
"name": "acme.com"
}
""", Encoding.UTF8, "application/json");
var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");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
htmlis given, a plain-text part is generated from it (readable text, links astext (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
contentorpath, 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. Defaulttransactional (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.
globalis reserved: global variables come from the account.
tlsstringrequiredorverified: 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 409idempotency_request_in_progressHTTP 409payload_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 422invalid_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 422template_render_errorHTTP 422template_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)
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."
}'const res = await fetch('https://zapsmtp.xyz/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZAP_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"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."
}),
});
// 204 No Content (e.g. a delete) has no body
const data = res.status === 204 ? null : await res.json();
if (!res.ok) throw new Error(`${data.name}: ${data.message}`);
console.log(data);import os
import requests
res = requests.post(
"https://zapsmtp.xyz/v1/emails",
headers={"Authorization": f"Bearer {os.environ['ZAP_API_KEY']}"},
json={
"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.",
},
timeout=30,
)
res.raise_for_status()
print(res.json() if res.status_code != 204 else "deleted")<?php
$ch = curl_init('https://zapsmtp.xyz/v1/emails');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('ZAP_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'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.',
]),
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException($data['message']);
}
print_r($data);package main
import (
"fmt"
"io"
"net/http"
"os"
"strings"
)
func main() {
body := strings.NewReader(`{
"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."
}`)
req, err := http.NewRequest(
"POST",
"https://zapsmtp.xyz/v1/emails",
body,
)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("ZAP_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(out))
}require 'net/http'
require 'json'
uri = URI('https://zapsmtp.xyz/v1/emails')
req = Net::HTTP::Post.new(uri)
req['Authorization'] = "Bearer #{ENV.fetch('ZAP_API_KEY')}"
req['Content-Type'] = 'application/json'
req.body = {
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."
}.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
puts res.code, (JSON.parse(res.body) unless res.body.to_s.empty?)import java.net.URI;
import java.net.http.*;
public class ZapRequest {
public static void main(String[] args) throws Exception {
var request = HttpRequest.newBuilder(URI.create("https://zapsmtp.xyz/v1/emails"))
.header("Authorization", "Bearer " + System.getenv("ZAP_API_KEY"))
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{
"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."
}"""))
.build();
var response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode() + " " + response.body());
}
}using System.Net.Http.Headers;
using System.Text;
using var http = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://zapsmtp.xyz/v1/emails");
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("ZAP_API_KEY"));
request.Content = new StringContent("""
{
"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."
}
""", Encoding.UTF8, "application/json");
var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");ZAP_API_KEY in your environment.Response 200 OK
{
"id": "019a1f2e-7c3b-7d41-9a52-0c4b8e6f1a23"
}// 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;
}import os, threading, requests
def send(msg: dict):
result = old_provider.send(msg) # still the source of truth
def shadow():
try:
r = requests.post("https://zapsmtp.xyz/v1/emails", timeout=10,
headers={"Authorization": f"Bearer {os.environ['ZAP_TEST_KEY']}"},
json=to_zap(msg))
if not r.ok:
log.warning("zapsmtp shadow %s %s", r.status_code, r.text)
except requests.RequestException as e:
log.warning("zapsmtp shadow %s", e)
threading.Thread(target=shadow, daemon=True).start()
return result3. 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.
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
}import hashlib, os, requests
def bucket(email: str) -> int:
return int.from_bytes(hashlib.sha256(email.lower().encode()).digest()[:2], "big") % 100
def send(msg: dict, idempotency_key: str):
if bucket(msg["to"]) >= int(os.environ.get("ZAP_PERCENT", "0")):
return old_provider.send(msg)
r = requests.post("https://zapsmtp.xyz/v1/emails", timeout=30, json=to_zap(msg), headers={
"Authorization": f"Bearer {os.environ['ZAP_API_KEY']}",
"Idempotency-Key": idempotency_key,
})
if r.ok:
return r.json()
if r.status_code == 429 or r.status_code >= 500: # nothing was accepted
return old_provider.send(msg)
raise RuntimeError(f"ZapSMTP {r.status_code}: {r.text}") # 4xx: fix the requestBetween 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
422validation errors,429withRetry-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 403invalid_parameterHTTP 422
curl 'https://zapsmtp.xyz/v1/suppressions?limit=1000' \
-H "Authorization: Bearer $ZAP_API_KEY"const res = await fetch('https://zapsmtp.xyz/v1/suppressions?limit=1000', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.ZAP_API_KEY}`,
},
});
// 204 No Content (e.g. a delete) has no body
const data = res.status === 204 ? null : await res.json();
if (!res.ok) throw new Error(`${data.name}: ${data.message}`);
console.log(data);import os
import requests
res = requests.get(
"https://zapsmtp.xyz/v1/suppressions?limit=1000",
headers={"Authorization": f"Bearer {os.environ['ZAP_API_KEY']}"},
timeout=30,
)
res.raise_for_status()
print(res.json() if res.status_code != 204 else "deleted")<?php
$ch = curl_init('https://zapsmtp.xyz/v1/suppressions?limit=1000');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('ZAP_API_KEY'),
],
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException($data['message']);
}
print_r($data);package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest(
"GET",
"https://zapsmtp.xyz/v1/suppressions?limit=1000",
nil,
)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("ZAP_API_KEY"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(out))
}require 'net/http'
require 'json'
uri = URI('https://zapsmtp.xyz/v1/suppressions?limit=1000')
req = Net::HTTP::Get.new(uri)
req['Authorization'] = "Bearer #{ENV.fetch('ZAP_API_KEY')}"
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
puts res.code, (JSON.parse(res.body) unless res.body.to_s.empty?)import java.net.URI;
import java.net.http.*;
public class ZapRequest {
public static void main(String[] args) throws Exception {
var request = HttpRequest.newBuilder(URI.create("https://zapsmtp.xyz/v1/suppressions?limit=1000"))
.header("Authorization", "Bearer " + System.getenv("ZAP_API_KEY"))
.method("GET", HttpRequest.BodyPublishers.noBody())
.build();
var response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode() + " " + response.body());
}
}using System.Net.Http.Headers;
using System.Text;
using var http = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Get, "https://zapsmtp.xyz/v1/suppressions?limit=1000");
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("ZAP_API_KEY"));
var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");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"
}