# ZevSend developer documentation — full text bundle
> Every page on `docs.zevsend.com` concatenated into one file. Page boundaries are marked by `===` separators carrying the page title and canonical URL so an LLM can cite back to the source.
For a curated map without the full body text, see https://docs.zevsend.com/llms.txt. For a single page, append `.md` to its URL.
---
===
# Quickstart
> Send your first transactional email in under five minutes.
Source: https://docs.zevsend.com/guide/quickstart
---
This guide takes you from a fresh account to a sent message in
under five minutes. Everything here runs in sandbox mode, so you
don't need to verify a domain or wait for approval.
## 1. Create an account
Sign up at [console.zevsend.com](https://console.zevsend.com).
Your first team is created automatically and starts in sandbox.
## 2. Grab a test key
From the dashboard, open **Settings → API keys** and create one.
Copy the value once shown — we never store it in a recoverable
form. Test-mode keys start with `sk_test_`; production keys start
with `sk_live_` and only appear after your team goes live.
```text
sk_test_a1b2c3d4e5f6...
```
## 3. Send your first email
The body of your email is generated in your own codebase the
same way you'd render it for any other transactional email
service. Pass the HTML and we deliver it.
```bash
curl https://api.zevsend.com/v1/emails \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"to": "you@example.com",
"subject": "Hello from ZevSend",
"html": "
If you can see this, the integration works.
"
}'
```
In sandbox the `from` is locked to a platform sender, so you
don't need to specify it. Once you verify a domain, you'll pass
your own `from` and the platform sender goes away.
You should get back a `202 Accepted` with the message id:
```json
{
"id": "eml_01HXYZ...",
"to": "you@example.com",
"from": "onboarding@zevsend.dev",
"subject": "Hello from ZevSend",
"status": "sending",
"sandbox": true,
"created_at": "2025-05-24T12:34:56.789Z"
}
```
Open the **Messages** page in the dashboard and watch the state
transition from `sending` → `sent` → `delivered` in real time.
## 4. (Optional) Try SMS or WhatsApp
SMS and WhatsApp run through pre-approved templates rather than
raw bodies. Every team ships with a starter template you can use
for the first send — find its id on the **Templates** page.
```bash
curl https://api.zevsend.com/v1/sms \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"to": "+2348012345678",
"template_id": "tpl_xxxxxxxxxxxxx",
"variables": { "code": "847291" }
}'
```
The response shape is identical to email — the same `id`,
`status`, `sandbox`, and `created_at` fields. One client; three
channels.
## What's next
- Set up a [verified domain](/guide/domains) so your live emails
send from your own brand.
- Subscribe to [webhooks](/webhooks/) so your app reacts to
delivery, bounce, and complaint events.
- Browse the [API reference](/api/) for the full surface,
including every error code and parameter.
===
# Sandbox and live mode
> How sandbox mode differs from live, and how to graduate your team.
Source: https://docs.zevsend.com/guide/sandbox-and-live
---
Every team starts in **sandbox**. Sandbox lets you build and test
the full API surface without worrying about deliverability,
domain verification, or compromising real recipients.
## What's different in sandbox
| Behaviour | Sandbox | Live |
| ------------------------ | ------------------------------------------------------ | --------------------------------------------------- |
| API endpoints | Same. | Same. |
| API keys | `sk_test_…` | `sk_live_…` |
| Email `from` address | A ZevSend-owned default sender. | Your verified domain. |
| SMS sender label | A shared sandbox sender. | Your approved sender ID, or the platform default. |
| Recipient delivery | Allowed recipients only (your own verified addresses). | Anyone. |
| Suppressions | Tracked but isolated to your team. | Tracked and enforced platform-wide. |
| Webhook events | Fire as normal. | Fire as normal. |
| Dashboard volume metrics | Visible but flagged `sandbox`. | Counts toward your billable usage. |
## Allowed recipients in sandbox
In sandbox, we only deliver to recipients that have been verified
on your team. This is the safety net that lets you put the API on
a staging environment without risk. Add and verify recipients
from the dashboard under **Settings → Verified recipients**.
Sending to an unverified address still returns `202 Accepted` and
shows up in the dashboard, but the message terminates with
`failed` and a clear reason — no spam leaves the platform.
## Going live
To switch to live mode you'll need to:
1. **Verify at least one domain.** This is the brand identity
we'll use as the `from` for email and the brand context for
SMS and WhatsApp.
2. **Submit your brand details.** Customer support email, legal
name, and a support URL. These are what we use to keep
ZevSend a trustworthy place to receive a message from — and
what recipients see when they ask "who is this?".
3. **Request a go-live review.** From the dashboard, click
**Go live**. We review and either approve or come back with
questions.
Once approved, your test-mode API keys keep working for the
sandbox flow, and a separate `sk_live_` key takes over for
production traffic. Both can run side by side.
## Switching keys at runtime
Use `sk_test_` and `sk_live_` keys to target sandbox or live
explicitly. Your code never needs to branch on environment —
the key implies the mode.
```ts
const apiKey =
process.env.NODE_ENV === 'production'
? process.env.ZEVSEND_LIVE_KEY
: process.env.ZEVSEND_TEST_KEY;
```
===
# Domains and brand identity
> Add and verify a domain so live messages send from your own brand.
Source: https://docs.zevsend.com/guide/domains
---
A **domain** is the brand identity your messages send from. After
you verify ownership and complete your brand profile, ZevSend
uses the domain as:
- The `from` address for **email**.
- The brand context (`brand.name`, `brand.support_email`,
`brand.support_url`, etc.) for **SMS** and **WhatsApp**.
Until you have at least one approved domain, your team is locked
to sandbox.
## Add a domain
From the dashboard, open **Domains → Add domain** and enter the
hostname you'd like to send from (for example, `mail.acme.com`).
We'll generate three DNS records for you:
- A **DKIM** record (one CNAME or TXT, depending on your DNS host).
- An **SPF** include (TXT).
- A **return-path** CNAME.
Add the records at your DNS provider. Most propagate in a few
minutes; some take longer. Click **Verify** to check.
## Complete your brand profile
Verification proves you control the DNS. The brand profile tells
recipients who you are. We require:
- **Brand name** — shown to recipients in plain-text contexts.
- **Support email** — where customers should reply or escalate.
- **Legal name** — for compliance footers in regulated regions.
- **Support URL** — your help centre or contact page.
These values are exposed inside templates through the `brand.*`
namespace (see [Variables](/guide/variables)). They're injected
on the server side — your API call never controls them — so a
leaked API key can't impersonate someone else's brand.
## Multi-domain teams
A team can own more than one domain (for example, `mail.acme.com`
and `mail.acme-eu.com`). When you send, we use the team's most
recently approved domain unless you pass `brand_domain_id`:
```json
{
"to": "customer@example.com",
"template_id": "tpl_…",
"brand_domain_id": "dom_…",
"variables": { ... }
}
```
You can also pin an API key to a specific domain when you create
it — useful if a single team owns two brands and you want a
leaked key to only impact one of them.
## What happens after approval
Each domain is reviewed before it can be used for live sends. We
look for the basics: that the brand profile makes sense, that
the support contact resolves, and that the domain itself looks
legitimate. Approval is usually same-day.
Once a domain is approved, live sends that target it will use
its brand identity automatically. Sandbox sends ignore domains
entirely — they always render against a sandbox brand stub.
===
# Templates
> Pre-approved message content with declared variables. Optional for email; required for SMS and WhatsApp.
Source: https://docs.zevsend.com/guide/templates
---
A **template** is pre-approved message content with declared
variables. You reference it by id (`tpl_…`) when you send; we
render the body against the variables you pass.
Templates aren't required for every channel:
| Channel | Templates | Why |
| -------- | ---------- | ---------------------------------------------------------------------- |
| Email | Optional | You can also send a raw HTML or text body directly. |
| SMS | Required | Carrier networks require pre-registered content for transactional traffic. |
| WhatsApp | Required | WhatsApp's network only delivers approved templates outside the 24h window. |
When you do use a template, you get three things that direct
sends can't offer:
- **Editable copy.** Your support team can tweak wording in the
dashboard without a code deploy.
- **Server-injected brand.** Variables like `{{brand.name}}` and
`{{brand.support_email}}` are filled in from your verified
domain at send time. A leaked API key can't impersonate
another brand on your team.
- **Variable validation.** Required keys, type checks, and
missing-variable errors run before the message is dispatched.
## What's in a template
Each template has:
- A **name** for humans (e.g. "Order confirmation").
- A **channel** — `email`, `sms`, or `whatsapp`.
- A **content** body with `{{handlebars}}` placeholders.
- A **variable manifest** — the declared list of variables the
template accepts, with type and required/optional metadata.
When you call `POST /v1/{channel}`, we validate the variables
you pass against the manifest and reject mismatches before the
send leaves the API.
## Authoring templates
Templates are authored in the dashboard under **Templates → New
template**. Pick the channel, write the body, and declare
variables as you go. The dashboard previews the rendered output
side-by-side using sample values.
For email, the body is HTML with a plain-text fallback. For SMS,
the body is plain text capped at 1600 characters once rendered.
For WhatsApp, the body matches a pre-registered template body
on the WhatsApp network — only the variable values you pass
travel over the wire; the body itself is rendered by the
provider from the pre-registered version.
## Variable manifest
Each declared variable has:
| Field | Meaning |
| ------------ | --------------------------------------------------------------------------------- |
| `key` | The placeholder name, used as `{{customer.key}}`. |
| `label` | A human label shown in the dashboard preview. |
| `required` | When `true`, omitting it returns `422 missing_variable`. |
| `type` | One of `string`, `number`, `email`, `phone`, `url`, `code`. |
The order of variables matters for **WhatsApp**: it's the order
of the positional placeholders (`{{1}}`, `{{2}}`, …) in the
pre-registered template body. The dashboard makes the ordering
visible so you can't get it wrong.
## Send-time substitution
Variables you send live in the `customer.*` namespace and the
brand fields live in `brand.*`. So a template body like:
```text
Hi {{customer.first_name}}, your {{brand.name}} verification
code is {{customer.code}}. Reply STOP to opt out.
For help, email {{brand.support_email}}.
```
With this request:
```json
{
"to": "+2348012345678",
"template_id": "tpl_…",
"variables": {
"first_name": "Ada",
"code": "847291"
}
}
```
Renders as:
```text
Hi Ada, your Acme verification code is 847291.
Reply STOP to opt out. For help, email support@acme.com.
```
You never pass `brand.*` values from your code — they're filled
in from your domain's brand profile on the server side.
## Re-using templates across teams
Templates are scoped to your team. If you have a staging team
and a production team, author the templates in each one (you
can copy-paste). Keeping them separate means a draft template
can't accidentally go live before you mean it to.
===
# Variables
> How the customer and brand variable namespaces work.
Source: https://docs.zevsend.com/guide/variables
---
Templates use [Handlebars](https://handlebarsjs.com)-style
placeholders. Each placeholder belongs to one of two namespaces:
`customer.*` (values from your API request) and `brand.*` (values
filled in by us from the verified domain you send from).
## `customer.*` — values from your API call
Anything you pass in `variables` lives under `customer.*`. The
template references them as `{{customer.}}`. The variable
manifest on the template controls which keys are required and
what types they accept.
```json
{
"to": "ada@example.com",
"template_id": "tpl_…",
"variables": {
"first_name": "Ada",
"amount": "₦12,500"
}
}
```
Inside the template:
```text
Hi {{customer.first_name}}, your transfer of
{{customer.amount}} was successful.
```
If you pass a key the template doesn't declare, the API returns
`422 unknown_variable`. If you skip a required key, it returns
`422 missing_variable`. Both errors name the offending key in
`extensions.param` so your client can highlight the field.
## `brand.*` — values from your verified domain
The `brand` namespace is **never** passed in the API request.
We fill it in on the server side using the brand profile of the
verified domain the send is using. The keys are fixed:
| Key | Source |
| --------------------- | -------------------------------------- |
| `brand.name` | Brand name on the domain profile. |
| `brand.support_email` | Support email on the domain profile. |
| `brand.support_url` | Support URL on the domain profile. |
| `brand.legal_name` | Legal name on the domain profile. |
Use them anywhere you would have hardcoded your brand in the
template body:
```text
For help, contact {{brand.support_email}} or
visit {{brand.support_url}}.
```
This is why a leaked API key cannot impersonate someone else's
brand: the API key authenticates a team, but the `brand.*`
values come from a domain that team owns. If you've pinned the
API key to one specific domain, even another domain on the same
team is out of reach.
## Sandbox renders
In sandbox mode, the team isn't tied to a verified domain yet,
so `brand.*` resolves to a sandbox stub:
| Key | Sandbox value |
| --------------------- | -------------------------------- |
| `brand.name` | Your team name + " (sandbox)" |
| `brand.support_email` | `support@sandbox.zevsend.com` |
| `brand.support_url` | `https://sandbox.zevsend.com` |
| `brand.legal_name` | Your team name |
It's there so previews render and template wording is realistic
during development — your live messages always use your real
brand profile.
## Variable types
The variable manifest accepts these types:
| Type | What it validates |
| -------- | ---------------------------------------------------------- |
| `string` | Any value, coerced to string. |
| `number` | A numeric value or a string that parses as one. |
| `email` | Looks like an email address. |
| `phone` | A phone number, normalised to E.164 where possible. |
| `url` | A valid `http(s)` URL. |
| `code` | A short alphanumeric token. Useful for OTPs. |
Type validation runs before we hand the message off, so a
malformed value returns `422 invalid_variable` and the message
is never charged.
===
# Pricing and billing
> How email, SMS, WhatsApp, and Verify are priced, where to find your rates, and how Zev Credit pays for sends.
Source: https://docs.zevsend.com/guide/pricing
---
ZevSend has two billing layers. Plans cover what your team is
allowed to do: which channels, how much included credit, how
many domains and members. Per-send pricing covers what each
message actually costs, and is set on the template you send it
through.
## What you pay for
Each plan is a flat monthly or annual fee. Pick yearly on
checkout to lock the discounted rate. The plan price covers:
- Email up to the daily and monthly quotas printed on the plan
- The monthly **Zev Credit allowance** that drains as you send
paid messages
- The channels the plan enables. Free is email only; paid plans
add SMS, WhatsApp, and Verify
- Team members and verified sending domains within the plan
limits
Anything beyond the included quotas drains from your Zev Credit
balance. You can top up Zev Credit any time from the dashboard.
## Where to find your rates
Open the **Templates** page in the dashboard. Every template
shows its per-send price next to its name. Sample row:
```
otp_login_sms from ₦5 / send
```
The "from" prefix appears when the template has country-specific
pricing. Open the template to see the full breakdown of the
default rate and every per-country override.
The price is always shown in your team's billing currency, set
when the team was created.
## Per-channel pricing
Each channel has its own pricing shape. The model behind the
scenes is the same (read the price off the template you're
sending through), but the dimensions that affect cost differ.
### Email
Included in your plan up to the monthly quota. Past the quota,
each additional send drains the template's per-send price from
Zev Credit. Most email templates ship priced at zero so customers
who stay inside their plan never see a charge.
### SMS
SMS templates carry a per-send price. The price you see on the
template is the default rate. Some templates additionally have
**per-country overrides**: sending to recipients in those
countries uses the override rate instead of the default. We
surface every applicable rate inline on the template detail
panel so there is no hidden routing.
- We never expose which upstream carrier we use to deliver. The
template tells you what you pay, end of story.
- The rate is debited from Zev Credit the moment the carrier
accepts the send. Carrier rejection refunds the credit
automatically. Failed sends never burn balance.
- Sandbox sends are always free. Switch to live to start
draining credit.
### WhatsApp
WhatsApp pricing follows the same per-template plus per-country
override model as SMS. WhatsApp's network only delivers
pre-approved template bodies, so the template you see in the
dashboard is also the only thing we can actually send.
WhatsApp providers price by **category** internally
(authentication, utility, marketing) and by destination country.
Both of those land in your per-template rate before you see it.
You only need to look at the template's price, never compute it
yourself.
### Verify (OTP)
Verify has a flat per-currency rate set by the platform admin,
with no per-country variation. Each `start` call drains the
rate; the matching `check` call is free. See the
[Verify (OTP) API reference](/api/verify) for the request shape.
## How sends are charged
Every paid send runs the same cascade:
1. **Plan gate.** Your plan must enable the channel. If it
doesn't, the send fails with `feature_not_on_plan` before any
carrier work happens.
2. **Credit check.** Your Zev Credit balance must cover the
template's price for this send. Insufficient balance returns
`insufficient_credit` and the send doesn't dispatch.
3. **Dispatch.** We hand the message to the carrier.
4. **Debit.** On a successful submit, we drain the credit. Each
debit is keyed by the message's public id, so retries never
double-charge.
Carrier rejections happen before step four, so you only pay for
sends the carrier accepted.
## Currency
Your team's billing currency is set when the team is created and
stays fixed for the team's lifetime. Every invoice, every
template price, and every credit grant or debit uses that
currency. To bill in a different currency, create a separate
team. You can be the owner of multiple teams from the same
account.
## Topping up Zev Credit
Open **Credits** in the dashboard to add credit at any time. You
pick the amount, the payment method (ZevPay hosted checkout or
bank transfer), and the funds land on your team within minutes of
the payment settling. Bank transfers wait on operations to
confirm receipt; ZevPay credits the balance the moment the
webhook fires.
===
# Authentication
> How to authenticate API requests with a Bearer key.
Source: https://docs.zevsend.com/api/auth
---
ZevSend uses Bearer token authentication. Every request must
include a valid API key in the `Authorization` header:
```bash
curl https://api.zevsend.com/v1/emails \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json"
```
## Creating an API key
Open **Settings → API keys** in the dashboard and click
**Create key**. We'll show the secret value once. Copy it
immediately — we never store it in a recoverable form, and
losing it means creating a new key.
```text
sk_test_a1b2c3d4e5f6... # sandbox traffic
sk_live_a1b2c3d4e5f6... # live traffic
```
## Test vs live keys
The prefix tells you which mode the key targets:
| Prefix | Mode | When you can create one |
| ----------- | ------- | ------------------------------------ |
| `sk_test_…` | Sandbox | Immediately. Default for new teams. |
| `sk_live_…` | Live | After your team is approved. |
Both types call the same endpoints — the key implies the mode.
You can have both active at the same time so you can run your
staging environment against `sk_test_` and production against
`sk_live_`.
## Per-key domain scope
When you create a key you can pin it to a specific verified
domain. A key pinned to one domain can only send under that
brand identity — useful when a single team owns more than one
brand and you want to limit blast radius if a key leaks.
To send under a different domain on the same team, create a
separate key for that domain.
## Rotating a key
When you suspect a key is compromised:
1. Create a new key in the dashboard.
2. Deploy the new key to your application.
3. Revoke the old key from the dashboard.
Revoking a key takes effect immediately — in-flight requests
on the old key fail with `401 invalid_api_key`. Messages
already accepted are unaffected.
## Authentication errors
| Status | Code | Meaning |
| ------ | ------------------- | ----------------------------------------------- |
| `401` | `missing_api_key` | No `Authorization` header. |
| `401` | `invalid_api_key` | The key isn't valid or has been revoked. |
| `401` | `api_key_revoked` | The key was revoked from the dashboard. |
| `403` | `key_domain_scope` | The key is pinned to a different domain. |
## Safe key handling
- **Never commit keys to source control.** Use environment
variables or a secret store.
- **Never embed live keys in mobile or browser apps.** The
client side is not a safe place for a server credential.
Proxy through a backend you control.
- **Use the principle of least privilege.** Create separate
keys for separate services so a leak from one doesn't
compromise the rest.
===
# Errors
> The error envelope, common codes, and how to handle them.
Source: https://docs.zevsend.com/api/errors
---
Every error response from the API uses the same shape. One
handler, three channels.
## The error envelope
```json
{
"error": {
"code": "invalid_phone",
"message": "Recipient must be a phone number in international format, e.g. +2348012345678.",
"param": "to",
"type": "validation_error",
"request_id": "req_01HXYZ..."
}
}
```
| Field | Meaning |
| ------------- | ------------------------------------------------------------------ |
| `code` | Stable, machine-readable identifier. Switch on this in your code. |
| `message` | Human-readable explanation. Safe to show to developers. |
| `param` | The request field at fault, when applicable. |
| `type` | High-level category: `validation_error`, `auth_error`, etc. |
| `request_id` | Unique id for this request. Include it when you contact support. |
## Status codes
| Status | Type | Example codes |
| ------ | --------------------- | --------------------------------------------------- |
| `400` | `validation_error` | `invalid_phone`, `invalid_email`, `body_too_long` |
| `401` | `auth_error` | `missing_api_key`, `invalid_api_key` |
| `403` | `permission_error` | `key_domain_scope`, `team_suspended` |
| `404` | `not_found` | `template_not_found`, `domain_not_found` |
| `409` | `conflict` | `template_channel_mismatch` |
| `422` | `validation_error` | `missing_variable`, `unknown_variable` |
| `429` | `rate_limited` | `rate_limited` |
| `500` | `internal_error` | `internal_error` |
## Common error codes
### `invalid_phone`
The `to` field isn't a valid phone number in E.164 format.
E.164 means international form with a leading `+` and country
code, no spaces or dashes: `+2348012345678`.
### `invalid_email`
The `to` field isn't a valid email address.
### `template_not_found`
The `template_id` you passed doesn't exist on this team, or
isn't enabled. Check that the template hasn't been archived.
### `wrong_template_channel`
You sent a template through the wrong endpoint — e.g. an SMS
template via `POST /v1/emails`. Send it through the matching
`/v1/{channel}` endpoint.
### `missing_variable` / `unknown_variable`
Your `variables` object doesn't match the template's variable
manifest. The `param` field names the offending key.
### `no_approved_brand_domain`
You're in live mode but no verified domain on the team is
approved yet. Add a verified domain and complete the brand
profile.
### `rate_limited`
You exceeded the rate limit. The response includes a
`Retry-After` header (seconds) telling you when to retry.
See [Rate limits](/api/rate-limits).
### `internal_error`
Something went wrong on our side. Include the `request_id`
when you contact support. These errors are safe to retry with
exponential backoff.
## Handling errors
A reasonable client retries `429` and `5xx` with exponential
backoff and surfaces everything else to the developer:
```ts
async function send(payload: SendInput, attempt = 0): Promise {
const res = await fetch('https://api.zevsend.com/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZEVSEND_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (res.ok) return res.json();
const body = await res.json();
const retryable = res.status === 429 || res.status >= 500;
if (retryable && attempt < 5) {
const wait =
res.status === 429
? Number(res.headers.get('Retry-After') ?? 1) * 1000
: 2 ** attempt * 250;
await new Promise((r) => setTimeout(r, wait));
return send(payload, attempt + 1);
}
throw new Error(`${body.error.code}: ${body.error.message}`);
}
```
===
# Rate limits
> How rate limits work and how to back off when you hit one.
Source: https://docs.zevsend.com/api/rate-limits
---
ZevSend rate-limits requests per API key. The limits are set so
typical transactional traffic doesn't notice them, while bulk
or runaway scripts get a clear signal to slow down.
## The default limit
By default, each API key is allowed **100 requests per 60
seconds**. The window is a rolling 60-second window, not a
calendar minute.
Send-pipeline limits (per channel) apply on top: SMS and
WhatsApp have additional per-team daily caps that scale with
your plan. The dashboard shows your current usage under
**Usage**.
## When you hit the limit
You get a `429` response with a `Retry-After` header:
```http
HTTP/1.1 429 Too Many Requests
Retry-After: 13
Content-Type: application/json
{
"error": {
"code": "rate_limited",
"message": "Too many requests. Retry after 13 seconds.",
"type": "rate_limited",
"request_id": "req_01HXYZ..."
}
}
```
`Retry-After` is in seconds. Sleep for at least that long
before retrying. A reasonable client retries `429` with
exponential backoff:
```ts
async function withRetry(fn: () => Promise, attempt = 0): Promise {
try {
return await fn();
} catch (err) {
if (isRateLimited(err) && attempt < 5) {
const wait = retryAfterMs(err) ?? 2 ** attempt * 250;
await sleep(wait);
return withRetry(fn, attempt + 1);
}
throw err;
}
}
```
## Raising your limit
If your application has a legitimate burst pattern that
exceeds the default, contact support from your dashboard.
We'll raise the per-key limit and add a note to your account.
## Spreading load
The simplest way to stay under the limit is to spread bulk
sends over time. If you're notifying 10,000 customers, send
them at a steady rate rather than in one tight loop. Queueing
the work in your own infrastructure (BullMQ, SQS, etc.) is the
right pattern.
Don't parallelise the same key across many workers without a
shared rate limiter — the limit is per key, not per process.
===
# Send email
> POST /v1/emails — deliver transactional email with a raw HTML body, or render against a pre-approved template.
Source: https://docs.zevsend.com/api/send-email
---
import Endpoint from '../../../components/Endpoint.astro';
import Callout from '../../../components/Callout.astro';
Send a transactional email to a single recipient. You own the
subject and body; we handle deliverability, signing (DKIM, SPF,
DMARC), and the lifecycle events your app reacts to.
## Quick example
The body of your email is generated in your codebase the same
way you'd render it for any other transactional email service.
Pass the HTML (and optionally a plain-text fallback) and we'll
deliver it.
```bash
curl https://api.zevsend.com/v1/emails \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"from": "noreply@acme.com",
"to": "ada@example.com",
"subject": "Your receipt for order 12345",
"html": "Hi Ada,
Thanks for your order. Your receipt is attached.
",
"text": "Hi Ada, Thanks for your order. Your receipt is attached."
}'
```
## Request body
| Field | Type | Required | Description |
| ----------------- | ------------------- | --------------- | ---------------------------------------------------------------------------------------------------- |
| `to` | string \| string\[\] | yes | Primary recipient(s). A single email string or an array of up to 50 addresses. Combined with `cc` + `bcc` the total must stay at or under 50. |
| `cc` | string\[\] | no | Carbon-copy recipients (visible to To + Cc). Up to 50 entries. |
| `bcc` | string\[\] | no | Blind-carbon-copy recipients. Delivered out-of-band — their addresses are NOT placed in the MIME headers. Up to 50 entries. |
| `reply_to` | string | no | Reply-To header. Hitting Reply in the recipient's mail client pre-fills this address instead of `from`. |
| `attachments` | object\[\] | no | File attachments. See [Attachments](#attachments) below. Up to 20 per send. |
| `from` | string | live mode | Sender address. Accepts either a bare email (`"noreply@acme.com"`) or RFC 5322 name-addr (`"Acme Receipts "`). Must be on one of your verified + approved domains. Sandbox sends are locked to the platform sender. See [Sender display name](#sender-display-name) below. |
| `from_display_id` | string | no | `dn_…` public id of a registered + admin-approved alternate display name on the sending domain. Recommended over inlining the display string in `from` — id-based lookup is typo-proof. |
| `subject` | string | direct mode | Subject line. Up to 200 characters. |
| `html` | string | direct mode\* | HTML body. At least one of `html` or `text` is required when not using a template. |
| `text` | string | direct mode\* | Plain-text body. Strongly recommended alongside `html` for accessibility and spam-filter safety. |
| `template_id` | string | template mode | Public id of a pre-approved template (`tpl_…`). Mutually exclusive with `subject` / `html` / `text`. |
| `variables` | object | template mode | Values for the template's declared `customer.*` variables. |
You can't mix the two modes — passing both `template_id` and
`subject`/`html`/`text` returns `400 mixed_send_mode`.
## Two modes for one endpoint
### Direct mode
The default path for transactional email. Your codebase owns the
body — render it with React Email, MJML, Handlebars, a string
template, whatever you already use — and we deliver it. This is
how teams migrating from Resend, Postmark, SendGrid, or SES
typically integrate.
```bash
curl https://api.zevsend.com/v1/emails \
-H "Authorization: Bearer sk_live_..." \
-d '{
"from": "noreply@acme.com",
"to": "ada@example.com",
"subject": "Welcome to Acme",
"html": "Hi Ada, welcome aboard.
"
}'
```
### Template mode
Reference a template by id and pass per-send variables. The
template owns the subject and body; you supply the values. Useful
when:
- Your support team needs to tweak wording without a code deploy.
- You want server-injected brand identity (`{{brand.name}}` etc.).
- You're sending the same message to many recipients with
different per-recipient data.
```bash
curl https://api.zevsend.com/v1/emails \
-H "Authorization: Bearer sk_live_..." \
-d '{
"to": "ada@example.com",
"template_id": "tpl_01HXYZ...",
"variables": { "first_name": "Ada", "order_id": "ORD-1234" }
}'
```
See the [Templates](/guide/templates) guide for the full
walkthrough.
## Response
Every successful send returns the same shape. Fields are always
present, with empty arrays and `null` for anything the send did
not use — so you can rely on the structure without checking
which fields exist.
```http
HTTP/1.1 202 Accepted
Content-Type: application/json
{
"id": "eml_01HXYZ...",
"to": "ada@example.com",
"to_addresses": ["ada@example.com"],
"cc": [],
"bcc": [],
"reply_to": null,
"from": "noreply@acme.com",
"subject": "Your receipt for order 12345",
"recipient_count": 1,
"attachments_count": 0,
"status": "sending",
"sandbox": false,
"created_at": "2025-05-24T12:34:56.789Z"
}
```
| Field | Type | Description |
| ------------------- | ----------- | ---------------------------------------------------------------------------------------------------------- |
| `id` | string | Public id of the message (`eml_…`). Stable for the message's lifetime. |
| `to` | string | Primary recipient. Always a single string — the first address in the To list. |
| `to_addresses` | string\[\] | Full To list. At least one entry; the first matches `to`. |
| `cc` | string\[\] | Cc recipients. Empty array when none. |
| `bcc` | string\[\] | Bcc recipients after any suppression-list drops. Empty array when none. |
| `reply_to` | string \| null | Reply-To header. `null` when not set. |
| `from` | string | The resolved sender address. |
| `subject` | string | The subject as delivered. |
| `recipient_count` | integer | Total recipients across To + Cc + Bcc (after Bcc suppression drops). This is the unit billed against your quota. |
| `attachments_count` | integer | Number of attachments. `0` when none. |
| `status` | string | Initial state. Almost always `sending` at this point. |
| `sandbox` | boolean | `true` for sandbox sends. |
| `created_at` | string | ISO 8601 UTC timestamp of the request. |
**Backward compatibility.** `to` remains a single string — the
primary recipient — so existing integrations that read
`response.to` as a string keep working unchanged. New fields like
`to_addresses`, `cc`, and `bcc` are additive; clients that ignore
unknown fields are unaffected.
## Sender display name
The `from` field accepts both a bare email and an RFC 5322
name-addr. zevsend always emits a properly composed `From:` header
on the wire so recipients see a brand name, not a bare address.
### Default — auto-injected brand
If you pass just the email, the display name is taken from the
**primary brand identity** you submitted in the dashboard for that
domain. This is the common path.
```bash
curl https://api.zevsend.com/v1/emails \
-H "Authorization: Bearer sk_live_..." \
-d '{
"from": "noreply@acme.com",
"to": "ada@example.com",
"subject": "Welcome to Acme",
"html": "Hi Ada, welcome aboard.
"
}'
```
Recipient sees:
```
From: Acme
```
The brand name is server-controlled — a leaked key can't change
how your sends present to recipients.
### Override — an approved alternate display name
When you need a second brand string on the same domain (e.g.
"Acme Receipts" vs your primary "Acme"), register it in the
dashboard under **Domains → \[your domain\] → Additional display
names** and submit it for review. The submission gets a stable
`dn_…` id immediately, but it only becomes usable on the API
once an admin approves it.
You can reference an approved alternate two ways. Both produce
the same `From:` header on the wire.
**By id (recommended)** — copy the `dn_…` from the dashboard and
pass it on `from_display_id`. The id is immune to typos, casing
drift, or accidental edits to the display string in your codebase.
```bash
curl https://api.zevsend.com/v1/emails \
-H "Authorization: Bearer sk_live_..." \
-d '{
"from": "noreply@acme.com",
"from_display_id": "dn_8AYnhCe0z1C4",
"to": "ada@example.com",
"subject": "Receipt for your order",
"html": "Thanks for your purchase.
"
}'
```
**By name (RFC 5322)** — embed the display string inline. The
server normalises the match (case-insensitive, whitespace-
collapsed) and looks it up against your approved alternates.
Useful when you're migrating from another provider that already
emits this format. A typo or wording drift will fail the lookup
with `display_name_not_approved`.
```bash
curl https://api.zevsend.com/v1/emails \
-H "Authorization: Bearer sk_live_..." \
-d '{
"from": "Acme Receipts ",
"to": "ada@example.com",
"subject": "Receipt for your order",
"html": "Thanks for your purchase.
"
}'
```
If neither path matches a registered + approved alternate (and
the string isn't an exact match for your primary brand), the
request is rejected. Send without any display string or id to
fall back to your primary brand automatically.
Why review? The display name is what recipients perceive as the
sender. Pre-approval keeps a leaked API key from being able to
ship mail as "Acme Bank" from a verified Acme domain.
## Recipients — cc, bcc, reply-to
Pass `cc` and `bcc` as arrays of email addresses. The combined
count across `to` + `cc` + `bcc` must stay at or under 50.
```bash
curl https://api.zevsend.com/v1/emails \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"from": "noreply@acme.com",
"to": ["ada@example.com"],
"cc": ["accounts@example.com"],
"bcc": ["audit@acme.com"],
"reply_to": "support@acme.com",
"subject": "Your receipt for order 12345",
"html": "Hi Ada, your receipt is below.
"
}'
```
**`to`** is the primary recipient. Pass a single string for
backward compatibility (`"to": "ada@example.com"`) or an array for
multiple addresses.
**`cc`** addresses appear in the visible Cc header alongside the
To list. Every recipient sees them.
**`bcc`** addresses receive the message but are not placed in any
visible header. To and Cc recipients can't see them; the bcc
address knows it was a bcc only because the message arrived. We
deliver bcc out-of-band per RFC 5322 §3.6.3.
**`reply_to`** sets the Reply-To header. When set, recipients
hitting Reply pre-fill this address instead of the From address.
Useful when you send from `noreply@` but want replies to land
with your support team.
**Billing:** every recipient counts as one send against your plan
quota. An email to 1 To + 3 Cc + 2 Bcc is billed as 6 sends. This
mirrors how upstream networks charge us per recipient.
### Suppressed recipients
Each team carries a suppression list of addresses that previously
hard-bounced or complained.
- A suppressed address in **`to`** or **`cc`** rejects the whole
send with `403 recipient_suppressed`. We name the blocked
addresses in the error message — they're already visible to the
caller anyway.
- A suppressed address in **`bcc`** is **silently dropped** and
the send proceeds with the remaining recipients. Surfacing the
block would leak the fact that the bcc address exists to the
caller, which is the inverse of what bcc is for. The drop is
logged in ops; the customer sees a normal `202` response.
To clear an address from the suppression list, use the dashboard
Suppressions page or the [`DELETE /v1/suppressions/:email`](/api/errors)
endpoint.
## Attachments
Send up to 20 files per message. Each attachment is an object
with `filename`, `content` (base64-encoded bytes), and
`content_type`.
```bash
curl https://api.zevsend.com/v1/emails \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"from": "billing@acme.com",
"to": "ada@example.com",
"subject": "Your invoice",
"html": "Your invoice is attached.
",
"attachments": [
{
"filename": "invoice-12345.pdf",
"content_type": "application/pdf",
"content": "JVBERi0xLjQKJ..."
}
]
}'
```
### Limits
| Limit | Value |
| ---------------------- | ---------------------- |
| Attachments per send | 20 |
| Per-file size | 10 MB (after decode) |
| Total per send | 25 MB (sum) |
### Allowed content types
We accept common business document and image types. Anything else
returns `400 unsupported_attachment_type`.
- **Documents:** `application/pdf`, `application/msword`,
`application/vnd.openxmlformats-officedocument.wordprocessingml.document`,
`application/vnd.ms-excel`,
`application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`,
`application/vnd.ms-powerpoint`,
`application/vnd.openxmlformats-officedocument.presentationml.presentation`
- **Data:** `application/json`, `text/csv`, `text/plain`,
`text/calendar`
- **Archives:** `application/zip`,
`application/x-zip-compressed`
- **Images:** `image/png`, `image/jpeg`, `image/gif`,
`image/webp`, `image/svg+xml`
Executables, scripts, and other delivery-risky types are
deliberately blocked. If you have a legitimate need for a type
outside this list, contact support.
### Encoding the content
The `content` field expects standard base64 (RFC 4648). Most
languages and HTTP clients have a built-in encoder.
```js
// Node
const fs = require('fs');
const content = fs.readFileSync('./invoice.pdf').toString('base64');
```
```py
# Python
import base64
with open('invoice.pdf', 'rb') as f:
content = base64.b64encode(f.read()).decode()
```
```ruby
# Ruby
require 'base64'
content = Base64.strict_encode64(File.binread('invoice.pdf'))
```
## Lifecycle
| State | Meaning |
| ------------ | -------------------------------------------------------------------------------------- |
| `queued` | Persisted but not yet handed off. |
| `sending` | Handed off to the upstream network; awaiting accept. |
| `sent` | Accepted by the upstream network and in transit. |
| `delivered` | Confirmed accepted by the recipient's inbox provider. |
| `bounced` | Hard bounce — the address is invalid or the inbox refused it permanently. |
| `complained` | Recipient flagged the message as spam. |
| `failed` | Rejected at submit or permanent failure before reaching the inbox. |
State changes fire as [webhook events](/webhooks/events). You can
also look up the current state from the dashboard.
## Retrying safely with an idempotency key
If your process crashes after calling us but before it records the
result, you cannot know whether the email went out. Retrying might send
it twice; not retrying might lose it.
Send an `Idempotency-Key` header and the retry is safe. The first
request sends the email and we remember the response. A retry with the
same key returns that same response without sending again.
```bash
curl -X POST https://api.zevsend.com/v1/emails \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-receipt" \
-d '{
"from": "receipts@acme.com",
"to": "ada@example.com",
"subject": "Your receipt",
"html": "Thanks for your order.
"
}'
```
The header is optional. Leave it off and the endpoint behaves exactly
as it always has.
**Choosing a key.** Use something your own system can derive again
after a restart, such as the id of the thing the email is about. A
random value generated fresh on each attempt defeats the purpose. Keys
are up to 255 characters and are scoped to the API key that sent them,
so two integrations on the same team never collide.
**How long it lasts.** A key is remembered for 24 hours. That is the
window in which a retry makes sense; after it, the same key is treated
as a new request.
**A replayed response** carries `Idempotency-Replayed: true` and is
byte-for-byte what the first call returned, including the original
message `id`. Nothing is sent.
| Status | Code | Cause |
| ------ | ---- | ----- |
| `409` | `idempotency_key_reused` | The same key was used with a different request body. Use a new key for a different email. |
| `409` | `idempotency_request_in_progress` | An earlier request with this key is still running. Retry in a few seconds. |
If a send fails, the key is released, so you can retry it with the same
key once you have fixed the cause.
## Pricing
Email is included in your plan up to the monthly quota. Past the
quota, each send drains the template's per-send price from Zev
Credit. Most email templates ship priced at zero, so customers
who stay within their plan quota never see a charge.
See the [Pricing guide](/guide/pricing#email) for the charge
cascade and how Zev Credit is debited.
## Errors
| Status | Code | Cause |
| ------ | -------------------------- | ---------------------------------------------------------------- |
| `400` | `invalid_email` | `to` isn't a valid email address. |
| `400` | `missing_body` | Direct mode requires `subject` and at least one of `html`/`text`. |
| `400` | `mixed_send_mode` | You passed both `template_id` and direct-mode fields. |
| `400` | `sandbox_from_locked` | In sandbox, `from` must be the platform sender. |
| `403` | `from_domain_unverified` | The `from` address isn't on a verified domain on your team. |
| `403` | `key_domain_scope` | The key is pinned to a different domain than the `from`. |
| `400` | `invalid_from_address` | The `from` value is not a valid bare email or RFC 5322 name-addr. |
| `400` | `display_name_not_approved`| The display name on `from` is not a registered + approved alternate for this domain. Submit it in the dashboard or omit it. |
| `400` | `display_name_id_not_found`| `from_display_id` doesn't match any record. Check the value in the dashboard. |
| `400` | `display_name_id_wrong_domain` | The `dn_…` belongs to a different domain than your `from` addr. |
| `400` | `display_name_id_not_approved` | The display-name record exists but isn't admin-approved yet. |
| `403` | `no_approved_brand_domain` | You're live but no verified domain on the team is approved yet. |
| `403` | `recipient_not_allowed` | Sandbox: recipient hasn't been verified on this team. |
| `403` | `recipient_suppressed` | Recipient is on the suppression list (bounce/complaint). |
| `404` | `template_not_found` | Template mode: no such template on this team. |
| `409` | `wrong_template_channel` | The template is for SMS or WhatsApp, not email. |
| `409` | `idempotency_key_reused` | The `Idempotency-Key` was already used with a different body. |
| `409` | `idempotency_request_in_progress` | An earlier request with this `Idempotency-Key` is still running. |
| `422` | `missing_variable` | Template mode: a required variable was missing. |
| `422` | `unknown_variable` | Template mode: you passed a variable the template doesn't declare. |
## Suppression list
When a recipient bounces or complains, we automatically add them
to your team's suppression list. Further sends to that address
return `403 recipient_suppressed` without consuming quota. Manage
suppressions from the dashboard under **Suppressions**.
===
# Send SMS
> POST /v1/sms — send a transactional SMS using a pre-approved template.
Source: https://docs.zevsend.com/api/send-sms
---
import Endpoint from '../../../components/Endpoint.astro';
Send a transactional SMS to a phone number in international
format.
## Request
```bash
curl https://api.zevsend.com/v1/sms \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"to": "+2348012345678",
"template_id": "tpl_01HXYZ...",
"variables": {
"code": "847291"
}
}'
```
### Body parameters
| Field | Type | Required | Description |
| ----------------- | ------ | -------- | ------------------------------------------------------------------------------------------------ |
| `to` | string | yes | Recipient phone in E.164 (`+` then country code then number, no spaces or dashes). |
| `template_id` | string | yes | Template public id (`tpl_…`). Must be an `sms` template. |
| `variables` | object | sometimes| Values for the template's declared `customer.*` variables. |
| `brand_domain_id` | string | no | Pin the send to a specific verified domain (`dom_…`) for brand variables like `{{brand.name}}`. |
### Phone format
We accept international format only. Examples:
| Country | Format example |
| ------- | ----------------------- |
| Nigeria | `+2348012345678` |
| Kenya | `+254712345678` |
| Ghana | `+233241234567` |
| USA | `+12025550100` |
Sending without the `+` and country code returns `400 invalid_phone`.
## Response
```http
HTTP/1.1 202 Accepted
Content-Type: application/json
{
"id": "sms_01HXYZ...",
"to": "+2348012345678",
"status": "sending",
"sandbox": false,
"created_at": "2025-05-24T12:34:56.789Z"
}
```
## Lifecycle
| State | Meaning |
| ----------- | ---------------------------------------------------------------------- |
| `queued` | Persisted but not yet handed off. |
| `sending` | Handed off to the network; awaiting accept. |
| `sent` | Accepted by the network and in transit. |
| `delivered` | Confirmed at the recipient's handset. |
| `failed` | Rejected at submit or permanent delivery failure (out-of-window, etc.). |
SMS has no `bounced` or `complained` state. Permanent failures
land on `failed`.
## Length limits
The rendered body is capped at **1600 characters**. Longer
bodies return `400 body_too_long`. Two notes:
- Standard GSM-7 messages are 160 characters per segment.
Going over 160 splits across segments and is billed per
segment.
- Templates with lots of Unicode (emoji, non-Latin scripts)
fall back to UCS-2, which drops the per-segment limit to 70
characters.
The dashboard preview shows the rendered length so you can
spot accidental segment splits.
## Pricing
SMS is priced per template, in your team's billing currency.
Open the template in the dashboard to see the rate. Each template
carries a default per-send price plus any **per-country
overrides**: sending to a recipient in a country with an
override charges the override rate; everything else uses the
default. The customer-facing rate already accounts for the
underlying carrier and route, so you only ever read one number.
Credit drains the moment the carrier accepts the send. Carrier
rejections never drain credit. See the
[Pricing guide](/guide/pricing#sms) for the full charge cascade
(plan gate, credit check, dispatch, debit).
## Errors
| Status | Code | Cause |
| ------ | -------------------------- | ---------------------------------------------------------------- |
| `400` | `invalid_phone` | `to` isn't a valid E.164 number. |
| `400` | `body_too_long` | Rendered SMS exceeds 1600 characters. |
| `400` | `template_id_required` | `template_id` was missing. |
| `404` | `template_not_found` | No such template on this team. |
| `409` | `wrong_template_channel` | The template is for email or WhatsApp. |
| `422` | `missing_variable` | A required variable was missing. |
| `422` | `unknown_variable` | You passed a variable the template doesn't declare. |
| `403` | `no_approved_brand_domain` | You're live but no approved domain exists for the team. |
| `403` | `recipient_not_allowed` | Sandbox-only: recipient hasn't been verified on this team. |
| `503` | `no_carrier_available` | No enabled carrier serves the destination region right now. |
## Sender ID
A **sender ID** is the label the recipient sees as the "from"
on the message. Every send uses one of three:
1. **Your team-scope sender ID**, when you've registered one
through the dashboard and it's been approved for the
destination country.
2. **A ZevSend platform default**, when your team hasn't
registered a country-matched sender ID yet. This is a
pre-approved transactional sender we maintain so new teams
can send OTPs and alerts on day one without waiting for
their own sender ID to be reviewed.
3. **A network-provided ID**, on routes where the carrier
appends one regardless of submission.
The platform default lets you ship immediately. Once you want
your own brand in the from field, register a team-scope sender
ID under **Settings → SMS → Sender IDs** in the dashboard.
We'll review and approve it for the countries you select.
## Costs and regions
SMS pricing varies by destination country. The dashboard
**Usage** page shows your per-country breakdown. Some regions
require local sender ID registration before delivery is
allowed — your dashboard shows which sender ID we'll use per
country.
===
# Send WhatsApp
> POST /v1/whatsapp — send a transactional WhatsApp message using a pre-approved template.
Source: https://docs.zevsend.com/api/send-whatsapp
---
import Endpoint from '../../../components/Endpoint.astro';
Send a transactional WhatsApp message to a phone number in
international format.
## Request
```bash
curl https://api.zevsend.com/v1/whatsapp \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"to": "+2348012345678",
"template_id": "tpl_01HXYZ...",
"variables": {
"first_name": "Ada",
"order_id": "ORD-1234"
}
}'
```
### Body parameters
| Field | Type | Required | Description |
| ----------------- | ------ | --------- | ------------------------------------------------------------------------------------------------------------ |
| `to` | string | yes | Recipient phone in E.164. |
| `template_id` | string | yes | Template public id (`tpl_…`). Must be a `whatsapp` template. |
| `variables` | object | sometimes | Values for the template's declared `customer.*` variables. |
| `brand_domain_id` | string | no | Pin the send to a specific verified domain (`dom_…`) for brand variables like `{{brand.name}}`. |
## Response
```http
HTTP/1.1 202 Accepted
Content-Type: application/json
{
"id": "wam_01HXYZ...",
"to": "+2348012345678",
"status": "sending",
"sandbox": false,
"created_at": "2025-05-24T12:34:56.789Z"
}
```
## Lifecycle
WhatsApp has one more terminal state than email or SMS — a
`read` event fires when the recipient opens the conversation.
| State | Meaning |
| ----------- | -------------------------------------------------------------------- |
| `queued` | Persisted but not yet handed off. |
| `sending` | Handed off; awaiting accept. |
| `sent` | Accepted and in transit. |
| `delivered` | Confirmed at the recipient's device. |
| `read` | Recipient opened the conversation. |
| `failed` | Rejected at submit or permanent delivery failure. |
The `read` state is best-effort. WhatsApp only fires read
receipts when the recipient has read-receipts enabled in their
own settings, so a message that's been read may never visibly
transition past `delivered`. Don't treat the absence of `read`
as a signal that the message wasn't seen.
## Templates only
Every WhatsApp send must reference a pre-approved template.
Raw text isn't accepted — the upstream network would reject
it anyway, so we reject it at the API.
The variable manifest on the template encodes the positional
order of placeholders in the approved template body. You pass
named variables; we map them to positions at send time. The
upstream network renders the message body from the approved
template using your variable values.
## Pricing
WhatsApp templates carry a per-send price, set on the template
in your team's billing currency. Templates can also have
per-country overrides that take precedence over the default rate
when sending to those countries. The price you see on the
template is what you pay. We never expose which upstream
provider handled the send.
See the [Pricing guide](/guide/pricing#whatsapp) for the full
charge cascade and how Zev Credit is debited.
## Errors
| Status | Code | Cause |
| ------ | -------------------------- | ---------------------------------------------------------------- |
| `400` | `invalid_phone` | `to` isn't a valid E.164 number. |
| `400` | `template_id_required` | `template_id` was missing. |
| `404` | `template_not_found` | No such template on this team. |
| `409` | `wrong_template_channel` | The template is for email or SMS. |
| `422` | `missing_variable` | A required variable was missing. |
| `422` | `unknown_variable` | You passed a variable the template doesn't declare. |
| `403` | `no_approved_brand_domain` | You're live but no approved domain exists for the team. |
| `403` | `recipient_not_allowed` | Sandbox-only: recipient hasn't been verified on this team. |
| `503` | `no_carrier_available` | No enabled WhatsApp provider is currently online. |
## Conversation windows
WhatsApp enforces 24-hour conversation windows: once a recipient
last messaged you, you have 24 hours to send free-form messages
back. Outside that window only pre-approved templates of the
right category can be delivered. ZevSend is template-only, so
this is enforced by design.
Template categories (set when the template is registered):
| Category | Used for |
| -------------- | ----------------------------------------------------------------------- |
| `AUTHENTICATION` | OTPs, login codes, verification messages. |
| `UTILITY` | Account updates, order confirmations, shipping notifications. |
| `MARKETING` | Promotional content. Limited per recipient per day. |
Pick the right category when you author the template — picking
wrong is the most common reason templates get rejected.
===
# Verify (OTP)
> POST /v1/verify sends a verification code, POST /v1/verify/:id/check validates the candidate code.
Source: https://docs.zevsend.com/api/verify
---
import Endpoint from '../../../components/Endpoint.astro';
Verify is the OTP channel. Two endpoints: one to send a
verification code to a recipient, one to check the code they
enter back. The code itself is hashed at rest and never returned
in any response. You only learn whether the candidate matched.
## Channels
V1 supports `sms` only. Email and WhatsApp verify share the same
table and lifecycle and will land on this endpoint in a later
release.
## Start a verification
```bash
curl https://api.zevsend.com/v1/verify \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"to": "+2348012345678",
"channel": "sms"
}'
```
### Body parameters
| Field | Type | Required | Description |
| --------- | ------ | -------- | -------------------------------------------------------------------- |
| `to` | string | yes | Recipient phone in E.164 format (`+2348012345678`). |
| `channel` | string | no | `sms`. Default: `sms`. |
### Response
```http
HTTP/1.1 202 Accepted
Content-Type: application/json
{
"id": "vrf_01HXYZ...",
"channel": "sms",
"to": "+2348012345678",
"status": "pending",
"sandbox": false,
"expires_at": "2025-05-24T12:44:56.789Z",
"error_message": null
}
```
Save the `id`. You'll pass it back to the check endpoint along
with the code the user entered.
The code expires in **10 minutes** and is single-use. After the
expiry the verification flips to `expired` regardless of whether
the user ever tried.
If the carrier refused the message at submit (invalid number,
network outage, etc.), the response returns `status: "failed"`
and the `error_message` field carries the carrier text. You will
not be charged for a failed dispatch.
## Check a verification
```bash
curl https://api.zevsend.com/v1/verify/vrf_01HXYZ.../check \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "code": "847291" }'
```
### Body parameters
| Field | Type | Required | Description |
| ------ | ------ | -------- | ---------------------------------------- |
| `code` | string | yes | Candidate code the user entered (4–10 chars). |
### Response
```http
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": "vrf_01HXYZ...",
"verified": true,
"status": "verified",
"remaining_attempts": 4
}
```
| Field | Type | Description |
| -------------------- | ------- | -------------------------------------------------------------------------- |
| `verified` | boolean | `true` on the call that accepted the right code. |
| `status` | string | Lifecycle state, see below. |
| `remaining_attempts` | number | How many wrong-code attempts the verification has left before exhaustion. |
## Lifecycle
| State | Meaning |
| ----------- | ----------------------------------------------------------------------- |
| `pending` | Code sent, awaiting a check. |
| `verified` | Correct code presented. Terminal. |
| `expired` | 10-minute window lapsed without a successful check. Terminal. |
| `exhausted` | 5 wrong codes presented. Terminal. |
Wrong codes increment the `attempts` counter without flipping
status until the limit. The fifth wrong code flips the
verification to `exhausted`; further checks return the terminal
state without consuming another attempt.
## Pricing
Verify is priced per `start` call. The rate is a flat amount per
currency, set by the platform. Open the
[Pricing and billing](/guide/pricing#verify-otp) page for the
current numbers in your team's currency.
The matching `check` call is free. Wrong-code submissions don't
incur extra charges either. You pay once when the code goes
out, regardless of how the user fares at typing it.
Sandbox verifies are always free. Switch your team to live to
start draining credit on Verify sends.
## Errors
| Status | Code | Cause |
| ------ | -------------------------- | ---------------------------------------------------------------- |
| `400` | `invalid_phone` | `to` isn't a valid E.164 number. |
| `400` | `insufficient_credit` | Zev Credit balance can't cover the verify rate. Top up + retry. |
| `403` | `feature_not_on_plan` | Your plan doesn't include Verify. Upgrade to a paid plan. |
| `404` | `verification_not_found` | Wrong `id` on the check endpoint, or the verification was on a different team. |
| `403` | `team_suspended` | The team is suspended. Contact support. |
===
# Verifying signatures
> Verify the X-Zev-Signature header so you know the event really came from ZevSend.
Source: https://docs.zevsend.com/webhooks/signatures
---
import Callout from '../../../components/Callout.astro';
Every webhook delivery carries an `X-Zev-Signature` header.
Verifying it proves the payload came from ZevSend and hasn't
been tampered with in flight.
The format matches Stripe's signing convention so any team
that's worked with Stripe will recognise it.
## Header format
```http
X-Zev-Signature: t=1716553200000,v1=4f1c…
```
| Part | Meaning |
| ----------- | ---------------------------------------------------------- |
| `t=` | Unix timestamp **in milliseconds** when we signed. |
| `v1=` | HMAC-SHA256 of `.` using your secret. |
The `v1` prefix versions the algorithm. We may add `v2=…` in
the future; existing implementations keep working as long as
they verify *any* version they recognise.
## Verifying in Node.js
```ts
import crypto from 'node:crypto';
const FIVE_MINUTES_MS = 5 * 60 * 1000;
export function verifyZevSignature(req: {
headers: Record;
rawBody: string;
}) {
const header = req.headers['x-zev-signature'];
const secret = process.env.ZEVSEND_WEBHOOK_SECRET!;
if (!header) return false;
const parts = Object.fromEntries(
header.split(',').map((p) => p.trim().split('=')),
);
const timestamp = Number(parts.t);
const signature = parts.v1;
if (!timestamp || !signature) return false;
// Replay defence — reject anything older than 5 minutes.
if (Math.abs(Date.now() - timestamp) > FIVE_MINUTES_MS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${req.rawBody}`)
.digest('hex');
// Constant-time comparison.
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```
The HMAC is computed over the **raw HTTP body** before any
JSON parsing. If your framework parses the body before your
verifier sees it, the bytes you hash won't match the bytes we
hashed and verification will fail.
In Express, use `express.raw({ type: 'application/json' })`
on the webhook route, then `JSON.parse` after verification.
## Verifying in Python
```python
import hmac
import hashlib
import time
FIVE_MINUTES_MS = 5 * 60 * 1000
def verify_zev_signature(header: str, raw_body: bytes, secret: str) -> bool:
try:
parts = dict(p.strip().split('=', 1) for p in header.split(','))
except ValueError:
return False
timestamp = int(parts.get('t', '0'))
signature = parts.get('v1', '')
if not timestamp or not signature:
return False
now_ms = int(time.time() * 1000)
if abs(now_ms - timestamp) > FIVE_MINUTES_MS:
return False
payload = f'{timestamp}.'.encode() + raw_body
expected = hmac.new(
secret.encode(), payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
```
## Verifying in Go
```go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)
const fiveMinutesMs = 5 * 60 * 1000
func verifyZevSignature(header string, rawBody []byte, secret string) bool {
var ts int64
var sig string
for _, part := range strings.Split(header, ",") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "t":
ts, _ = strconv.ParseInt(kv[1], 10, 64)
case "v1":
sig = kv[1]
}
}
if ts == 0 || sig == "" {
return false
}
nowMs := time.Now().UnixMilli()
if (nowMs-ts > fiveMinutesMs) || (ts-nowMs > fiveMinutesMs) {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%d.", ts)
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sig))
}
```
## Rotating the secret
If you suspect your webhook secret has leaked:
1. Open **Settings → Webhooks → \** in the dashboard.
2. Click **Rotate secret**.
3. Deploy the new secret to your application.
4. Click **Revoke previous** in the dashboard.
While both secrets are active, signatures computed with either
one verify. That window gives you a no-downtime rollout.
===
# Event catalogue
> Every webhook event you can subscribe to and the payload shape it carries.
Source: https://docs.zevsend.com/webhooks/events
---
Each event uses the same envelope. The shape of `data` varies
by channel.
```json
{
"id": "whe_…",
"type": "",
"created_at": "2025-05-24T12:34:56.789Z",
"data": { ... }
}
```
## Email events
### `email.sent`
Fires when the upstream network accepts the email for delivery.
This is the first signal that the message has left ZevSend.
### `email.delivered`
Fires when the recipient's mail server accepts the message.
### `email.bounced`
Permanent delivery failure — the address is invalid or the
inbox refused the message. The recipient is automatically added
to your team's suppression list.
```json
{
"id": "whe_…",
"type": "email.bounced",
"created_at": "…",
"data": {
"message": {
"public_id": "eml_…",
"from_address": "noreply@acme.com",
"to_address": "deleted@example.com",
"subject": "Your receipt",
"status": "bounced",
"sandbox": false,
"sent_at": "…"
},
"reason": "smtp; 550 5.1.1 No such user"
}
}
```
### `email.complained`
Recipient marked the message as spam. The recipient is
automatically added to your team's suppression list.
### `email.failed`
Delivery permanently failed for a non-bounce reason — e.g. the
upstream network rejected the message before submitting.
## SMS events
### `sms.sent`
Fires when the network accepts the SMS for delivery.
### `sms.delivered`
Fires when the recipient's handset confirms receipt via a
network delivery report.
```json
{
"id": "whe_…",
"type": "sms.delivered",
"created_at": "…",
"data": {
"message": {
"public_id": "sms_…",
"to_phone": "+2348012345678",
"status": "delivered",
"sandbox": false,
"sent_at": "…"
}
}
}
```
### `sms.failed`
Synchronous rejection at submit time or a permanent failure
reported by a later delivery report. The `reason` field holds
the network's diagnostic where available.
## WhatsApp events
### `whatsapp.sent`
Fires when the network accepts the template send.
### `whatsapp.delivered`
Fires when the recipient's device confirms receipt.
### `whatsapp.read`
Fires when the recipient opens the conversation. This event
is best-effort — recipients with read receipts disabled never
trigger it.
```json
{
"id": "whe_…",
"type": "whatsapp.read",
"created_at": "…",
"data": {
"message": {
"public_id": "wam_…",
"to_phone": "+2348012345678",
"status": "read",
"sandbox": false,
"sent_at": "…"
}
}
}
```
### `whatsapp.failed`
Rejected at submit time or a permanent delivery failure. The
`reason` field surfaces the network's error message.
## Ordering and duplicates
We deliver events in order whenever the network behind them
provides ordering, but you should not depend on perfect
ordering. Two safe patterns:
- **Treat each event as a state assertion**, not a transition.
An `email.delivered` for a message that's already at
`bounced` in your store is a stale event and you should
ignore it.
- **Use the `id` field as your dedupe key**. We retry on
failure, so the same event may arrive more than once.