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

Recipes

Complete, end-to-end solutions to the jobs most apps need email for. Each recipe gives the API calls (sends, previews and reads run here with a test key; creating templates, domains, webhooks, inbound domains, API keys and thread metadata needs a full-access live key, since test keys get 403 test_key_restricted) and the application code in Node.js, Python and Go or PHP. Keys always come from environment variables and every call runs on your server.

On this page

Setup: one helper per language

The recipes share two small functions: zap() sends one API request and throws an error carrying the HTTP status, the error code (for example rate_limit_exceeded) and Retry-After; verifyZapSignature() checks a webhook’s Zap-Signature header as described in Webhook events. Save the file for your language next to your code.

Environment
ZAP_API_KEYA live key (zap_…) in production, a test key (zap_test_…) in development and CI. Test keys run the whole pipeline, accept any From domain and never deliver, but they can only send, preview templates, reply to received mail and read: one-time setup (templates, domains, webhooks, inbound domains, API keys, thread metadata, suppressions) needs a full-access live key. See API keys.
ZAP_WEBHOOK_SECRETThe whsec_… secret of your webhook endpoint, only where you receive webhooks.
zap helper
// zap.mjs: Node.js 18+ (built-in fetch). Server-side only.
import crypto from 'node:crypto';

const API = 'https://zapsmtp.xyz/v1';

export class ZapError extends Error {
  constructor(status, body, headers) {
    super(`${status} ${body?.name ?? 'error'}: ${body?.message ?? ''}`);
    this.status = status;
    this.code = body?.name;
    this.retryAfter = Number(headers.get('Retry-After')) || 0; // seconds
    this.requestId = headers.get('X-Request-Id');
  }
}

