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:

Base URL 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

  1. 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.
  2. 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.
  3. 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.

KeyStarts withWhat happens
Livezs_live_Emails are delivered and count towards your monthly allowance.
Testzs_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.

RecordWhy
SPFSays your mail host may send for your domain. On cPanel, go to Email Deliverability › Repair.
DKIMSigns each email, so receivers know it wasn't changed. On cPanel, the same Repair button sets it up.
DMARCTells 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

fromrequiredstring or objectOne of your sending addresses: "noreply@yourcompany.com" or {"email": "…", "name": "Your App"}. Without a name, the address's own sender name is used.
torequiredstring, array"ada@example.com", ["a@x.com", "b@y.com"] or [{"email": "…", "name": "…"}].
cc, bccsame as toCopies. At most 50 recipients in all.
replyTostring or objectWhere replies go. Defaults to the sending address's reply-to setting.
subjectrequiredstringCan use {{fields}}. Not needed with a template that has one.
htmlstringThe email's HTML. Give html, text or a templateId.
textstringThe plain-text version. If you leave it out, it's made from the HTML.
templateIdnumberA template saved in Zettace. See Templates.
paramsobjectValues for {{fields}}, e.g. {"code": "482913"}. They're made safe for HTML.
attachmentsarray[{"filename": "invoice.pdf", "content": "<base64>", "contentType": "application/pdf"}]. See Attachments.
headersobjectExtra headers: X-…, List-Unsubscribe, In-Reply-To and References only.
tagsarrayUp to 10 labels, e.g. ["sign-in"]. They're returned in webhooks and the log.
scheduledAtstringAn ISO date and time, up to 72 hours ahead. See Scheduling.
trackobject{"opens": true, "clicks": true}. Defaults to your company's setting. See Opens and clicks.

Headers

AuthorizationrequiredstringBearer zs_live_…
Idempotency-KeystringAny 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:

SettingBrevoZettace
API addresshttps://api.brevo.com/v3https://app.zettace.com/api/v3
api-key headerYour Brevo keyYour 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, tags and scheduledAt.
  • sender.email must be one of your Zettace sending addresses.
  • templateId is a Zettace template's number, not a Brevo one. {{ params.name }} works in templates.
  • Not supported: messageVersions (send one request each) and attachments by url (send content).
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.

Hostapp.zettace.com
Port587 with STARTTLS (recommended), or 465 with SSL/TLS
UsernameAnything, e.g. your company domain
PasswordYour Zettace API key
FromOne 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, unless params gives them.
  • A subject, html or text in 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 opened and clicked webhook 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.

EventWhen
sentYour mail server accepted the email for delivery.
deferredA temporary problem. It will be tried again (reason says why).
bouncedA recipient's server refused it, or reported it undeliverable, or the recipient marked it as spam.
failedIt couldn't be sent after all retries.
blockedNot sent, because every recipient is on your blocked list.
openedOpened for the first time (with tracking on).
clickedA link was clicked for the first time (once per link). Includes the url.
unsubscribedA 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 bounced and 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 blocked and isn't counted.

Campaigns

Newsletters are sent from Settings › Send, without code:

  • Contacts: make lists and import them from CSV. email is needed and name is 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.
Only email people who agreed to hear from you. Complaints and bounces hurt your domain's reputation for all your email, including your staff's.

Email statuses

StatusMeaning
queuedWaiting to be sent: just accepted, scheduled, waiting for the hourly limit, or waiting to be retried (see nextAttemptAt).
sendingBeing handed to your mail server right now.
sentYour mail server accepted it for delivery.
bouncedRefused, or reported as undeliverable. error says why.
failedCouldn't be sent after all retries, or the sending address was removed.
blockedNot sent: every recipient is on your blocked list.
testSent 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.

HTTPcodeWhat happened
400missing_parameterSomething required is missing: from, to, subject, or html/text/templateId.
400invalid_parameterA value isn't right: an address, a date, too many recipients, an attachment without content…
400invalid_senderfrom isn't one of your sending addresses.
401unauthorizedThe API key is missing, wrong or revoked.
403sender_not_allowedThis key can't send from that address.
403not_enabledSend isn't turned on for your company, or the company is suspended.
404document_not_foundNo template with that templateId.
404not_foundNo email with that id (GET).
413invalid_parameterThe email is bigger than 10 MB.
429rate_limitedMore than 30 requests a second with one key. Wait a moment and try again. (Brevo-compatible: too_many_requests.)
429limit_reachedThis month's allowance is used up. Ask Zettace for a bigger plan.
500internal_errorSomething went wrong at Zettace. It's safe to retry with the same Idempotency-Key.

Limits

Recipients per email50 (to + cc + bcc)
Email size10 MB by API, 15 MB by SMTP, attachments included
Requests30 a second per API key
SchedulingUp to 72 hours ahead
Emails per hourYour company's hourly limit (your mail host's cap)
Emails per monthYour plan's allowance. Every recipient counts; test and blocked emails don't.
Sending logKept for 90 days

Questions, or need a bigger plan?

Talk to us