Everything POST /emails can do, with a working example for each. The examples send to the simulator: paste a test key and press Try it (the attachment-by-URL example points at a placeholder URL and has no Try it). Field names follow Resend, so an existing Resend integration only needs a new base URL and key.
Base URL https://zapsmtp.xyz/v1. Send Authorization: Bearer <key> and a JSON body. Unknown fields are rejected with 400 validation_error, so a misspelled field name is not silently ignored. The whole request, attachments included, may be up to 40 MB.
Addresses use the RFC 5322 format: a bare jane@example.org or a display name with the address in angle brackets, Jane Doe <jane@example.org>. Put a display name that contains a comma or other punctuation in double quotes. Non-ASCII names are encoded for you.
Address fields
fromrequired
One address. With a live key its domain must be verified in your account (403 domain_not_verified otherwise); a test key accepts any domain. When you send only an address, the domain’s brand from_name becomes the display name. May be omitted when the template defines a sender.
torequired
A string or an array. At least one address.
cc, bcc
A string or an array. Bcc addresses receive the email but are never written into its headers.
reply_to
A string or an array: where replies go instead of from.
Limit
At most 50 addresses across to, cc and bcc combined (422 invalid_parameter, “too many recipients (max 50)”). Recipients are lowercased and duplicates are sent once.
Invalid address
422 invalid_parameter naming the field and the address, e.g. invalid to address "jane@".
One email to several recipients is one message: everyone sees the same To and Cc headers. To send each person their own copy, use a batch with one recipient per email.
to, cc, bcc and reply_to take a string or an array; from takes one string. Display names are optional; Bcc recipients get the email but are not written into its headers.
POST/v1/emails
curl -X POST 'https://zapsmtp.xyz/v1/emails' \
-H "Authorization: Bearer $ZAP_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"from": "Acme <hello@acme.com>",
"to": [
"Jane Doe <delivered+jane@simulator.zapsmtp.xyz>"
],
"cc": "\"Doe, John\" <delivered+john@simulator.zapsmtp.xyz>",
"bcc": [
"delivered+audit@simulator.zapsmtp.xyz"
],
"reply_to": "Acme Support <support@acme.com>",
"subject": "Your order has shipped",
"text": "Your order A-1042 is on its way."
}'
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 Doe <delivered+jane@simulator.zapsmtp.xyz>"
],
"cc": "\"Doe, John\" <delivered+john@simulator.zapsmtp.xyz>",
"bcc": [
"delivered+audit@simulator.zapsmtp.xyz"
],
"reply_to": "Acme Support <support@acme.com>",
"subject": "Your order has shipped",
"text": "Your order A-1042 is on its way."
}),
});
// 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": [
"Jane Doe <delivered+jane@simulator.zapsmtp.xyz>",
],
"cc": "\"Doe, John\" <delivered+john@simulator.zapsmtp.xyz>",
"bcc": [
"delivered+audit@simulator.zapsmtp.xyz",
],
"reply_to": "Acme Support <support@acme.com>",
"subject": "Your order has shipped",
"text": "Your order A-1042 is on its way.",
},
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' => [
'Jane Doe <delivered+jane@simulator.zapsmtp.xyz>',
],
'cc' => '"Doe, John" <delivered+john@simulator.zapsmtp.xyz>',
'bcc' => [
'delivered+audit@simulator.zapsmtp.xyz',
],
'reply_to' => 'Acme Support <support@acme.com>',
'subject' => 'Your order has shipped',
'text' => 'Your order A-1042 is on its way.',
]),
]);
$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": [
"Jane Doe <delivered+jane@simulator.zapsmtp.xyz>"
],
"cc": "\"Doe, John\" <delivered+john@simulator.zapsmtp.xyz>",
"bcc": [
"delivered+audit@simulator.zapsmtp.xyz"
],
"reply_to": "Acme Support <support@acme.com>",
"subject": "Your order has shipped",
"text": "Your order A-1042 is on its way."
}`)
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: [
"Jane Doe <delivered+jane@simulator.zapsmtp.xyz>"
],
cc: "\"Doe, John\" <delivered+john@simulator.zapsmtp.xyz>",
bcc: [
"delivered+audit@simulator.zapsmtp.xyz"
],
reply_to: "Acme Support <support@acme.com>",
subject: "Your order has shipped",
text: "Your order A-1042 is on its way."
}.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": [
"Jane Doe <delivered+jane@simulator.zapsmtp.xyz>"
],
"cc": "\"Doe, John\" <delivered+john@simulator.zapsmtp.xyz>",
"bcc": [
"delivered+audit@simulator.zapsmtp.xyz"
],
"reply_to": "Acme Support <support@acme.com>",
"subject": "Your order has shipped",
"text": "Your order A-1042 is on its way."
}"""))
.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": [
"Jane Doe <delivered+jane@simulator.zapsmtp.xyz>"
],
"cc": "\"Doe, John\" <delivered+john@simulator.zapsmtp.xyz>",
"bcc": [
"delivered+audit@simulator.zapsmtp.xyz"
],
"reply_to": "Acme Support <support@acme.com>",
"subject": "Your order has shipped",
"text": "Your order A-1042 is on its way."
}
""", Encoding.UTF8, "application/json");
var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
Send html, text or both; at least one is required (422 missing_required_field, “html or text is required”). subject is required and may not be blank. Some clients and many spam filters look at the text part, so an email with html and no text gets one generated from the HTML: links become “text (url)”, and hidden elements such as the preheader, styles and scripts are left out. Send your own text when you want to control its wording. Messages sent through SMTP relay go out exactly as given.
What is sent
text only
A single text/plain part (UTF-8).
html only
multipart/alternative with a text part generated from the HTML, then the HTML.
Both
multipart/alternative with the text first and the HTML second.
template
Instead of html and text; see Templates. A template without a text body gets a generated text part too.
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 sign-in code",
text: "Your sign-in code is 482913. It expires in 10 minutes."
}.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 sign-in code",
"text": "Your sign-in code is 482913. It expires in 10 minutes."
}"""))
.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 sign-in code",
"text": "Your sign-in code is 482913. It expires in 10 minutes."
}
""", Encoding.UTF8, "application/json");
var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
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: "Welcome to Acme",
html: "<h1>Welcome, Jane</h1><p>Your workspace is ready. <a href=\"https://acme.com/start\">Get started</a>.</p>",
text: "Welcome, Jane\n\nYour workspace is ready. Get started: https://acme.com/start"
}.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": "Welcome to Acme",
"html": "<h1>Welcome, Jane</h1><p>Your workspace is ready. <a href=\"https://acme.com/start\">Get started</a>.</p>",
"text": "Welcome, Jane\n\nYour workspace is ready. Get started: https://acme.com/start"
}"""))
.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": "Welcome to Acme",
"html": "<h1>Welcome, Jane</h1><p>Your workspace is ready. <a href=\"https://acme.com/start\">Get started</a>.</p>",
"text": "Welcome, Jane\n\nYour workspace is ready. Get started: https://acme.com/start"
}
""", Encoding.UTF8, "application/json");
var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
Pass template: {"id": "welcome", "variables": {"first_name": "Jane"}} to render a stored template by id or alias instead of sending html or text (combining them returns 422 invalid_parameter). Sends always use the published version. Every variable the template uses must be supplied, otherwise 422 template_render_error lists the missing ones. A subject or from in the request overrides the template’s. See Templates and the template editor.
Each item in attachments carries the file either inline in content or as a URL in path, never both.
Attachment fields
filenamerequired
The name the recipient sees. With path it may be omitted: the last segment of the URL is used.
content
The file, standard base64 with padding.
path
An http(s) URL we download when you send. The download must answer 200 within 20 seconds. Private and internal addresses are refused.
content_type
MIME type. When omitted it comes from the file extension, or from the server’s Content-Type for a path.
content_id
Makes the file inline: reference it in the HTML as cid:<content_id>. In an email without html it is sent as an ordinary attachment.
Size
The request body is limited to 40 MB (413 payload_too_large); base64 makes a file about a third larger, so plan on roughly 30 MB of files. Files fetched by path may add up to 40 MB per request, a batch included.
Errors
422 invalid_attachment with the index, e.g. attachments[0]: content must be base64, filename is required, set either content or path, not both, fetching path returned HTTP 404.
Receiving servers often refuse messages over 20–25 MB, whatever we accept. For large files, send a link instead. Attachments of a sent email can be read back with GET /emails/{id}/attachments.
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 export",
text: "The export you asked for is attached.",
attachments: [
{
filename: "orders.csv",
content: "b3JkZXIsdG90YWwKQS0xMDQyLDQyLjAwCg==",
content_type: "text/csv"
}
]
}.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 export",
"text": "The export you asked for is attached.",
"attachments": [
{
"filename": "orders.csv",
"content": "b3JkZXIsdG90YWwKQS0xMDQyLDQyLjAwCg==",
"content_type": "text/csv"
}
]
}"""))
.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 export",
"text": "The export you asked for is attached.",
"attachments": [
{
"filename": "orders.csv",
"content": "b3JkZXIsdG90YWwKQS0xMDQyLDQyLjAwCg==",
"content_type": "text/csv"
}
]
}
""", Encoding.UTF8, "application/json");
var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
Give the attachment a content_id and reference it from the HTML as cid:<content_id>. The image travels with the email, so it shows even when the client blocks remote images.
headers is an object of header names to string values. Headers that we build or that receivers trust for authentication cannot be set and return 422 invalid_parameter (“header … cannot be set”):
From, To, Cc, Bcc, Reply-To, Subject, Date, Message-ID, MIME-Version, Content-Type, Content-Transfer-Encoding: use the body fields instead.
Setting In-Reply-To and References to the Message-ID of the email you answer keeps the conversation together in the recipient’s client, and the response’s thread_id then names that conversation. On the broadcast stream, a List-Unsubscribe header of your own replaces the one we add.
An array of {"name", "value"}. Both match ^[A-Za-z0-9_-]{1,256}$; at most 20 per email. Use them for categories you report on: they come back in webhooks as an object (data.tags, e.g. {"category": "receipt"}). On email.opened and email.clicked they are left out when the email’s tags, encoded, exceed 200 bytes and split delivery numbers in GET /metrics?tag=category=receipt.
metadata
An object of strings: at most 10 keys, keys match ^[A-Za-z0-9_-]{1,40}$, values up to 500 bytes (UTF-8). Use it for your own ids. It comes back in delivery webhooks (data.metadata), on email.opened and email.clicked when its JSON fits in 200 bytes (not on reply events), and on GET /emails/{id}, and filters the email list with GET /emails?metadata[key]=value.
Invalid tags or metadata return 422 invalid_parameter.
Tags group email in metrics; metadata ties an email to your own records. Both come back on the email’s delivery webhooks; see the table above for the exceptions.
const res = await fetch('https://zapsmtp.xyz/v1/emails?metadata%5Border_id%5D=A-1042', {
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/emails?metadata%5Border_id%5D=A-1042",
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")
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?metadata%5Border_id%5D=A-1042"))
.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/emails?metadata%5Border_id%5D=A-1042");
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()}");
A timeout does not tell you whether the email was sent. Send an Idempotency-Key header (1–256 bytes) with POST /emails or POST /emails/batch, and a retry with the same key within 24 hours returns the first result instead of sending again. Keys are scoped to your API key and kept for 24 hours, together with a fingerprint of the request.
A second request with the same key
Same body, first request finished
The original status and body come back, with the header Idempotent-Replayed: true. Nothing is sent again.
Different body
409 idempotency_key_reused
First request still running
409 idempotency_request_in_progress: wait briefly and retry.
First request got 429 or a 5xx
The key is released, so a retry with it is processed as new.
Key over 256 bytes
400 invalid_idempotency_key
Use a key that names the email in your system, such as order-1042-receipt, not a random value created per attempt. Errors from the send itself (for example 422 or 403) are stored under the key too: after fixing the request, use a new key. Errors found before the key is checked, such as malformed JSON, an unknown field, a body over 40 MB or an invalid batch size or x-batch-validation, are not stored.
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",
text: "Thanks for your order A-1042."
}.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",
"text": "Thanks for your order A-1042."
}"""))
.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",
"text": "Thanks for your order A-1042."
}
""", Encoding.UTF8, "application/json");
var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
Add scheduled_at and the email waits until then. The request is checked when you make it: addresses, content, headers, tags, metadata, stream and the sender domain, so those errors come back at once. Rate limits, quotas and suppressions are applied when the email is dispatched: a rate-limited dispatch is retried later, while other dispatch failures, such as an exhausted monthly quota, mark the email failed with the reason, and suppressed recipients are skipped as for an immediate send.
scheduled_at
RFC 3339
2026-10-28T09:00:00Z or with an offset, 2026-10-28T11:00:00+02:00.
Relative
in <n> <unit>, unit min, mins, minute, minutes, h, hour, hours, d, day or days: in 30 minutes, in 2 hours, in 1 day. Other phrases (“tomorrow at 9am”) are not understood.
In the past
Sent immediately.
Limits
Up to 7 days ahead (422 invalid_parameter, “scheduled_at must be within 7 days”). At most 10,000 emails, 256 MB in total, may wait per account.
At send time
The account and the API key are checked again: a suspended account or a revoked or expired key fails the email, and GET /emails/{id} shows failed with the reason.
While it waits, GET /emails/{id} returns status: "scheduled" with its scheduled_at, and GET /emails?status=scheduled lists waiting emails, soonest first: up to limit of them (default 20, at most 100). That list has no further pages (has_more is always false), so with more waiting emails than the limit you see only the soonest. Rescheduling and canceling work until the email is dispatched; after that, and for unknown ids, they return 409 not_cancellable. A key restricted to a domain or stream can only manage its own scheduled emails, and a test key cannot touch live ones (403 restricted_api_key).
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 trial ends tomorrow",
text: "Your Acme trial ends tomorrow. Upgrade any time from Settings.",
scheduled_at: "in 1 hour"
}.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 trial ends tomorrow",
"text": "Your Acme trial ends tomorrow. Upgrade any time from Settings.",
"scheduled_at": "in 1 hour"
}"""))
.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 trial ends tomorrow",
"text": "Your Acme trial ends tomorrow. Upgrade any time from Settings.",
"scheduled_at": "in 1 hour"
}
""", Encoding.UTF8, "application/json");
var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
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/019a1f40-2b3c-7d4e-8f5a-6b7c8d9e0f12"))
.header("Authorization", "Bearer " + System.getenv("ZAP_API_KEY"))
.header("Content-Type", "application/json")
.method("PATCH", HttpRequest.BodyPublishers.ofString("""
{
"scheduled_at": "2026-10-28T09:00:00Z"
}"""))
.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.Patch, "https://zapsmtp.xyz/v1/emails/019a1f40-2b3c-7d4e-8f5a-6b7c8d9e0f12");
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("ZAP_API_KEY"));
request.Content = new StringContent("""
{
"scheduled_at": "2026-10-28T09:00:00Z"
}
""", Encoding.UTF8, "application/json");
var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
The email is never sent and its status becomes canceled.
Path parameters
idstringrequired
Scheduled email id
POST/v1/emails/{id}/cancel
curl -X POST 'https://zapsmtp.xyz/v1/emails/019a1f40-2b3c-7d4e-8f5a-6b7c8d9e0f12/cancel' \
-H "Authorization: Bearer $ZAP_API_KEY"
const res = await fetch('https://zapsmtp.xyz/v1/emails/019a1f40-2b3c-7d4e-8f5a-6b7c8d9e0f12/cancel', {
method: 'POST',
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.post(
"https://zapsmtp.xyz/v1/emails/019a1f40-2b3c-7d4e-8f5a-6b7c8d9e0f12/cancel",
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")
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/019a1f40-2b3c-7d4e-8f5a-6b7c8d9e0f12/cancel"))
.header("Authorization", "Bearer " + System.getenv("ZAP_API_KEY"))
.method("POST", 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.Post, "https://zapsmtp.xyz/v1/emails/019a1f40-2b3c-7d4e-8f5a-6b7c8d9e0f12/cancel");
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()}");
POST /emails/batch takes an array of 1 to 100 emails (422 invalid_parameter otherwise), each with the fields of a single send, and sends them concurrently. The 40 MB body limit applies to the whole batch. One Idempotency-Key covers the whole request.
x-batch-validation
strictdefault
Every email is validated first. If any is invalid, nothing is sent and the response is 422 validation_error with an errors array of {index, code, message}.
permissive
Valid emails are sent; invalid ones appear in errors and the response is 200.
A 200 response holds data, one {index, id} per accepted email (plus suppressed when some of its recipients were skipped), and errors. index is the email’s position in your array. Failures that happen at send time, such as rate_limit_exceeded or monthly_quota_exceeded, are reported inside errors in either mode rather than as the HTTP status, so always read errors. To retry only the failed emails, send them in a new batch with a new Idempotency-Key: repeating the old key replays the old response.
stream is transactional (the default) or broadcast. On shared IPs the two leave from separate pools, so newsletter traffic does not slow down or weigh on the reputation of your password resets. An account with a dedicated IP pool sends both streams from it. Any other value returns 422 invalid_parameter. A key restricted to one stream sends on it by default and gets 403 restricted_api_key when asked for the other.
A broadcast email to a single recipient gets RFC 8058 one-click unsubscribe headers (List-Unsubscribe and List-Unsubscribe-Post) unless you set your own; an unsubscribe adds the address to your suppressions with reason unsubscribe, which stops later broadcast mail to it but not transactional mail. Hard bounces, complaints and manual suppressions apply to both streams. For a link in the body, write {{{zap:unsubscribe_url}}} in the HTML or text: it becomes the recipient’s signed unsubscribe link when the email is sent, with or without a template.
tracking: {"opens": true, "clicks": true} overrides the From domain’s open and click tracking for one email. tls: "required" delivers only over TLS and tls: "verified" also requires a valid certificate on the receiving server; either raises the domain’s TLS policy and never lowers it.
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 News <news@acme.com>",
to: [
"delivered@simulator.zapsmtp.xyz"
],
subject: "What shipped in October",
stream: "broadcast",
html: "<p>Three new features this month.</p><p><a href=\"{{{zap:unsubscribe_url}}}\">Unsubscribe</a></p>",
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 News <news@acme.com>",
"to": [
"delivered@simulator.zapsmtp.xyz"
],
"subject": "What shipped in October",
"stream": "broadcast",
"html": "<p>Three new features this month.</p><p><a href=\"{{{zap:unsubscribe_url}}}\">Unsubscribe</a></p>",
"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 News <news@acme.com>",
"to": [
"delivered@simulator.zapsmtp.xyz"
],
"subject": "What shipped in October",
"stream": "broadcast",
"html": "<p>Three new features this month.</p><p><a href=\"{{{zap:unsubscribe_url}}}\">Unsubscribe</a></p>",
"tracking": {
"opens": true,
"clicks": true
}
}
""", Encoding.UTF8, "application/json");
var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
Keys that start with zap_test_ go through the same validation, email log, events and webhooks as live keys, accept any From domain, deliver nothing and do not count against your monthly plan (the per-second limit still applies). Addresses at simulator.zapsmtp.xyz act out outcomes with live and test keys alike: delivered@, bounced@, full@, blocked@, delayed@, complained@, suppressed@, and bounce+<code>@ for any reply you need. They accept +labels and never touch your suppression list or reputation. See Testing & simulator.
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",
"delayed@simulator.zapsmtp.xyz"
],
"subject": "Simulator run",
"text": "Testing every path."
}
""", Encoding.UTF8, "application/json");
var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
A 200 means the email was accepted, not that it was delivered. Store id with your record: the email’s delivery webhooks carry it as data.email_id, and GET /emails/{id} returns its status and timeline. Delivery results arrive by webhook.
Response fields
id
The email’s id, a time-ordered UUIDv7. Also part of the Message-ID header.
thread_id
The conversation the email belongs to. Replies to it carry the same thread_id.
suppressed
Recipients skipped because they are on your suppression list. Each gets an email.suppressed event. If every recipient is suppressed the request still succeeds: there is nothing to retry.
failed_recipients
Recipients refused while the others were taken. They are not sent and not charged.
unconfirmed
true when the message was handed over but the confirmation was lost. It counts as sent; do not retry it. Its events tell the outcome. ZapSMTP checks these sends itself: recipients the MTA never logged receiving are sent again once that MTA node has been back for 10 minutes, or after it has been down for an hour in a row. A rare duplicate is possible. A loss is extremely unlikely: ZapSMTP keeps the message to check it, and only if object storage stays unavailable for minutes is a send left unchecked (we count those).
Responses to immediate sends usually report your budget: RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset for recipients per second, and Zap-Quota-Monthly-Limit and Zap-Quota-Monthly-Remaining for the month. On paid plans without a spending cap, Zap-Quota-Monthly-Limit is unlimited and the remaining header is omitted. Scheduling responses, and accounts without a per-second limit, carry none of these headers. Every response carries an X-Request-Id; log it and include it when you contact support.
const res = await fetch('https://zapsmtp.xyz/v1/emails/019a1f2e-7c3b-7d41-9a52-0c4b8e6f1a23', {
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/emails/019a1f2e-7c3b-7d41-9a52-0c4b8e6f1a23",
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")
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/019a1f2e-7c3b-7d41-9a52-0c4b8e6f1a23"))
.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/emails/019a1f2e-7c3b-7d41-9a52-0c4b8e6f1a23");
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()}");
Errors carry the HTTP status plus name (the error code) and message; the full list is in Errors, limits & paging. Retry only what can succeed unchanged, always with the same Idempotency-Key, and back off exponentially when there is no Retry-After.
Should you retry?
rate_limit_exceededHTTP 429
Yes. Wait for Retry-After (1 second).
overloadedHTTP 429
Yes. The server is at capacity or briefly backed up and nothing was sent: wait for Retry-After (1 or 5 seconds).
service_unavailableHTTP 503
Yes. Wait for Retry-After (2–5 seconds).
internal_errorHTTP 500
Yes, with backoff and the same Idempotency-Key.
idempotency_request_in_progressHTTP 409
Yes, after a short pause: the first request with this key is still running.
Network error or timeoutno response
Yes, with the same Idempotency-Key: you cannot tell whether the first attempt was accepted.
daily_quota_exceededHTTP 429
Not until the limit resets at 00:00 UTC.
monthly_quota_exceededHTTP 429
No. The Free plan or your spending cap is reached for the month.
misdirected_cellHTTP 421
Not here. Your account is served by another cell: send to the URL named in the message (the Zap-Cell header names the cell).
idempotency_key_reusedHTTP 409
No. The key was used for a different body: fix the key, not the timing.
400, 401, 403, 413, 422HTTP 4xx
No. The request itself has to change; the message says what is wrong.
The per-second limit counts recipients across all your requests. If you send in bursts, spread them using RateLimit-Remaining rather than waiting for 429s.
Retry safely
const API = 'https://zapsmtp.xyz/v1';
// Safe to repeat with the same Idempotency-Key: the email is sent at most once.
const RETRYABLE = new Set(['rate_limit_exceeded', 'overloaded', 'idempotency_request_in_progress', 'internal_error', 'service_unavailable']);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function send(email, idempotencyKey) {
for (let attempt = 0; ; attempt++) {
let res;
try {
res = await fetch(`${API}/emails`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZAP_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(email),
});
} catch (networkError) {
if (attempt >= 5) throw networkError;
await sleep(2 ** attempt * 500); // no answer: retry with the same key
continue;
}
const body = await res.json();
if (res.ok) return body; // store body.id
if (!RETRYABLE.has(body.name) || attempt >= 5) {
throw new Error(`${res.status} ${body.name}: ${body.message}`);
}
const retryAfter = Number(res.headers.get('Retry-After'));
await sleep(retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500);
}
}
import os, time, requests
API = "https://zapsmtp.xyz/v1"
RETRYABLE = {"rate_limit_exceeded", "overloaded", "idempotency_request_in_progress", "internal_error", "service_unavailable"}
def send(email: dict, idempotency_key: str) -> dict:
for attempt in range(6):
try:
res = requests.post(
f"{API}/emails",
json=email,
headers={
"Authorization": f"Bearer {os.environ['ZAP_API_KEY']}",
"Idempotency-Key": idempotency_key,
},
timeout=30,
)
except requests.RequestException:
time.sleep(2 ** attempt * 0.5) # no answer: retry with the same key
continue
body = res.json()
if res.ok:
return body # store body["id"]
if body.get("name") not in RETRYABLE:
raise RuntimeError(f"{res.status_code} {body.get('name')}: {body.get('message')}")
time.sleep(float(res.headers.get("Retry-After") or 2 ** attempt * 0.5))
raise RuntimeError("gave up after 6 attempts")
Retries reuse one Idempotency-Key, so a timeout followed by a retry still sends once.
If your application or platform already speaks SMTP, point it at the ZapSMTP relay with SMTP credentials. Messages sent that way get the same suppression checks, events, webhooks and email log. The API is the better fit when you want attachments by URL, templates, tags and metadata, scheduling, batches and idempotency. See SMTP relay.