export async function zap(method, path, body, headers = {}) {
  const res = await fetch(API + path, {
    method,
    headers: {
      Authorization: `Bearer ${process.env.ZAP_API_KEY}`,
      ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
      ...headers,
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const text = await res.text();
  let data = null;
  try { data = text ? JSON.parse(text) : null; } catch {}
  if (!res.ok) throw new ZapError(res.status, data, res.headers);
  return data;
}

// Webhooks: HMAC-SHA256 of "<t>.<raw body>", keyed with the whole whsec_ secret.
export function verifyZapSignature(rawBody, header, secret = process.env.ZAP_WEBHOOK_SECRET) {
  const parts = String(header ?? '').split(',').map((p) => p.split('='));
  const t = parts.find(([k]) => k === 't')?.[1] ?? '';
  if (!/^\d+$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex');
  return parts.some(([k, v]) => k === 'v1' && v?.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(v), Buffer.from(expected)));
}
Never ship ZAP_API_KEY to a browser or a mobile app.

1. Password reset with a template and an idempotency key

What you build: a “forgot password” flow that emails a single-use, 30-minute link and can be retried safely after a timeout without sending two emails.

  1. Create and publish a password-reset template once. Sends always use the published version.
  2. Per request, generate a random token, store only its hash with an expiry, and send the template with Idempotency-Key: password-reset-<token id>.
  3. Turn off open and click tracking for this email so the link is not rewritten.

Create the reset template

POST/v1/templates

Once, at deploy time. publish: true makes it usable for sending right away.

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

Syntax: {{var}} escaped, {{{var}}} raw, {{#list}}…{{/list}} sections, {{^x}}…{{/x}} inverted sections. Exact ordering of variable_names is determined by the parser; the example order is illustrative.

Body: html and text both empty → 422 missing_required_field; template syntax errors → 422 template_render_error "<field>: <error>"; a tag that is not a variable name ({{first name}}, {{#each items}}, {{> footer}}) → 422 template_render_error "html: line L, column C: {{…}} is not a variable name: …"

variable_schema in the response is the saved list after discovery. A send is checked against the published copy, published.variable_schema.

Body parameters

namestringrequired
Limits: trimmed, 1-200 characters → 422 missing_required_field "name is required (max 200 chars)"
aliasstring
Stable handle usable instead of the id (template.id in sends, /templates/{alias}). Limits: ^[a-z0-9][a-z0-9_-]{0,62}$ → 422 invalid_parameter; duplicate → 409 conflict
subjectstring
Mustache source.
htmlstring
At least one of html and text is required. Mustache source; {{var}} is HTML-escaped, {{{var}}} raw.
textstring
fromstring
Default From used when a send omits from.
variablesobject
Sample variables used by previews. Never used for sending; a variable first found in the source takes its sample as example.
variable_schemaobject[]
Declares the template’s variables (see Variable schema). Replaces the declared list when given. On every save, names the source uses that the list lacks are added (their sample value as example): a section {{#x}} as optional any, a dotted {{x.y}} as a required object, any other name as a required any, never stricter than sends were before, and declared names the source no longer uses are kept with unused: true. Limits: at most 200 entries, no duplicates, defaults and examples of their type → 422 invalid_parameter "variable_schema: <reason>"
Show 6 fields
namestringrequired
Variable name: letters, digits, _ and -, starting with a letter or _, up to 64; global is reserved.
typestringrequired
What a send must pass. One of string, number, boolean, url, date, list, object, any.
requiredboolean
A send without a value (or with null) and without a default is refused with template_variables_missing. Default false.
defaultany
Used when a send gives no value; must be of type.
exampleany
Used by previews; must be of type.
descriptionstring
For your team; at most 500 bytes.
designobject | null
The visual editor’s document (a JSON object with a numeric version), stored with the template. null makes it a code template. Changing html without sending design drops the design (it no longer describes the HTML). Limits: max 1 MB → 422 invalid_parameter "design is larger than 1 MB"; not an object with a numeric version → 422 invalid_parameter
publishboolean
Publish the result in the same call. Without it the template stays a draft and is not used for sending until published. Default false.

Errors

restricted_api_keyHTTP 403
test_key_restrictedHTTP 403
test-mode keys can send test email and read, not change the account: use a live key
conflictHTTP 409
a template with this alias already exists
payload_too_largeHTTP 413
request body too large (over 4 MB)
missing_required_fieldHTTP 422
invalid_parameterHTTP 422
template_render_errorHTTP 422
template syntax error: "<field>: <error>"
template_render_errorHTTP 422
a tag that is not a variable name, e.g. {{first name}}, {{#each items}}, {{> footer}}: "html: line L, column C: {{…}} is not a variable name: letters, digits, _ and - (no spaces), parts joined with dots"
POST/v1/templates
curl -X POST 'https://zapsmtp.xyz/v1/templates' \
  -H "Authorization: Bearer $ZAP_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
  "name": "Password reset",
  "alias": "password-reset",
  "from": "Acme <security@acme.com>",
  "subject": "Reset your Acme password",
  "html": "<p>Hi {{first_name}},</p><p><a href=\"{{reset_url}}\">Choose a new password</a>. The link works once, for {{expires_minutes}} minutes.</p><p>Did not ask for this? Ignore this email; your password stays the same.</p>",
  "text": "Hi {{first_name}},\n\nChoose a new password: {{reset_url}}\nThe link works once, for {{expires_minutes}} minutes.\n\nDid not ask for this? Ignore this email.",
  "publish": true
}'
Set ZAP_API_KEY in your environment.
Response 201 Created
{
  "id": "019a1f23-7f80-7192-8b3c-4d5e6f7a8b9c",
  "name": "Receipt",
  "alias": "receipt",
  "subject": "Your receipt for {{order_id}}",
  "html": "<p>Hi {{name}}, thanks for order {{order_id}}.</p>",
  "text": "Hi {{name}}, thanks for order {{order_id}}.",
  "from": "Acme <billing@acme.com>",
  "variables": {
    "name": "Jane",
    "order_id": "A-1042"
  },
  "variable_schema": [
    {
      "name": "order_id",
      "type": "string",
      "required": true,
      "example": "A-1042",
      "description": "Order number shown to the customer"
    },
    {
      "name": "name",
      "type": "string",
      "required": false,
      "default": "there",
      "example": "Jane"
    }
  ],
  "design": null,
  "created_at": "2026-10-21T16:10:00Z",
  "updated_at": "2026-10-21T16:10:00Z",
  "object": "template",
  "variable_names": [
    "order_id",
    "name"
  ],
  "status": "draft",
  "has_unpublished_changes": false,
  "published_at": null,
  "published": null
}

Send the reset email

POST/v1/emails

One call per reset request. The Idempotency-Key is tied to the reset token, so a retry after a timeout returns the first answer instead of sending a second email.

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

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

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

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

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

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

Headers

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

Body parameters

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

Errors

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

// db is your own data layer.
export async function sendPasswordReset(email) {
  const user = await db.users.findByEmail(email);
  if (!user) return; // answer the caller the same way: never reveal who has an account

  const tokenId = crypto.randomUUID();
  const token = crypto.randomBytes(32).toString('base64url');
  await db.passwordResets.insert({
    id: tokenId,
    userId: user.id,
    tokenHash: crypto.createHash('sha256').update(token).digest('hex'), // store only the hash
    expiresAt: new Date(Date.now() + 30 * 60_000),
  });

  return zap('POST', '/emails', {
    from: 'Acme <security@acme.com>',
    to: [user.email],
    template: {
      id: 'password-reset',
      variables: {
        first_name: user.firstName,
        reset_url: `https://acme.com/reset?id=${tokenId}&token=${token}`,
        expires_minutes: 30,
      },
    },
    tracking: { opens: false, clicks: false }, // keep the link exactly as generated
    tags: [{ name: 'category', value: 'password-reset' }],
    metadata: { user_id: String(user.id) },
  }, { 'Idempotency-Key': `password-reset-${tokenId}` });
}
db is your own data layer.

The same key with a different body answers 409 idempotency_key_reused; a key whose first attempt got a 429 or 5xx is released and can be retried. Keys are kept for 24 hours. The email log stores the sent content for your plan’s retention, so keep reset links short-lived and single-use.

2. Order confirmation with line items and a PDF invoice

What you build: a receipt whose table repeats over the order’s items, with the invoice attached as a PDF.

  1. Create a template with a section, {{#items}}…{{/items}}, that repeats per item. Optional blocks such as a gift note use a section too and render only when the value is present.
  2. Format money as strings in your code: templates are logic-less and print numbers as they are.
  3. Preview with real data, then send with the PDF base64-encoded in attachments[].content and the order id as the idempotency key.

Create the order template

POST/v1/templates

{{#items}}…{{/items}} repeats once per line item; inside it, names resolve against the item first. {{#gift_note}} renders only when the value is present and not empty.

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

Syntax: {{var}} escaped, {{{var}}} raw, {{#list}}…{{/list}} sections, {{^x}}…{{/x}} inverted sections. Exact ordering of variable_names is determined by the parser; the example order is illustrative.

Body: html and text both empty → 422 missing_required_field; template syntax errors → 422 template_render_error "<field>: <error>"; a tag that is not a variable name ({{first name}}, {{#each items}}, {{> footer}}) → 422 template_render_error "html: line L, column C: {{…}} is not a variable name: …"

variable_schema in the response is the saved list after discovery. A send is checked against the published copy, published.variable_schema.

Body parameters

namestringrequired
Limits: trimmed, 1-200 characters → 422 missing_required_field "name is required (max 200 chars)"
aliasstring
Stable handle usable instead of the id (template.id in sends, /templates/{alias}). Limits: ^[a-z0-9][a-z0-9_-]{0,62}$ → 422 invalid_parameter; duplicate → 409 conflict
subjectstring
Mustache source.
htmlstring
At least one of html and text is required. Mustache source; {{var}} is HTML-escaped, {{{var}}} raw.
textstring
fromstring
Default From used when a send omits from.
variablesobject
Sample variables used by previews. Never used for sending; a variable first found in the source takes its sample as example.
variable_schemaobject[]
Declares the template’s variables (see Variable schema). Replaces the declared list when given. On every save, names the source uses that the list lacks are added (their sample value as example): a section {{#x}} as optional any, a dotted {{x.y}} as a required object, any other name as a required any, never stricter than sends were before, and declared names the source no longer uses are kept with unused: true. Limits: at most 200 entries, no duplicates, defaults and examples of their type → 422 invalid_parameter "variable_schema: <reason>"
Show 6 fields
namestringrequired
Variable name: letters, digits, _ and -, starting with a letter or _, up to 64; global is reserved.
typestringrequired
What a send must pass. One of string, number, boolean, url, date, list, object, any.
requiredboolean
A send without a value (or with null) and without a default is refused with template_variables_missing. Default false.
defaultany
Used when a send gives no value; must be of type.
exampleany
Used by previews; must be of type.
descriptionstring
For your team; at most 500 bytes.
designobject | null
The visual editor’s document (a JSON object with a numeric version), stored with the template. null makes it a code template. Changing html without sending design drops the design (it no longer describes the HTML). Limits: max 1 MB → 422 invalid_parameter "design is larger than 1 MB"; not an object with a numeric version → 422 invalid_parameter
publishboolean
Publish the result in the same call. Without it the template stays a draft and is not used for sending until published. Default false.

Errors

restricted_api_keyHTTP 403
test_key_restrictedHTTP 403
test-mode keys can send test email and read, not change the account: use a live key
conflictHTTP 409
a template with this alias already exists
payload_too_largeHTTP 413
request body too large (over 4 MB)
missing_required_fieldHTTP 422
invalid_parameterHTTP 422
template_render_errorHTTP 422
template syntax error: "<field>: <error>"
template_render_errorHTTP 422
a tag that is not a variable name, e.g. {{first name}}, {{#each items}}, {{> footer}}: "html: line L, column C: {{…}} is not a variable name: letters, digits, _ and - (no spaces), parts joined with dots"
POST/v1/templates
curl -X POST 'https://zapsmtp.xyz/v1/templates' \
  -H "Authorization: Bearer $ZAP_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
  "name": "Order confirmation",
  "alias": "order-confirmation",
  "from": "Acme Shop <orders@acme.com>",
  "subject": "Order {{order_id}} confirmed",
  "html": "<h1>Thanks, {{customer.first_name}}!</h1><p>Order {{order_id}}</p><table>{{#items}}<tr><td>{{name}} × {{quantity}}</td><td align=\"right\">{{price}}</td></tr>{{/items}}</table><p>Total: <strong>{{total}}</strong></p>{{#gift_note}}<p>Gift note: {{gift_note}}</p>{{/gift_note}}<p>Your invoice is attached.</p>",
  "text": "Thanks, {{customer.first_name}}!\nOrder {{order_id}}\n{{#items}}- {{name}} x {{quantity}}: {{price}}\n{{/items}}Total: {{total}}",
  "publish": true
}'
Set ZAP_API_KEY in your environment.
Response 201 Created
{
  "id": "019a1f23-7f80-7192-8b3c-4d5e6f7a8b9c",
  "name": "Receipt",
  "alias": "receipt",
  "subject": "Your receipt for {{order_id}}",
  "html": "<p>Hi {{name}}, thanks for order {{order_id}}.</p>",
  "text": "Hi {{name}}, thanks for order {{order_id}}.",
  "from": "Acme <billing@acme.com>",
  "variables": {
    "name": "Jane",
    "order_id": "A-1042"
  },
  "variable_schema": [
    {
      "name": "order_id",
      "type": "string",
      "required": true,
      "example": "A-1042",
      "description": "Order number shown to the customer"
    },
    {
      "name": "name",
      "type": "string",
      "required": false,
      "default": "there",
      "example": "Jane"
    }
  ],
  "design": null,
  "created_at": "2026-10-21T16:10:00Z",
  "updated_at": "2026-10-21T16:10:00Z",
  "object": "template",
  "variable_names": [
    "order_id",
    "name"
  ],
  "status": "draft",
  "has_unpublished_changes": false,
  "published_at": null,
  "published": null
}

Preview it with real data

POST/v1/templates/{id}/render

Renders the template without sending. A variable the template needs but the data lacks is reported here; a send would fail with 422 template_variables_missing for a required variable (template_render_error only for a template published before variable schemas, or a field missing inside an object).

Requires a full-access key.

Missing variables do not fail the preview; they are listed in missing_variables.

Path parameters

idstringrequired
Template id or alias

Body parameters

variablesobject
Variables to render with; when omitted the template's stored sample variables are used.

Errors

restricted_api_keyHTTP 403
not_foundHTTP 404
template not found
template_render_errorHTTP 422
POST/v1/templates/{id}/render
curl -X POST 'https://zapsmtp.xyz/v1/templates/order-confirmation/render' \
  -H "Authorization: Bearer $ZAP_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
  "variables": {
    "customer": {
      "first_name": "Ann"
    },
    "order_id": "A-1042",
    "items": [
      {
        "name": "Desk lamp",
        "quantity": 1,
        "price": "$49.00"
      },
      {
        "name": "LED bulb",
        "quantity": 2,
        "price": "$24.00"
      }
    ],
    "total": "$73.00"
  }
}'
Set ZAP_API_KEY in your environment.
Response 200 OK
{
  "subject": "Your receipt for A-1042",
  "html": "<p>Hi Jane, thanks for order A-1042.</p>",
  "text": "Hi Jane, thanks for order A-1042.",
  "missing_variables": [],
  "variable_names": [
    "order_id",
    "name"
  ],
  "design": null
}

Send it with the invoice

POST/v1/emails

Attachments are base64 in content (or an HTTPS URL in path that ZapSMTP downloads). The whole JSON request is capped at 40 MB, base64 included (base64 adds about a third), so the files themselves can total only about 30 MB; a batch shares one 40 MB request. Larger requests get 413 payload_too_large.

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

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

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

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

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

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

Headers

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

Body parameters

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

Errors

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

const money = (cents, currency) =>
  new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cents / 100);

export async function sendOrderConfirmation(order) {
  const pdf = await readFile(order.invoicePath); // or render it in memory
  return zap('POST', '/emails', {
    to: [order.customer.email],
    template: {
      id: 'order-confirmation', // its from and subject are used
      variables: {
        customer: { first_name: order.customer.firstName },
        order_id: order.id,
        items: order.items.map((i) => ({
          name: i.name, quantity: i.quantity, price: money(i.totalCents, order.currency),
        })),
        total: money(order.totalCents, order.currency),
        ...(order.giftNote && { gift_note: order.giftNote }),
      },
    },
    attachments: [{
      filename: `invoice-${order.id}.pdf`,
      content: pdf.toString('base64'),
      content_type: 'application/pdf',
    }],
    tags: [{ name: 'category', value: 'order' }],
    metadata: { order_id: order.id },
  }, { 'Idempotency-Key': `order-${order.id}-confirmation` });
}

A required variable the data does not contain fails the send with 422 template_variables_missing and names it (a field missing inside an object, or any variable of a template published before variable schemas, fails with 422 template_render_error). A section whose value is missing simply renders nothing. from and subject come from the template unless you pass them.

3. Welcome series: send now, day 2 and day 7, cancel on churn

What you build: three onboarding emails queued at signup, and a function that removes the unsent ones when the user unsubscribes or leaves.

  1. At signup, send one batch: the first email without scheduled_at, the others with "in 2 days" and "in 7 days".
  2. Store the returned ids on the user.
  3. On unsubscribe or churn, cancel each id; treat 409 not_cancellable as done.

Queue the whole series in one call

POST/v1/emails/batch

Each email in a batch can carry its own scheduled_at. In the default strict mode an email that fails validation stops the whole batch before anything is sent. Sending and scheduling then run per email, so a send-time failure (such as rate_limit_exceeded) affects only that email and is reported in errors[]: handle it.

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

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

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

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

Headers

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

Body parameters

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

Errors

validation_errorHTTP 400
invalid JSON or x-batch-validation not strict/permissive
invalid_idempotency_keyHTTP 400
Idempotency-Key must be 1 to 256 bytes
idempotency_key_reusedHTTP 409
idempotency_request_in_progressHTTP 409
payload_too_largeHTTP 413
invalid_parameterHTTP 422
batch size outside 1-100
validation_errorHTTP 422
strict mode, any email invalid
service_unavailableHTTP 503
too many large bodies in flight
POST/v1/emails/batch
curl -X POST 'https://zapsmtp.xyz/v1/emails/batch' \
  -H "Authorization: Bearer $ZAP_API_KEY" \
  -H 'Idempotency-Key: welcome-user-8812' \
  -H 'Content-Type: application/json' \
  -d '[
  {
    "from": "Acme <hello@acme.com>",
    "to": [
      "delivered@simulator.zapsmtp.xyz"
    ],
    "template": {
      "id": "welcome",
      "variables": {
        "first_name": "Ann"
      }
    },
    "tags": [
      {
        "name": "series",
        "value": "welcome"
      }
    ],
    "metadata": {
      "user_id": "8812"
    }
  },
  {
    "from": "Acme <hello@acme.com>",
    "to": [
      "delivered@simulator.zapsmtp.xyz"
    ],
    "template": {
      "id": "welcome-day-2",
      "variables": {
        "first_name": "Ann"
      }
    },
    "scheduled_at": "in 2 days",
    "tags": [
      {
        "name": "series",
        "value": "welcome"
      }
    ],
    "metadata": {
      "user_id": "8812"
    }
  },
  {
    "from": "Acme <hello@acme.com>",
    "to": [
      "delivered@simulator.zapsmtp.xyz"
    ],
    "template": {
      "id": "welcome-day-7",
      "variables": {
        "first_name": "Ann"
      }
    },
    "scheduled_at": "in 7 days",
    "tags": [
      {
        "name": "series",
        "value": "welcome"
      }
    ],
    "metadata": {
      "user_id": "8812"
    }
  }
]'
Set ZAP_API_KEY in your environment.
Response 200 OK
{
  "data": [
    {
      "index": 0,
      "id": "019a1f2e-7c3b-7d41-9a52-0c4b8e6f1a23"
    },
    {
      "index": 2,
      "id": "019a1f2e-7c3c-7a10-8b21-4d5e6f7a8b9c",
      "suppressed": [
        "old@example.org"
      ]
    }
  ],
  "errors": [
    {
      "index": 1,
      "code": "missing_required_field",
      "message": "subject is required"
    }
  ]
}

Cancel a pending step

POST/v1/emails/{id}/cancel

Call it for every stored id when the user unsubscribes, churns or deletes the account. 409 not_cancellable means the email already went out (or the id is unknown): nothing left to stop.

Requires a full-access or sending-access key.

Path parameters

idstringrequired
Scheduled email id

Errors

restricted_api_keyHTTP 403
this API key cannot manage this email (another domain or stream, or live mail with a test key)
not_cancellableHTTP 409
unknown id, already dispatched or already canceled
POST/v1/emails/{id}/cancel
curl -X POST 'https://zapsmtp.xyz/v1/emails/019a1f2e-7c3b-7d41-9a52-0c4b8e6f1a23/cancel' \
  -H "Authorization: Bearer $ZAP_API_KEY"
Set ZAP_API_KEY in your environment.
Response 200 OK
{
  "object": "email",
  "id": "019a1f40-2b3c-7d4e-8f5a-6b7c8d9e0f12",
  "status": "canceled"
}
Start and stop a welcome series
import { zap } from './zap.mjs';

const STEPS = [
  { template: 'welcome' },                                 // now
  { template: 'welcome-day-2', scheduled_at: 'in 2 days' },
  { template: 'welcome-day-7', scheduled_at: 'in 7 days' }, // 7 days is the scheduling limit
];

export async function startWelcomeSeries(user) {
  const emails = STEPS.map(({ template, scheduled_at }) => ({
    from: 'Acme <hello@acme.com>',
    to: [user.email],
    template: { id: template, variables: { first_name: user.firstName } },
    ...(scheduled_at && { scheduled_at }),
    tags: [{ name: 'series', value: 'welcome' }],
    metadata: { user_id: String(user.id) },
  }));
  const res = await zap('POST', '/emails/batch', emails, { 'Idempotency-Key': `welcome-${user.id}` });
  const ids = res.data.map((d) => d.id);
  // send-time failures (e.g. rate_limit_exceeded) come back per email: send those one by one
  for (const e of res.errors ?? []) {
    const one = await zap('POST', '/emails', emails[e.index], { 'Idempotency-Key': `welcome-${user.id}-${e.index}` });
    ids.push(one.id);
  }
  await db.users.update(user.id, { welcomeEmailIds: ids });
}

// On unsubscribe, churn or account deletion.
export async function stopWelcomeSeries(user) {
  for (const id of user.welcomeEmailIds ?? []) {
    try {
      await zap('POST', `/emails/${id}/cancel`);
    } catch (err) {
      if (err.code !== 'not_cancellable') throw err; // already sent: nothing to stop
    }
  }
  await db.users.update(user.id, { welcomeEmailIds: [] });
}

Emails can be scheduled at most 7 days ahead and an account can hold 10,000 waiting emails. For longer or larger sequences, keep the schedule in your own job queue and send each step when it is due. When the time comes, the key and the account are checked again: a key revoked in the meantime fails the email. Keys restricted to a domain or a stream can only cancel the emails they scheduled themselves. To move a step instead of canceling it, use PATCH /emails/{id}.

4. Weekly newsletter: broadcast stream, one-click unsubscribe, batching

What you build: a weekly issue sent to every subscriber from a separate IP pool, with a working unsubscribe link and header, paced to your rate limit and safe to restart.

  1. Put {{{zap:unsubscribe_url}}} in the template’s footer.
  2. Send each subscriber their own email with stream: "broadcast". Broadcast emails to a single recipient also get RFC 8058 List-Unsubscribe and List-Unsubscribe-Post headers, so mail apps show their own unsubscribe button.
  3. Batch 100 emails per request with x-batch-validation: permissive. Resend the items that failed with rate_limit_exceeded after a pause; stop on monthly_quota_exceeded or daily_quota_exceeded.
  4. Before each issue, pull new suppressions and drop those addresses from your list.

A newsletter template with an unsubscribe link

POST/v1/templates

{{{zap:unsubscribe_url}}} becomes each recipient’s signed one-click unsubscribe link at send time. It is never reported as a missing variable.

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

Syntax: {{var}} escaped, {{{var}}} raw, {{#list}}…{{/list}} sections, {{^x}}…{{/x}} inverted sections. Exact ordering of variable_names is determined by the parser; the example order is illustrative.

Body: html and text both empty → 422 missing_required_field; template syntax errors → 422 template_render_error "<field>: <error>"; a tag that is not a variable name ({{first name}}, {{#each items}}, {{> footer}}) → 422 template_render_error "html: line L, column C: {{…}} is not a variable name: …"

variable_schema in the response is the saved list after discovery. A send is checked against the published copy, published.variable_schema.

Body parameters

namestringrequired
Limits: trimmed, 1-200 characters → 422 missing_required_field "name is required (max 200 chars)"
aliasstring
Stable handle usable instead of the id (template.id in sends, /templates/{alias}). Limits: ^[a-z0-9][a-z0-9_-]{0,62}$ → 422 invalid_parameter; duplicate → 409 conflict
subjectstring
Mustache source.
htmlstring
At least one of html and text is required. Mustache source; {{var}} is HTML-escaped, {{{var}}} raw.
textstring
fromstring
Default From used when a send omits from.
variablesobject
Sample variables used by previews. Never used for sending; a variable first found in the source takes its sample as example.
variable_schemaobject[]
Declares the template’s variables (see Variable schema). Replaces the declared list when given. On every save, names the source uses that the list lacks are added (their sample value as example): a section {{#x}} as optional any, a dotted {{x.y}} as a required object, any other name as a required any, never stricter than sends were before, and declared names the source no longer uses are kept with unused: true. Limits: at most 200 entries, no duplicates, defaults and examples of their type → 422 invalid_parameter "variable_schema: <reason>"
Show 6 fields
namestringrequired
Variable name: letters, digits, _ and -, starting with a letter or _, up to 64; global is reserved.
typestringrequired
What a send must pass. One of string, number, boolean, url, date, list, object, any.
requiredboolean
A send without a value (or with null) and without a default is refused with template_variables_missing. Default false.
defaultany
Used when a send gives no value; must be of type.
exampleany
Used by previews; must be of type.
descriptionstring
For your team; at most 500 bytes.
designobject | null
The visual editor’s document (a JSON object with a numeric version), stored with the template. null makes it a code template. Changing html without sending design drops the design (it no longer describes the HTML). Limits: max 1 MB → 422 invalid_parameter "design is larger than 1 MB"; not an object with a numeric version → 422 invalid_parameter
publishboolean
Publish the result in the same call. Without it the template stays a draft and is not used for sending until published. Default false.

Errors

restricted_api_keyHTTP 403
test_key_restrictedHTTP 403
test-mode keys can send test email and read, not change the account: use a live key
conflictHTTP 409
a template with this alias already exists
payload_too_largeHTTP 413
request body too large (over 4 MB)
missing_required_fieldHTTP 422
invalid_parameterHTTP 422
template_render_errorHTTP 422
template syntax error: "<field>: <error>"
template_render_errorHTTP 422
a tag that is not a variable name, e.g. {{first name}}, {{#each items}}, {{> footer}}: "html: line L, column C: {{…}} is not a variable name: letters, digits, _ and - (no spaces), parts joined with dots"
POST/v1/templates
curl -X POST 'https://zapsmtp.xyz/v1/templates' \
  -H "Authorization: Bearer $ZAP_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
  "name": "Acme Weekly",
  "alias": "weekly-newsletter",
  "from": "Acme Weekly <news@acme.com>",
  "subject": "{{title}}",
  "html": "<h1>{{title}}</h1>{{{body_html}}}<hr><p style=\"font-size:12px;color:#6b7280\">Hi {{first_name}}, you get Acme Weekly because you subscribed at acme.com. <a href=\"{{{zap:unsubscribe_url}}}\">Unsubscribe</a></p>",
  "text": "{{title}}\n\n{{body_text}}\n\nUnsubscribe: {{{zap:unsubscribe_url}}}",
  "publish": true
}'
Set ZAP_API_KEY in your environment.
Response 201 Created
{
  "id": "019a1f23-7f80-7192-8b3c-4d5e6f7a8b9c",
  "name": "Receipt",
  "alias": "receipt",
  "subject": "Your receipt for {{order_id}}",
  "html": "<p>Hi {{name}}, thanks for order {{order_id}}.</p>",
  "text": "Hi {{name}}, thanks for order {{order_id}}.",
  "from": "Acme <billing@acme.com>",
  "variables": {
    "name": "Jane",
    "order_id": "A-1042"
  },
  "variable_schema": [
    {
      "name": "order_id",
      "type": "string",
      "required": true,
      "example": "A-1042",
      "description": "Order number shown to the customer"
    },
    {
      "name": "name",
      "type": "string",
      "required": false,
      "default": "there",
      "example": "Jane"
    }
  ],
  "design": null,
  "created_at": "2026-10-21T16:10:00Z",
  "updated_at": "2026-10-21T16:10:00Z",
  "object": "template",
  "variable_names": [
    "order_id",
    "name"
  ],
  "status": "draft",
  "has_unpublished_changes": false,
  "published_at": null,
  "published": null
}

Send one chunk on the broadcast stream

POST/v1/emails/batch

One recipient per email, up to 100 emails per request. permissive sends the valid emails and reports the rest in errors[], together with send-time failures such as rate_limit_exceeded.

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

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

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

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

Headers

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

Body parameters

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

Errors

validation_errorHTTP 400
invalid JSON or x-batch-validation not strict/permissive
invalid_idempotency_keyHTTP 400
Idempotency-Key must be 1 to 256 bytes
idempotency_key_reusedHTTP 409
idempotency_request_in_progressHTTP 409
payload_too_largeHTTP 413
invalid_parameterHTTP 422
batch size outside 1-100
validation_errorHTTP 422
strict mode, any email invalid
service_unavailableHTTP 503
too many large bodies in flight
POST/v1/emails/batch
curl -X POST 'https://zapsmtp.xyz/v1/emails/batch' \
  -H "Authorization: Bearer $ZAP_API_KEY" \
  -H 'x-batch-validation: permissive' \
  -H 'Idempotency-Key: newsletter-2026-43-chunk-0-attempt-0' \
  -H 'Content-Type: application/json' \
  -d '[
  {
    "to": [
      "delivered+ann@simulator.zapsmtp.xyz"
    ],
    "stream": "broadcast",
    "template": {
      "id": "weekly-newsletter",
      "variables": {
        "first_name": "Ann",
        "title": "Acme Weekly #43",
        "body_html": "<p>This week: faster exports.</p>",
        "body_text": "This week: faster exports."
      }
    },
    "tags": [
      {
        "name": "issue",
        "value": "2026-43"
      }
    ]
  },
  {
    "to": [
      "suppressed@simulator.zapsmtp.xyz"
    ],
    "stream": "broadcast",
    "template": {
      "id": "weekly-newsletter",
      "variables": {
        "first_name": "Bob",
        "title": "Acme Weekly #43",
        "body_html": "<p>This week: faster exports.</p>",
        "body_text": "This week: faster exports."
      }
    },
    "tags": [
      {
        "name": "issue",
        "value": "2026-43"
      }
    ]
  }
]'
Set ZAP_API_KEY in your environment.
Response 200 OK
{
  "data": [
    {
      "index": 0,
      "id": "019a1f2e-7c3b-7d41-9a52-0c4b8e6f1a23"
    },
    {
      "index": 2,
      "id": "019a1f2e-7c3c-7a10-8b21-4d5e6f7a8b9c",
      "suppressed": [
        "old@example.org"
      ]
    }
  ],
  "errors": [
    {
      "index": 1,
      "code": "missing_required_field",
      "message": "subject is required"
    }
  ]
}

Sync unsubscribes back to your list

GET/v1/suppressions

Newest first. reason is unsubscribe, complaint, hard_bounce or manual. Requires a full-access key.

Requires a full-access key.

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

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

Query parameters

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

Errors

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

const CHUNK = 100; // emails per batch request (the maximum)
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

export async function sendIssue(issue, subscribers) { // subscribers: [{ email, firstName }]
  for (let start = 0; start < subscribers.length; start += CHUNK) {
    let pending = subscribers.slice(start, start + CHUNK).map((s) => ({
      to: [s.email], // one recipient per email: per-recipient unsubscribe link and header
      stream: 'broadcast',
      template: {
        id: 'weekly-newsletter',
        variables: { first_name: s.firstName, title: issue.title, body_html: issue.html, body_text: issue.text },
      },
      tags: [{ name: 'issue', value: issue.slug }],
    }));
    for (let attempt = 0; pending.length > 0; attempt++) {
      // a restarted job replays the same keys: no subscriber gets the issue twice
      const res = await zap('POST', '/emails/batch', pending, {
        'x-batch-validation': 'permissive',
        'Idempotency-Key': `newsletter-${issue.slug}-${start}-${attempt}`,
      });
      const retry = [];
      for (const e of res.errors ?? []) {
        if (e.code === 'rate_limit_exceeded') retry.push(pending[e.index]);
        else if (e.code.endsWith('quota_exceeded')) throw new Error(`stopped at ${start}: ${e.code}`);
        else console.warn('skipped', pending[e.index].to[0], e.code, e.message);
      }
      for (const d of res.data ?? []) {
        if (d.suppressed) await db.subscribers.markSuppressed(d.suppressed);
      }
      pending = retry;
      if (pending.length) await sleep(1000 * Math.min(30, 2 ** attempt)); // the budget refills every second
    }
  }
}

// Run before each issue, with the time of the previous sync.
export async function syncSuppressions(since) {
  let after;
  for (;;) {
    const q = new URLSearchParams({ limit: '1000', ...(after && { after }) });
    const page = await zap('GET', '/suppressions?' + q);
    for (const s of page.data) {
      if (new Date(s.created_at) < since) return;
      await db.subscribers.markSuppressed([s.email], s.reason);
    }
    if (!page.has_more) return;
    after = page.next_cursor;
  }
}
GET /suppressions needs a full-access key; sending works with a sending-access key.

Your per-second budget counts recipients and is reported on send responses in RateLimit-Limit and RateLimit-Remaining (when your account has one); it refills every second. Unsubscribes go on the account’s suppression list with reason unsubscribe and stop broadcast mail only: someone who unsubscribes from the newsletter still gets receipts and password resets. Complaints and hard bounces stop every stream. Unsubscribes are not per list: if you need opt-outs per list, set your own List-Unsubscribe and List-Unsubscribe-Post headers pointing at your endpoint (ZapSMTP then leaves them as they are), do not use zap:unsubscribe_url, and keep the preference in your database.

5. Bounces and complaints: webhooks, user flags, Slack alerts

What you build: a webhook endpoint that flags bad addresses and complainers in your user table, and a scheduled check that posts to Slack when bounce or complaint rates climb.

  1. Create a webhook for email.bounced, email.complained and email.suppressed.
  2. Verify every request, skip Zap-Event-Ids you have handled, then act: bounce.suppressed: true means the address is dead; a complaint means stop marketing to it.
  3. Every 15 minutes, read GET /metrics for the last 6 hours by stream and alert above your thresholds.

Subscribe to the events that matter

POST/v1/webhooks

The response carries the whsec_… signing secret: put it in ZAP_WEBHOOK_SECRET.

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

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

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

Body parameters

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

Errors

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

const app = express();
// the signature covers the exact bytes: keep the raw body
app.post('/hooks/zapsmtp', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verifyZapSignature(req.body, req.get('Zap-Signature'))) return res.sendStatus(400);
  const eventId = req.get('Zap-Event-Id'); // the same on every retry and replay
  if (await db.webhookEvents.seen(eventId)) return res.sendStatus(200);

  const { type, data } = JSON.parse(req.body);
  if (!data.test) { // events from test keys carry test: true
    const b = data.bounce; // { type, subType, message, suppressed }
    switch (type) {
      case 'email.bounced':
        // suppressed: a permanent mailbox or domain failure. Otherwise the problem is
        // on the sending side (SpamBlocked, PolicyRejected...) and the address is fine.
        if (b?.suppressed) await db.users.markEmailInvalid(data.to, b.subType);
        else console.warn('bounce, address kept', data.to, b?.subType, b?.message);
        break;
      case 'email.complained':
        await db.users.optOutOfMarketing(data.to);
        break;
      case 'email.suppressed': // we tried to email an address already on the list
        await db.users.markEmailInvalid(data.to, 'Suppressed');
        break;
    }
  }
  await db.webhookEvents.markSeen(eventId);
  res.sendStatus(200);
});

Read the rates for alerting

GET/v1/metrics

Bounce rate is of sent, complaint rate of delivered. Test-mode mail is excluded. dimension=stream gives one row per stream, so a bad newsletter does not hide behind good receipts.

Requires a full-access key.

Counts are recipients, except delayed, opened and clicked: unique emails. Delivery and bounce rates are of sent; complaint, open and click rates of delivered. Test-mode mail is excluded. Kept for 400 days.

An out-of-band bounce (a later bounce email from the remote server) has no sending domain: it is grouped under an empty value.

Totals and the series count each email once whatever the dimension; with dimension=tag an email with several tags appears in each of its tags' groups.

Query parameters

fromstring
RFC 3339 start. At most 400 days before to. Default 7 days ago.
tostring
RFC 3339 end (exclusive). Default now.
granularitystring
hour (ranges up to 31 days) or day for series. Default hour up to 48 hours, else day.
dimensionstring
domain, stream, tag or provider: adds groups, one row per value, busiest first.
domainstring
Only this sending (From) domain.
streamstring
transactional or broadcast.
providerstring
Gmail, Microsoft, Yahoo, Apple or Other.
tagstring
Only emails with this tag, as name=value. Cannot be combined with domain, stream or provider, and groups only by tag.

Errors

restricted_api_keyHTTP 403
invalid_parameterHTTP 422
Bad time, range, granularity or dimension, or a tag combined with another filter.
GET/v1/metrics
curl 'https://zapsmtp.xyz/v1/metrics?from=2026-10-26T06%3A00%3A00Z&to=2026-10-26T12%3A00%3A00Z&dimension=stream' \
  -H "Authorization: Bearer $ZAP_API_KEY"
Set ZAP_API_KEY in your environment.
Response 200 OK
{
  "object": "metrics",
  "from": "2026-10-18T00:00:00Z",
  "to": "2026-10-25T00:00:00Z",
  "granularity": "day",
  "dimension": "domain",
  "totals": {
    "sent": 12400,
    "delivered": 12210,
    "bounced": 95,
    "delayed": 3,
    "complained": 4,
    "opened": 4380,
    "human_opened": 2409,
    "clicked": 912,
    "suppressed": 2,
    "failed": 0,
    "delivery_rate": 0.9847,
    "bounce_rate": 0.0077,
    "complaint_rate": 0.0003,
    "open_rate": 0.3587,
    "human_open_rate": 0.1972,
    "click_rate": 0.0747
  },
  "series": [
    {
      "time": "2026-10-18T00:00:00Z",
      "sent": 1700,
      "delivered": 1676,
      "bounced": 14,
      "delayed": 3,
      "complained": 1,
      "opened": 601,
      "human_opened": 331,
      "clicked": 120,
      "suppressed": 2,
      "failed": 0,
      "delivery_rate": 0.9859,
      "bounce_rate": 0.0082,
      "complaint_rate": 0.0006,
      "open_rate": 0.3586,
      "human_open_rate": 0.1974,
      "click_rate": 0.0716
    }
  ],
  "groups": [
    {
      "value": "acme.com",
      "sent": 9800,
      "delivered": 9668,
      "bounced": 70,
      "delayed": 3,
      "complained": 3,
      "opened": 3540,
      "human_opened": 1947,
      "clicked": 760,
      "suppressed": 2,
      "failed": 0,
      "delivery_rate": 0.9865,
      "bounce_rate": 0.0071,
      "complaint_rate": 0.0003,
      "open_rate": 0.3662,
      "human_open_rate": 0.2013,
      "click_rate": 0.0786
    },
    {
      "value": "news.acme.com",
      "sent": 2600,
      "delivered": 2542,
      "bounced": 25,
      "delayed": 3,
      "complained": 1,
      "opened": 840,
      "human_opened": 462,
      "clicked": 152,
      "suppressed": 2,
      "failed": 0,
      "delivery_rate": 0.9777,
      "bounce_rate": 0.0096,
      "complaint_rate": 0.0004,
      "open_rate": 0.3304,
      "human_open_rate": 0.1817,
      "click_rate": 0.0598
    }
  ]
}
Alert Slack when rates rise
// Run every 15 minutes (cron, a scheduled job). Needs a full-access key.
import { zap } from './zap.mjs';

const LIMITS = { bounce_rate: 0.04, complaint_rate: 0.001 }; // pick your own thresholds
const MIN_SENT = 200; // ignore tiny samples

export async function checkRates() {
  const to = new Date();
  const from = new Date(to.getTime() - 6 * 3600_000);
  const q = new URLSearchParams({ from: from.toISOString(), to: to.toISOString(), dimension: 'stream' });
  const m = await zap('GET', '/metrics?' + q);
  const alerts = m.groups
    .filter((g) => g.sent >= MIN_SENT)
    .flatMap((g) => Object.entries(LIMITS)
      .filter(([rate, max]) => g[rate] > max)
      .map(([rate, max]) => `${g.value}: ${rate} ${(g[rate] * 100).toFixed(2)}% (limit ${max * 100}%), ${g.sent} sent in 6 h`));
  if (alerts.length) {
    await fetch(process.env.SLACK_WEBHOOK_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text: 'ZapSMTP rates over the limit:\n' + alerts.join('\n') }),
    });
  }
}
SLACK_WEBHOOK_URL is a Slack incoming-webhook URL.

One event is sent per recipient, so data.to is a single address. ZapSMTP already suppresses hard-bounced and complaining addresses for you; the handler keeps your own records in step. Answer with a 2xx quickly. Network errors, timeouts, 5xx, 408 and 429 are retried for about 28 hours; any other 4xx ends delivery of that event at once, so do not answer 4xx for a temporary problem on your side. The 4% and 0.1% thresholds above are examples; choose your own. Details: Webhook events, bounce classification.

6. Support inbox: receive, open a ticket, reply in the thread

What you build: customers write to support@help.acme.com; each new conversation becomes a ticket with an automatic acknowledgement; follow-ups attach to the same ticket; agents answer from your UI and the customer sees one email thread.

  1. Add help.acme.com as an inbound domain and publish its MX record.
  2. Create a webhook for thread.created and thread.replied.
  3. On thread.created: create the ticket, store thread_id and email_id, tag the conversation with the ticket id and send an acknowledgement with auto_reply: true.
  4. On thread.replied: add reply_text (the new text without the quoted history) to the ticket.
  5. Agents answer with POST /emails/receiving/{id}/reply on the latest received email.

Receive on a subdomain

POST/v1/inbound/domains

Your own domain (or its parent) must already be verified for sending. Publish the MX record from the response and call verify. For a quick test, {"managed": true} gives an instant address with no DNS.

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

Body parameters

managedboolean
true: create an instant managed domain <10 random lowercase chars>.<inbound zone>; no DNS needed; name is ignored. Default False.
namestring
Required unless managed is true. Your own domain. It (or a parent) must be verified for sending by the account. Then publish MX 10 <inbound host> and call verify. Limits: invalid → 422 invalid_parameter "invalid domain name"; ZapSMTP-owned names → 422 reserved_domain; not verified for sending → 403 domain_not_verified
reject_spamboolean
Refuse messages flagged as spam at SMTP time instead of delivering them with is_spam: true. Default False.
require_tlsboolean
Accept encrypted mail only: senders without STARTTLS are refused with 530 5.7.0.

Errors

domain_not_verifiedHTTP 403
verify <name> (or its parent domain) for sending first; inbound reuses that proof of ownership
restricted_api_keyHTTP 403
test_key_restrictedHTTP 403
test-mode keys can send test email and read, not change the account: use a live key
conflictHTTP 409
this domain already receives mail on ZapSMTP
invalid_parameterHTTP 422
invalid domain name
reserved_domainHTTP 422
this domain is reserved by ZapSMTP; use a managed address or your own domain
inbound_not_configuredHTTP 503
POST/v1/inbound/domains
curl -X POST 'https://zapsmtp.xyz/v1/inbound/domains' \
  -H "Authorization: Bearer $ZAP_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
  "name": "help.acme.com"
}'
Set ZAP_API_KEY in your environment.
Response 201 Created
{
  "id": "019a1f20-4c5d-7e6f-8a9b-0c1d2e3f4a5b",
  "name": "k3f9x2m7q1.in.zapsmtp.xyz",
  "managed": true,
  "reject_spam": false,
  "mx_verified": true,
  "last_checked_at": "2026-10-21T15:00:00Z",
  "created_at": "2026-10-21T15:00:00Z",
  "records": [],
  "example_address": "anything@k3f9x2m7q1.in.zapsmtp.xyz"
}

Subscribe to conversation events

POST/v1/webhooks

thread.created fires for a message that answers nothing (a new ticket), thread.replied for an answer in an existing conversation. Neither fires for bounces, out-of-office, auto-replies or auto-generated notifications. Bulk and mailing-list mail (Precedence: bulk, List-Id, List-Unsubscribe) is classified as automated but still produces these events: check automated.type === "bulk" if newsletters should not open tickets.

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

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

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

Body parameters

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

Errors

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

Link the conversation to the ticket

PATCH/v1/threads/{id}

Conversation metadata shows in the dashboard inbox and in GET /threads, so anyone can jump from an email to the ticket.

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

Omitted fields are kept.

Path parameters

idstring (UUID)required
Thread id

Body parameters

readboolean
true sets read_at to now (unread again once a new email arrives); false clears it.
metadataobject
Your own key-value pairs (a ticket id, a CRM deal), replacing the previous ones. Limits: at most 20 keys matching [A-Za-z0-9_-]{1,40}, values up to 500 bytes (UTF-8) (422 invalid_parameter).

Errors

validation_errorHTTP 400
invalid JSON / unknown field
restricted_api_keyHTTP 403
test_key_restrictedHTTP 403
test-mode keys can send test email and read, not change the account: use a live key
not_foundHTTP 404
thread not found
invalid_parameterHTTP 422
metadata limits
PATCH/v1/threads/{id}
curl -X PATCH 'https://zapsmtp.xyz/v1/threads/3f1c9a52-6b0e-5d4f-8a71-2c9e0b4d6f13' \
  -H "Authorization: Bearer $ZAP_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
  "metadata": {
    "ticket": "T-1042"
  }
}'
Set ZAP_API_KEY in your environment.
Response 200 OK
{
  "object": "thread",
  "id": "3f1c9a52-6b0e-5d4f-8a71-2c9e0b4d6f13",
  "subject": "Where is my order?",
  "participants": [
    "jane@example.org",
    "support@acme.com"
  ],
  "message_count": 3,
  "inbound_count": 2,
  "outbound_count": 1,
  "has_attachments": false,
  "first_message_at": "2026-10-21T15:10:02.904Z",
  "last_message_at": "2026-10-21T16:02:13.540Z",
  "last_inbound_at": "2026-10-21T16:02:13.540Z",
  "last_message": {
    "id": "019a1f52-0c1d-7f2e-8a3b-4c5d6e7f8091",
    "direction": "inbound",
    "from": "jane@example.org",
    "subject": "Re: Where is my order?"
  },
  "awaiting_reply": true,
  "unread": false,
  "read_at": "2026-10-21T16:05:00.000Z",
  "metadata": {
    "ticket": "T-1042"
  }
}

Acknowledge in the same thread

POST/v1/emails/receiving/{id}/reply

Recipients, Re: subject and In-Reply-To/References default from the received email. Replying from another address sets reply_to to the receiving one, so the customer’s answer comes back to the inbox. auto_reply: true marks the message as automatic (RFC 3834).

Requires a full-access key. Live keys: the From domain must be verified for sending.

Our Message-ID of the reply names its thread, so the customer's answer joins the conversation even from a mail client that keeps only In-Reply-To.

Every other rule of POST /emails applies: suppressions, quotas, tracking, test keys.

Loop protection: replies to automated email need allow_automated, and one conversation takes at most 20 replies an hour.

Path parameters

idstring (UUID)required
Received email id

Headers

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

Body parameters

textstring
Plain-text body. Either text or html (or a template) is required.
htmlstring
HTML body.
fromstring
Default: the address that received the email (e.g. support@acme.com), which must be on a verified sending domain. Required when the email came to a managed *.in.zapsmtp.xyz address (those only receive): 422 missing_required_field.
tostring | string[]
Default: the email's Reply-To, else its From. Limits: 422 missing_required_field when the email has neither.
reply_allboolean
Also copy everyone on the email's To and Cc, except the receiving address, the sender you reply from and anyone already addressed. Default false.
allow_automatedboolean
Answer an automated email anyway (see automated on the received email). Without it a reply to a bounce, out-of-office, auto-reply or notification is refused with 422 automated_message: two auto-responders answering each other is a mail loop. Bulk mail can be answered without it. Default false.
auto_replyboolean
This answer is itself automatic (an acknowledgement, a ticket receipt): adds Auto-Submitted: auto-replied and X-Auto-Response-Suppress: All (RFC 3834), so other auto-responders do not answer it. Default false.
ccstring | string[]
Added to the Cc (reply_all adds the email's recipients after these).
bccstring | string[]
Bcc recipients.
reply_tostring | string[]
Default: the receiving address when you reply from another one, so the customer's answer comes back to the same inbox.
subjectstring
Default: Re: + the email's subject (kept as is when it already starts with Re:).
headersobject
Custom headers. In-Reply-To and References are set for you (RFC 5322 threading); a value you give wins.
attachments, tags, metadata, stream, tracking, template, scheduled_at
As in POST /emails.

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
not_foundHTTP 404
email not found
idempotency_key_reusedHTTP 409
idempotency_request_in_progressHTTP 409
missing_required_fieldHTTP 422
from (managed receiving address), to (no sender on the email), or the body
automated_messageHTTP 422
the email is a bounce, out-of-office, auto-reply or notification; pass allow_automated: true
rate_limit_exceededHTTP 429
thread_reply_limitHTTP 429
too many replies in this conversation in the last hour (a mail loop?); at most 20 per hour; Retry-After: 3600
inbound_not_configuredHTTP 503
POST/v1/emails/receiving/{id}/reply
curl -X POST 'https://zapsmtp.xyz/v1/emails/receiving/019a1f30-1d4e-7a6b-8c2d-3e4f5a6b7c8d/reply' \
  -H "Authorization: Bearer $ZAP_API_KEY" \
  -H 'Idempotency-Key: ack-019a1f30-1d4e-7a6b-8c2d-3e4f5a6b7c8d' \
  -H 'Content-Type: application/json' \
  -d '{
  "from": "Acme Support <support@acme.com>",
  "text": "Thanks for writing. Your ticket is T-1042; a person will answer within one business day.",
  "auto_reply": true
}'
Set ZAP_API_KEY in your environment.
Response 200 OK
{
  "id": "019a1f41-8b2c-7e3d-9f40-5a6b7c8d9e0f",
  "thread_id": "3f1c9a52-6b0e-5d4f-8a71-2c9e0b4d6f13"
}
Turn conversations into tickets
import express from 'express';
import { zap, verifyZapSignature } from './zap.mjs';

const app = express();
app.post('/hooks/support', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verifyZapSignature(req.body, req.get('Zap-Signature'))) return res.sendStatus(400);
  const eventId = req.get('Zap-Event-Id');
  if (await db.webhookEvents.seen(eventId)) return res.sendStatus(200);
  const { type, data } = JSON.parse(req.body);

  if (type === 'thread.created') {
    const ticket = await tickets.create({
      threadId: data.thread_id, subject: data.subject, customer: data.from,
      body: data.reply_text, lastEmailId: data.email_id,
    });
    await zap('PATCH', `/threads/${data.thread_id}`, { metadata: { ticket: ticket.id } });
    await zap('POST', `/emails/receiving/${data.email_id}/reply`, {
      from: 'Acme Support <support@acme.com>',
      text: `Thanks for writing. Your ticket is ${ticket.id}; a person will answer within one business day.`,
      auto_reply: true,
    }, { 'Idempotency-Key': `ack-${data.email_id}` });
  } else if (type === 'thread.replied') {
    const ticket = await tickets.findByThread(data.thread_id);
    await tickets.addMessage(ticket.id, { from: data.from, body: data.reply_text, emailId: data.email_id });
  }
  await db.webhookEvents.markSeen(eventId);
  res.sendStatus(200);
});

// An agent answers from your helpdesk UI.
export function answerTicket(ticket, agent, text) {
  return zap('POST', `/emails/receiving/${ticket.lastEmailId}/reply`, {
    from: `${agent.name} at Acme <support@acme.com>`,
    text,
  }, { 'Idempotency-Key': `ticket-${ticket.id}-msg-${ticket.messageCount}` });
}

The webhook carries metadata and reply_text; fetch the full body, headers and attachments with GET /emails/receiving/{id}. Replies to automated mail are refused with 422 automated_message unless you pass allow_automated: true, and one conversation takes at most 20 replies an hour (429 thread_reply_limit), so two auto-responders cannot loop. A reply’s from defaults to the receiving address, which must then be on a verified sending domain; the recipe sends from support@acme.com instead.

Send the sign-in link

POST/v1/emails

Plain HTML and text, no template needed. Open and click tracking are off so the link reaches the user exactly as you generated it.

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

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

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

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

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

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

Headers

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

Body parameters

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

Errors

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

const TTL_MINUTES = 10;

export async function sendMagicLink(email) {
  const id = crypto.randomUUID();
  const token = crypto.randomBytes(32).toString('base64url');
  await db.magicLinks.insert({
    id, email,
    tokenHash: crypto.createHash('sha256').update(token).digest('hex'),
    expiresAt: new Date(Date.now() + TTL_MINUTES * 60_000), // single use: delete on success
  });
  const url = `https://acme.com/login/verify?id=${id}&token=${token}`;
  return zap('POST', '/emails', {
    from: 'Acme <login@acme.com>',
    to: [email],
    subject: 'Your Acme sign-in link',
    html: `<p><a href="${url}">Sign in to Acme</a></p><p>The link works once, for ${TTL_MINUTES} minutes.</p>`,
    text: `Sign in to Acme: ${url}\n\nThe link works once, for ${TTL_MINUTES} minutes.`,
    tracking: { opens: false, clicks: false },
    tags: [{ name: 'category', value: 'magic-link' }],
  }, { 'Idempotency-Key': `magic-link-${id}` });
}

CI: wait for the outcome

GET/v1/emails/{id}

Poll until recipients[0].status is the status the simulator address produces (delivered, bounced…). Test keys may read, so CI never needs a live key.

Requires a full-access key.

Only data inside the plan's log retention is visible. metadata may be null.

Path parameters

idstringrequired
Email id

Errors

restricted_api_keyHTTP 403
not_foundHTTP 404
email not found (events can take a few seconds to appear)
GET/v1/emails/{id}
curl 'https://zapsmtp.xyz/v1/emails/019a1f2e-7c3b-7d41-9a52-0c4b8e6f1a23' \
  -H "Authorization: Bearer $ZAP_API_KEY"
Set ZAP_API_KEY in your environment.
Response 200 OK
{
  "object": "email",
  "id": "019a1f2e-7c3b-7d41-9a52-0c4b8e6f1a23",
  "from": "Acme <hello@acme.com>",
  "subject": "Your receipt",
  "created_at": "2026-10-21T15:04:10.902Z",
  "recipients": [
    {
      "email": "jane@example.org",
      "status": "delivered",
      "updated_at": "2026-10-21T15:04:12.381Z",
      "response": "250 2.0.0 OK 1761059052 x7si123"
    }
  ],
  "events": [
    {
      "type": "Reception",
      "status": "queued",
      "recipient": "jane@example.org",
      "time": "2026-10-21T15:04:10.902Z",
      "attempts": 0
    },
    {
      "type": "Delivery",
      "status": "delivered",
      "recipient": "jane@example.org",
      "time": "2026-10-21T15:04:12.381Z",
      "response_code": 250,
      "response": "250 2.0.0 OK 1761059052 x7si123",
      "attempts": 1,
      "mx": "gmail-smtp-in.l.google.com.",
      "peer_addr": "142.250.153.27",
      "tls": "TLSv1.3 TLS_AES_256_GCM_SHA384",
      "egress_source": "ip-1"
    }
  ],
  "stream": "transactional",
  "test": false,
  "metadata": {
    "order_id": "A-1042"
  }
}

CI: read what was sent

GET/v1/emails/{id}/content

Returns the HTML and text you sent, each truncated to 256 KiB in the archive; extract the link and drive your sign-in flow with it.

Requires a full-access key.

source is api or smtp; tags is the URL-encoded tag string. Content is kept for your plan’s log retention.

Path parameters

idstringrequired
Email id

Errors

restricted_api_keyHTTP 403
not_foundHTTP 404
content not available (past your plan's log retention)
GET/v1/emails/{id}/content
curl 'https://zapsmtp.xyz/v1/emails/019a1f2e-7c3b-7d41-9a52-0c4b8e6f1a23/content' \
  -H "Authorization: Bearer $ZAP_API_KEY"
Set ZAP_API_KEY in your environment.
Response 200 OK
{
  "source": "api",
  "from": "Acme <hello@acme.com>",
  "to": [
    "jane@example.org"
  ],
  "cc": [],
  "bcc": [],
  "subject": "Your receipt",
  "html": "<p>Thanks for your order!</p>",
  "text": "Thanks for your order!",
  "raw": "",
  "tags": "category=receipt",
  "size": 2481
}
Test it in CI
// magic-link.test.mjs: node --test. ZAP_API_KEY is a zap_test_ key with full access.
import test from 'node:test';
import assert from 'node:assert/strict';
import { zap } from './zap.mjs';
import { sendMagicLink } from './magic-link.mjs';

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function waitForStatus(id, status) {
  for (let i = 0; i < 30; i++) {
    const email = await zap('GET', `/emails/${id}`);
    if (email.recipients[0]?.status === status) return email;
    await sleep(1000);
  }
  throw new Error(`email ${id} never reached ${status}`);
}

test('the sign-in link arrives and is well formed', async () => {
  assert.match(process.env.ZAP_API_KEY, /^zap_test_/); // never run CI against a live key
  const run = process.env.GITHUB_RUN_ID ?? Date.now();
  const { id } = await sendMagicLink(`delivered+ci-${run}@simulator.zapsmtp.xyz`);
  await waitForStatus(id, 'delivered');
  const { html } = await zap('GET', `/emails/${id}/content`);
  const link = html.match(/href="([^"]+)"/)[1];
  assert.match(link, /^https:\/\/acme\.com\/login\/verify\?id=/);
});

test('a bounce is reported', async () => {
  const { id } = await sendMagicLink('bounced@simulator.zapsmtp.xyz');
  const email = await waitForStatus(id, 'bounced');
  assert.equal(email.recipients[0].status, 'bounced');
});
Store the zap_test_ key as a CI secret.

Expiry is enforced by your application, not by the email: check the stored expiry and delete the token on first use. Some corporate mail filters open links to scan them, so let the link open a page whose button completes the sign-in. Test-key email carries test: true in webhooks and does not count against your monthly quota. All scenarios: Testing & simulator.

8. Sending for many client domains (agencies)

What you build: one integration that sends for many clients, each from its own domain, without one client’s key ever sending as another.

Two ways to separate clients
One account per clientFull separation: own domains, keys, webhooks, suppressions, logs, reputation and plan. Create client accounts from the account menu in the dashboard (there is no API for creating accounts), invite the client with a role, and use that account’s own key in the client’s integration. See Teams & client accounts.
One account, a key per domainYour account holds every client domain; each client gets a sending_access key restricted with domain_id. Domains, quota, suppression list, webhooks and logs are shared, and only your full-access key can read logs and stats.

Pick separate accounts when clients need their own dashboard, billing or reputation; pick restricted keys when you run everything yourself. The code below automates the second option.

Add the client’s domain

POST/v1/domains

The response lists the DNS records (DKIM required, SPF and DMARC recommended) to hand to the client. Then call verify until status is verified. Requires a full-access live key.

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

Body parameters

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

Errors

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

Issue a key that can only send from that domain

POST/v1/api-keys

domain_id works with sending_access only. The key cannot read logs, change settings or send from any other domain (403 restricted_api_key). The token is shown once.

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

Body parameters

namestringrequired
Limits: 1-100 characters (not trimmed) → 422 invalid_parameter "name is required (max 100 chars)"
permissionstring
One of full_access, sending_access. Default full_access.
domain_idstring
Restrict sending to one of the account's domains. Only with sending_access. Limits: 422 "domain_id can only be used with sending_access" / "unknown domain_id"
modestring
test creates a zap_test_ key (see API keys). One of live, test. Default live.
streamstring
Restrict the key to one message stream. One of transactional, broadcast.
expires_atstring (RFC 3339)
Limits: must be in the future → 422 "expires_at must be in the future"
expires_in_daysinteger
Alternative to expires_at; when > 0 it overrides expires_at.

Errors

key_restrictedHTTP 403
this API key expires, so the keys it manages must expire no later than it does | this API key is limited to the <stream> stream, and so are the keys it manages
restricted_api_keyHTTP 403
test_key_restrictedHTTP 403
test-mode keys can send test email and read, not change the account: use a live key
invalid_parameterHTTP 422
name, permission, domain_id, mode, stream or expiry invalid
POST/v1/api-keys
curl -X POST 'https://zapsmtp.xyz/v1/api-keys' \
  -H "Authorization: Bearer $ZAP_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
  "name": "client-shop",
  "permission": "sending_access",
  "mode": "live",
  "domain_id": "019a1e9b-41c2-7a0e-b5d1-3f6c2e8a9d47"
}'
Set ZAP_API_KEY in your environment.
Response 201 Created
{
  "id": "019a1f05-3c4d-7e5f-8a6b-7c8d9e0f1a2b",
  "token": "zap_8fK2mQ9xLr3TzV7bN1cW5yHjP0aS4dE6gU2iO9kX",
  "key": {
    "id": "019a1f05-3c4d-7e5f-8a6b-7c8d9e0f1a2b",
    "name": "Production",
    "prefix": "zap_8fK2mQ",
    "permission": "sending_access",
    "mode": "live",
    "stream": null,
    "expires_at": null,
    "domain_id": "019a1f10-8a10-7b22-8c31-5d2e9f0a4b17",
    "last_used_at": null,
    "created_at": "2026-10-21T15:30:00Z"
  }
}
Onboard a client domain
import { zap } from './zap.mjs'; // ZAP_API_KEY: your full-access agency key

export async function onboardClient(client) {
  const domain = await zap('POST', '/domains', { name: client.sendingDomain });
  await sendDnsInstructions(client, domain.records); // [{ type, name, value, required }]
  await db.clients.update(client.id, { zapDomainId: domain.id });
}

// From a job, until it succeeds (DNS can take a while).
export async function activateClient(client) {
  const domain = await zap('POST', `/domains/${client.zapDomainId}/verify`);
  if (domain.status !== 'verified') return false;
  const { token, key } = await zap('POST', '/api-keys', {
    name: `client-${client.slug}`,
    permission: 'sending_access',
    mode: 'live',
    domain_id: client.zapDomainId,
  });
  await secrets.put(`zapsmtp/${client.slug}`, token); // your secret manager: shown once
  await db.clients.update(client.id, { zapKeyId: key.id });
  return true;
}

// Offboarding: the key stops working on the next request.
export const offboardClient = (client) => zap('DELETE', `/api-keys/${client.zapKeyId}`);

Restricted keys can also be limited to one stream (stream) and given an expiry (expires_at or expires_in_days). Change a key’s restrictions later with PATCH /api-keys/{id}; the token stays the same.

9. Retries with exponential backoff and Retry-After

What you build: a wrapper that retries exactly the failures worth retrying, waits at least as long as the server asks, and never sends an email twice.

What to retry
RetryNetwork errors and timeouts; 429 rate_limit_exceeded (Retry-After: 1); 429 overloaded, the server at capacity (Retry-After: 1 or 5); 503 service_unavailable (Retry-After: 2 while a backing store fails over, 5 otherwise: the MTA unavailable, the suppression list loading, too many large bodies in flight); other 5xx; 409 idempotency_request_in_progress (the first attempt is still running).
Do not retry429 monthly_quota_exceeded and daily_quota_exceeded (wait for the reset or upgrade); 421 misdirected_cell: send to the URL the message names instead; every other 4xx: fix the request.
Never retry a 200A response with unconfirmed: true counts as sent; its events tell the outcome. failed_recipients lists addresses that were refused and not charged.
Retry with backoff
import crypto from 'node:crypto';
import { zap } from './zap.mjs';

// Worth retrying: network errors, 5xx, the per-second rate limit and a duplicate in flight.
// Not worth it: monthly_quota_exceeded, daily_quota_exceeded and every other 4xx.
const RETRY_CODES = new Set(['rate_limit_exceeded', 'idempotency_request_in_progress']);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

export async function withRetry(call, { attempts = 6, baseMs = 500, maxMs = 30_000 } = {}) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await call();
    } catch (err) {
      const retry = err.status === undefined || err.status >= 500 || RETRY_CODES.has(err.code);
      if (!retry || attempt >= attempts) throw err;
      const backoff = Math.random() * Math.min(maxMs, baseMs * 2 ** attempt); // full jitter
      await sleep(Math.max(backoff, (err.retryAfter || 0) * 1000));          // never sooner than Retry-After
    }
  }
}

// The key is created once, outside the loop: every attempt is the same request.
export function sendEmail(email, idempotencyKey = crypto.randomUUID()) {
  return withRetry(() => zap('POST', '/emails', email, { 'Idempotency-Key': idempotencyKey }));
}

Create the Idempotency-Key once per logical email and reuse it on every attempt: if a timed-out attempt actually succeeded, the retry returns its response with Idempotent-Replayed: true. In a batch, per-email failures such as rate_limit_exceeded come back in errors[] of a 200 response; resend only those emails with a new key, as in recipe 4.

10. Next.js, Express, Django and Laravel

What you build: a send endpoint and a webhook endpoint in your framework, with the key read from the environment on the server.

  • Call ZapSMTP only from server code: Route Handlers or Server Actions in Next.js, never a client component.
  • Webhook routes need the raw body for the signature, and no CSRF check (the signature replaces it).
  • Answer webhooks with a 2xx quickly and dedupe on Zap-Event-Id.
Framework integration
// app/api/welcome/route.ts: a Route Handler runs on the server only.
// .env.local: ZAP_API_KEY=zap_...   Never prefix it with NEXT_PUBLIC_.
import { NextResponse } from 'next/server';

export const runtime = 'nodejs';

export async function POST(req: Request) {
  const { userId, email, firstName } = await req.json(); // authenticate the caller first
  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': `welcome-${userId}`,
    },
    body: JSON.stringify({
      from: 'Acme <hello@acme.com>',
      to: [email],
      template: { id: 'welcome', variables: { first_name: firstName } },
    }),
  });
  const data = await res.json();
  if (!res.ok) return NextResponse.json({ error: data.name }, { status: 502 });
  return NextResponse.json({ id: data.id });
}

// app/api/hooks/zapsmtp/route.ts: read the raw text before parsing.
import { verifyZapSignature } from '@/lib/zap'; // from Setup

export async function POST(req: Request) {
  const raw = await req.text();
  if (!verifyZapSignature(raw, req.headers.get('Zap-Signature'))) {
    return new Response('bad signature', { status: 400 });
  }
  const event = JSON.parse(raw); // dedupe on req.headers.get('Zap-Event-Id')
  return new Response(null, { status: 200 });
}
The zap helpers come from Setup.