Zettace Send
Send email from your apps, through your own domain
Zettace Send delivers your apps' and websites' email (sign-in codes, receipts, alerts and newsletters) from addresses like noreply@yourcompany.com, through your company's own mail server. There are three ways to connect, and all of them share the same sending log, bounce handling, webhooks and monthly allowance:
https://app.zettace.com. Every request uses HTTPS. Send has to be turned on for your company, and your admins manage it in Settings › Company › Send.Quickstart
- Add a sending address. In Zettace, open Settings › Company › Send › Sending addresses and add one, such as
noreply@yourcompany.com. Use Check DNS to see if SPF, DKIM and DMARC are set up. - Create an API key under API & SMTP. Copy it straight away, because it's only shown once. Keep it on your server, never in a website or mobile app.
- Send your first email:
curl https://app.zettace.com/api/v1/send \
-H "Authorization: Bearer $ZETTACE_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": { "email": "noreply@yourcompany.com", "name": "Your App" },
"to": "ada@example.com",
"subject": "Your sign-in code",
"html": "<p>Your code is <b>482913</b></p>"
}'
const res = await fetch('https://app.zettace.com/api/v1/send', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZETTACE_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: { email: 'noreply@yourcompany.com', name: 'Your App' },
to: 'ada@example.com',
subject: 'Your sign-in code',
html: '<p>Your code is <b>482913</b></p>',
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.error}`);
console.log('Queued', data.id);
import os, requests
res = requests.post(
"https://app.zettace.com/api/v1/send",
headers={"Authorization": f"Bearer {os.environ['ZETTACE_KEY']}"},
json={
"from": {"email": "noreply@yourcompany.com", "name": "Your App"},
"to": "ada@example.com",
"subject": "Your sign-in code",
"html": "<p>Your code is <b>482913</b></p>",
},
timeout=15,
)
res.raise_for_status()
print("Queued", res.json()["id"])
$ch = curl_init('https://app.zettace.com/api/v1/send');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('ZETTACE_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'from' => ['email' => 'noreply@yourcompany.com', 'name' => 'Your App'],
'to' => 'ada@example.com',
'subject' => 'Your sign-in code',
'html' => '<p>Your code is <b>482913</b></p>',
]),
]);
$data = json_decode(curl_exec($ch), true);
echo 'Queued ' . $data['id'];
The answer is 202 Accepted with the email's id. Zettace delivers it within seconds, and it appears in Sending log.
{ "id": 1042, "messageId": "<9f2c…@yourcompany.com>", "status": "queued" }
API keys
Send your key in the Authorization header as Bearer <key>. X-Api-Key: <key> works too.
| Key | Starts with | What happens |
|---|---|---|
| Live | zs_live_ | Emails are delivered and count towards your monthly allowance. |
| Test | zs_test_ | Emails are checked and logged with the status test, but never delivered or counted. See Test mode. |
- Give each app or website its own key, so you can revoke one without stopping the others.
- A key can be limited to some sending addresses.
- Zettace stores only a fingerprint of each key. If you lose one, revoke it and create a new one.
- Never put a key in a website's JavaScript or a mobile app: anyone could read it. Send from your server.
Sending addresses
Your company's admins add the addresses your apps may send from, such as noreply@ or receipts@. Zettace signs in to each one on its mail server, with its login stored encrypted. Email is delivered through your mail server and appears to come from your domain.
To keep email out of spam, your domain needs these DNS records. Check DNS next to each address shows what's missing.
| Record | Why |
|---|---|
| SPF | Says your mail host may send for your domain. On cPanel, go to Email Deliverability › Repair. |
| DKIM | Signs each email, so receivers know it wasn't changed. On cPanel, the same Repair button sets it up. |
| DMARC | Tells receivers what to do with failures. Gmail and Yahoo expect it from bulk senders. Start with v=DMARC1; p=none. |
POST /api/v1/send
Queues one email. Every recipient in to, cc and bcc gets the same email and counts as one towards your allowance.
Body
fromrequired | string or object | One of your sending addresses: "noreply@yourcompany.com" or {"email": "…", "name": "Your App"}. Without a name, the address's own sender name is used. |
torequired | string, array | "ada@example.com", ["a@x.com", "b@y.com"] or [{"email": "…", "name": "…"}]. |
cc, bcc | same as to | Copies. At most 50 recipients in all. |
replyTo | string or object | Where replies go. Defaults to the sending address's reply-to setting. |
subjectrequired | string | Can use {{fields}}. Not needed with a template that has one. |
html | string | The email's HTML. Give html, text or a templateId. |
text | string | The plain-text version. If you leave it out, it's made from the HTML. |
templateId | number | A template saved in Zettace. See Templates. |
params | object | Values for {{fields}}, e.g. {"code": "482913"}. They're made safe for HTML. |
attachments | array | [{"filename": "invoice.pdf", "content": "<base64>", "contentType": "application/pdf"}]. See Attachments. |
headers | object | Extra headers: X-…, List-Unsubscribe, In-Reply-To and References only. |
tags | array | Up to 10 labels, e.g. ["sign-in"]. They're returned in webhooks and the log. |
scheduledAt | string | An ISO date and time, up to 72 hours ahead. See Scheduling. |
track | object | {"opens": true, "clicks": true}. Defaults to your company's setting. See Opens and clicks. |
Headers
Authorizationrequired | string | Bearer zs_live_… |
Idempotency-Key | string | Any unique value, such as an order number. Sending again with the same key returns the first email and doesn't send another. See Avoiding double sends. |
Answer
202 when it's queued. You get 200 with the original email when the Idempotency-Key was already used.
{
"id": 1042,
"messageId": "<9f2c41d8e0a7b3c6.mh2x9k1z@yourcompany.com>",
"status": "queued",
"test": true
}
status is queued, blocked (every recipient is on your blocked list) or test. test appears only for test keys. On errors, see Errors.
GET /api/v1/messages/:id
An email's status and history, using the id from sending.
curl https://app.zettace.com/api/v1/messages/1042 -H "Authorization: Bearer $ZETTACE_KEY"
{
"message": {
"id": 1042,
"kind": "api",
"from": "noreply@yourcompany.com",
"to": "ada@example.com",
"recipients": 1,
"subject": "Your sign-in code",
"tags": ["sign-in"],
"status": "sent",
"error": null,
"attempts": 0,
"messageId": "<9f2c41d8e0a7b3c6.mh2x9k1z@yourcompany.com>",
"createdAt": 1794001200000,
"sentAt": 1794001201250,
"nextAttemptAt": null,
"opens": 2,
"clicks": 1,
"firstOpenAt": 1794001500000,
"firstClickAt": 1794001510000,
"activity": [
{ "type": "sent", "detail": "250 OK", "at": 1794001201250 },
{ "type": "opened", "detail": null, "at": 1794001500000 },
{ "type": "clicked", "detail": "https://yourcompany.com/verify", "at": 1794001510000 }
]
}
}
Times are milliseconds since 1 January 1970 (UTC). For the meaning of each status, see Email statuses.
POST /api/v3/smtp/email
The same call as Brevo's send a transactional email, taking the same body and giving the same answer. An app built for Brevo moves to Zettace by changing two settings:
| Setting | Brevo | Zettace |
|---|---|---|
| API address | https://api.brevo.com/v3 | https://app.zettace.com/api/v3 |
api-key header | Your Brevo key | Your Zettace key |
curl https://app.zettace.com/api/v3/smtp/email \
-H "api-key: $ZETTACE_KEY" \
-H "Content-Type: application/json" \
-d '{
"sender": { "email": "noreply@yourcompany.com", "name": "ZetteMart" },
"to": [{ "email": "shopper@example.com", "name": "Tolu" }],
"subject": "Your verification code",
"htmlContent": "<p>Your code is {{ params.otp }}</p>",
"params": { "otp": "482913" }
}'
The answer is 201 with {"messageId": "<…>"}. Errors use Brevo's format, {"code": "…", "message": "…"}.
- Supported:
sender,to,cc,bcc,replyTo,subject,htmlContent,textContent,templateId,params,attachment(content+name),headers,tagsandscheduledAt. sender.emailmust be one of your Zettace sending addresses.templateIdis a Zettace template's number, not a Brevo one.{{ params.name }}works in templates.- Not supported:
messageVersions(send one request each) and attachments byurl(sendcontent).
Example: Spring Boot (Java) that calls Brevo with RestClient
Move the base URL into a setting, then point it at Zettace:
// application.yaml
brevo:
baseUrl: ${BREVO_BASE_URL:https://api.brevo.com/v3}
apiKey: ${BREVO_API_KEY:}
// EmailService.java
this.restClient = RestClient.builder()
.baseUrl(brevoConfiguration.getBaseUrl())
.build();
Then set BREVO_BASE_URL=https://app.zettace.com/api/v3 and BREVO_API_KEY=zs_live_…. To switch back to Brevo, change those two values back.
SMTP relay
For anything that has SMTP settings. No code changes are needed.
| Host | app.zettace.com |
| Port | 587 with STARTTLS (recommended), or 465 with SSL/TLS |
| Username | Anything, e.g. your company domain |
| Password | Your Zettace API key |
| From | One of your sending addresses, in both the envelope and the From header |
import nodemailer from 'nodemailer';
const transport = nodemailer.createTransport({
host: 'app.zettace.com',
port: 587,
auth: { user: 'yourcompany.com', pass: process.env.ZETTACE_KEY },
});
await transport.sendMail({
from: '"Your App" <noreply@yourcompany.com>',
to: 'ada@example.com',
subject: 'Welcome!',
html: '<p>Thanks for signing up.</p>',
});
MAIL_MAILER=smtp
MAIL_HOST=app.zettace.com
MAIL_PORT=587
MAIL_USERNAME=yourcompany.com
MAIL_PASSWORD=zs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=noreply@yourcompany.com
MAIL_FROM_NAME="Your App"
# settings.py
EMAIL_HOST = "app.zettace.com"
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_USER = "yourcompany.com"
EMAIL_HOST_PASSWORD = os.environ["ZETTACE_KEY"]
DEFAULT_FROM_EMAIL = "Your App <noreply@yourcompany.com>"
import os, smtplib
from email.message import EmailMessage
msg = EmailMessage()
msg["From"] = "Your App <noreply@yourcompany.com>"
msg["To"] = "ada@example.com"
msg["Subject"] = "Welcome!"
msg.set_content("Thanks for signing up.")
with smtplib.SMTP("app.zettace.com", 587) as s:
s.starttls()
s.login("yourcompany.com", os.environ["ZETTACE_KEY"])
s.send_message(msg)
- Emails are sent exactly as your app built them, so opens and clicks aren't tracked.
- At most 50 recipients and 15 MB per email.
- After 10 failed sign-ins from one address, the relay refuses sign-ins from it for 15 minutes.
- The relay answers
250 Queued as <id>. Look the email up in the log or with GET /api/v1/messages/:id.
Templates
Keep your email designs in Zettace (Settings › Send › Templates, with a live preview), and send by number with only the values that change:
{
"from": "noreply@yourcompany.com",
"to": "ada@example.com",
"templateId": 3,
"params": { "name": "Ada", "orderId": "1042", "total": "₦24,500" }
}
- Fields look like
{{name}}. Brevo's{{ params.name }}works too, and so do nested values like{{order.id}}. - Values are made safe for HTML. A field with no value is left empty.
{{email}}and{{name}}are filled from the first recipient, unlessparamsgives them.- A
subject,htmlortextin the request replaces the template's.
Attachments
Send files as base64 in attachments. An email can be at most 10 MB, attachments included.
import { readFile } from 'node:fs/promises';
const pdf = await readFile('invoice-1042.pdf');
body.attachments = [{
filename: 'invoice-1042.pdf',
content: pdf.toString('base64'),
contentType: 'application/pdf',
}];
Scheduling
Add scheduledAt to send later, up to 72 hours ahead. A time in the past sends straight away.
{ "scheduledAt": "2026-11-10T09:00:00+01:00" }
Avoiding double sends
Networks fail: a request can reach Zettace even when your app never sees the answer. Send an Idempotency-Key header, and a retry with the same key gives you the first email back instead of sending a second one:
curl https://app.zettace.com/api/v1/send \
-H "Authorization: Bearer $ZETTACE_KEY" \
-H "Idempotency-Key: order-1042-confirmation" \
-H "Content-Type: application/json" \
-d '{ … }'
Use something unique to what the email is for, such as an order number plus the kind of email. With the Brevo-compatible API, send the same header.
Opens and clicks
Turn tracking on per email with "track": {"opens": true, "clicks": true}, or for every API email in Settings › Send › Overview. Campaigns have their own setting.
- Opens are counted with a tiny image. Some mail apps load images for privacy (Apple Mail, for example) and others block them, so treat open counts as a guide.
- Clicks go through a short Zettace link first, then straight to your page. These links are signed, so nobody can use them to send people elsewhere.
- Counts appear in the log, in an email's status, and as
openedandclickedwebhook events.
Test mode
A test key (zs_test_…) checks everything a live key would: the sender, recipients, template and fields. The email is logged with the status test but never delivered or counted. Use it in development and automated tests.
Webhooks
Add your URL in Settings › Send › Webhooks and pick the events you want. Zettace POSTs JSON to it as things happen. Answer with any 2xx within 10 seconds. If you don't, Zettace tries again after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours and 6 hours.
| Event | When |
|---|---|
sent | Your mail server accepted the email for delivery. |
deferred | A temporary problem. It will be tried again (reason says why). |
bounced | A recipient's server refused it, or reported it undeliverable, or the recipient marked it as spam. |
failed | It couldn't be sent after all retries. |
blocked | Not sent, because every recipient is on your blocked list. |
opened | Opened for the first time (with tracking on). |
clicked | A link was clicked for the first time (once per link). Includes the url. |
unsubscribed | A contact unsubscribed from a campaign. |
{
"event": "bounced",
"at": "2026-11-07T10:24:13.120Z",
"id": 1042,
"messageId": "<9f2c41d8e0a7b3c6.mh2x9k1z@yourcompany.com>",
"from": "noreply@yourcompany.com",
"email": "ada@example.com",
"subject": "Your sign-in code",
"tags": ["sign-in"],
"campaignId": null,
"reason": "ada@example.com: 550 5.1.1 User unknown"
}
Checking the signature
Every request has an X-Zettace-Signature: t=<unix seconds>,v1=<hex> header. v1 is an HMAC-SHA256 of t + "." + the raw body, made with your webhook's signing secret (whsec_…, shown once when you add the webhook). Check it with the raw body, before parsing, and reject old timestamps:
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/webhooks/zettace', express.raw({ type: 'application/json' }), (req, res) => {
const [t, v1] = (req.get('x-zettace-signature') || '').split(',').map((p) => p.split('=')[1]);
const expected = crypto.createHmac('sha256', process.env.ZETTACE_WEBHOOK_SECRET)
.update(`${t}.${req.body}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
if (!fresh || !v1 || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body);
// e.g. stop emailing addresses that bounce
res.sendStatus(200);
});
import hmac, hashlib, os, time, json
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/webhooks/zettace")
def zettace():
parts = dict(p.split("=", 1) for p in request.headers.get("X-Zettace-Signature", "").split(","))
t, v1 = parts.get("t", "0"), parts.get("v1", "")
body = request.get_data()
expected = hmac.new(os.environ["ZETTACE_WEBHOOK_SECRET"].encode(),
f"{t}.".encode() + body, hashlib.sha256).hexdigest()
if abs(time.time() - int(t)) > 300 or not hmac.compare_digest(expected, v1):
abort(400)
event = json.loads(body)
return "", 200
$body = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_ZETTACE_SIGNATURE'] ?? ''), $sig);
$expected = hash_hmac('sha256', ($sig['t'] ?? '') . '.' . $body, getenv('ZETTACE_WEBHOOK_SECRET'));
if (abs(time() - (int) ($sig['t'] ?? 0)) > 300 || !hash_equals($expected, $sig['v1'] ?? '')) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
http_response_code(200);
Use Test next to a webhook to send it a test event. Events can arrive more than once or out of order, so use id plus event to skip ones you've already handled.
Delivery, bounces and blocks
- Your hourly limit. Mail hosts cap how many emails an address may send per hour. Emails past your company's limit wait in the queue and go out as it frees up. Codes and receipts always go before newsletters: campaigns only use 80% of the limit.
- Retries. If the mail server is busy or unreachable, Zettace tries again after 1, 5 and 15 minutes, then 1 and 3 hours. After that the email is
failed. - Bounces. If a recipient's server refuses an address outright, or a "delivery failed" report arrives in the sending address's inbox (checked every 10 minutes), the email is marked
bouncedand the address is blocked. - Complaints. If someone marks your email as spam and their provider reports it, the address is blocked.
- Unsubscribes only stop campaigns. Codes, receipts and other emails people asked for still arrive.
- Blocked addresses are listed in Settings › Send › Blocked, where they can be unblocked. An email to a blocked address is logged as
blockedand isn't counted.
Campaigns
Newsletters are sent from Settings › Send, without code:
- Contacts: make lists and import them from CSV.
emailis needed andnameis optional. Other columns become fields, such as{{city}}. - Campaigns: write the HTML with a live preview using a real contact, send yourself a test, then send now or schedule it.
- Each contact gets their own copy, with
{{name}},{{first_name}},{{email}}and your fields filled in. - An unsubscribe footer is added unless you place
{{unsubscribe_url}}yourself. Gmail and Yahoo get one-click unsubscribe headers. - Stats show sent, opened, clicked, unsubscribed and bounced, and each email appears in the log.
Email statuses
| Status | Meaning |
|---|---|
queued | Waiting to be sent: just accepted, scheduled, waiting for the hourly limit, or waiting to be retried (see nextAttemptAt). |
sending | Being handed to your mail server right now. |
sent | Your mail server accepted it for delivery. |
bounced | Refused, or reported as undeliverable. error says why. |
failed | Couldn't be sent after all retries, or the sending address was removed. |
blocked | Not sent: every recipient is on your blocked list. |
test | Sent with a test key: logged, never delivered. |
Errors
Errors come back as {"error": "…", "code": "…"}, or {"code": "…", "message": "…"} from the Brevo-compatible API. The message explains what to fix and can be shown to your developers.
| HTTP | code | What happened |
|---|---|---|
| 400 | missing_parameter | Something required is missing: from, to, subject, or html/text/templateId. |
| 400 | invalid_parameter | A value isn't right: an address, a date, too many recipients, an attachment without content… |
| 400 | invalid_sender | from isn't one of your sending addresses. |
| 401 | unauthorized | The API key is missing, wrong or revoked. |
| 403 | sender_not_allowed | This key can't send from that address. |
| 403 | not_enabled | Send isn't turned on for your company, or the company is suspended. |
| 404 | document_not_found | No template with that templateId. |
| 404 | not_found | No email with that id (GET). |
| 413 | invalid_parameter | The email is bigger than 10 MB. |
| 429 | rate_limited | More than 30 requests a second with one key. Wait a moment and try again. (Brevo-compatible: too_many_requests.) |
| 429 | limit_reached | This month's allowance is used up. Ask Zettace for a bigger plan. |
| 500 | internal_error | Something went wrong at Zettace. It's safe to retry with the same Idempotency-Key. |
Limits
| Recipients per email | 50 (to + cc + bcc) |
| Email size | 10 MB by API, 15 MB by SMTP, attachments included |
| Requests | 30 a second per API key |
| Scheduling | Up to 72 hours ahead |
| Emails per hour | Your company's hourly limit (your mail host's cap) |
| Emails per month | Your plan's allowance. Every recipient counts; test and blocked emails don't. |
| Sending log | Kept for 90 days |
Questions, or need a bigger plan?
Talk to us