Send an email
POST/v1/emails
Send an email (or schedule it with scheduled_at).
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>",
"tags": [
{
"name": "category",
"value": "receipt"
}
],
"metadata": {
"order_id": "A-1042"
}
}'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>",
"tags": [
{
"name": "category",
"value": "receipt"
}
],
"metadata": {
"order_id": "A-1042"
}
}),
});
// 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>",
"tags": [
{
"name": "category",
"value": "receipt",
},
],
"metadata": {
"order_id": "A-1042",
},
},
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>',
'tags' => [
[
'name' => 'category',
'value' => 'receipt',
],
],
'metadata' => [
'order_id' => 'A-1042',
],
]),
]);
$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>",
"tags": [
{
"name": "category",
"value": "receipt"
}
],
"metadata": {
"order_id": "A-1042"
}
}`)
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>",
tags: [
{
name: "category",
value: "receipt"
}
],
metadata: {
order_id: "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",
"html": "<p>Thanks for your order.</p>",
"tags": [
{
"name": "category",
"value": "receipt"
}
],
"metadata": {
"order_id": "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",
"html": "<p>Thanks for your order.</p>",
"tags": [
{
"name": "category",
"value": "receipt"
}
],
"metadata": {
"order_id": "A-1042"
}
}
""", 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"
}