# 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.