# ZevCampaign developer documentation — full text bundle > Every page on `docs.zevcampaign.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.zevcampaign.com/llms.txt. For a single page, append `.md` to its URL. --- === # Quickstart > From a signup form on your site to a confirmed subscriber, in about five minutes. Source: https://docs.zevcampaign.com/guide/quickstart --- This walks the whole loop: a form on your site, a confirmation email, a confirmed subscriber you can send to. ## 1. Create a topic In the dashboard, under **Audience → Topics**, create one. Call it something a recipient would recognise, because they will see the name on the unsubscribe page: "Weekly newsletter" rather than "list-2". Note its id. It looks like `tp_7xk2m9qv4w`. ## 2. Get a publishable key **Settings → API keys**. Create a key and pick **publishable**. Publishable keys are safe in front-end code. They can do exactly one thing — subscribe somebody to a topic — and nothing else. Secret keys, which can read and change your audience, must never leave your server. Start with a **test** key. Test keys validate everything and send nothing, so you can get your form working without mailing anyone. More on that in [test and live keys](/guide/test-and-live). ## 3. Post a signup ```bash curl https://api.zevcampaign.com/v1/subscribe \ -H "Authorization: Bearer pk_test_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "topic_id": "tp_7xk2m9qv4w", "email": "ada@example.com", "first_name": "Ada" }' ``` ```json { "data": { "status": "confirmation_sent" } } ``` From a browser, the same call with `fetch`: ```js await fetch('https://api.zevcampaign.com/v1/subscribe', { method: 'POST', headers: { Authorization: 'Bearer pk_live_your_key_here', 'Content-Type': 'application/json', }, body: JSON.stringify({ topic_id: 'tp_7xk2m9qv4w', email: form.email.value, first_name: form.firstName.value, }), }); ``` ## 4. Expect one of two answers `status` is either `confirmation_sent` or `subscribed`, and that is all you get. It is deliberately this coarse. The response is identical whether the address was new, already subscribed, still pending, or previously unsubscribed. Anything more specific would turn your public form into a way for anyone to check whether a particular person is on your list, which is their business and not something we will leak on your behalf. Show the same friendly message either way: *"Check your inbox to confirm."* ## 5. They confirm On a double opt-in topic, which is the default, we email the address a confirmation link **from your brand**, not from us. Someone who typed their address into your bakery's newsletter box expects to hear from the bakery. When they click it, the subscription becomes active and your `contact.subscribed` webhook fires. That event always means real consent, which is why it is worth wiring up: your own CRM can trust it without re-checking. ## 6. Send Build the campaign in the dashboard and send it to the topic. If you are on a trial, or your brand has not been reviewed yet, you can only send to addresses you have verified as your own. That is not a bug and it is not a quota: see [getting approved](/guide/approval) for what lifts it and how long it takes. ## What next - [Consent and double opt-in](/guide/consent) — what we record, and why - [Webhooks](/webhooks/) — so your systems hear about this too - [Contacts API](/api/contacts) — importing an audience you already have === # Core concepts > Contacts, topics, lists and brands, and how they relate. Source: https://docs.zevcampaign.com/guide/concepts --- Four nouns. Learning them in this order will save you re-modelling later. ## Contact A person, identified by their email address, unique within your workspace. A contact carries a name, whatever custom attributes you define, and their consent and delivery history. A contact exists once no matter how many of your lists they are on. If the same address arrives twice, we merge rather than duplicate, using your workspace's merge policy to decide which value wins when the two disagree. ## Topic **What somebody consented to receive.** "Weekly newsletter", "Product updates", "Event invitations". This is the concept people skip, and it is the one that matters. An unsubscribe is almost never "never contact me again" — it is "stop sending me *this*". Without topics you only have one switch, so a reader who is tired of your daily digest has no way to say so except to leave entirely, or to report you as spam, which costs you far more. Consent lives on the topic. So does the unsubscribe link, by default. ## List **A grouping you maintain for your own convenience.** "Lagos customers", "Signed up in 2026", "Attended the launch". Lists are about segmentation, not permission. A contact being on a list does not mean they agreed to anything; their topic subscriptions decide what they may be sent. You can send a campaign to a list, and we will still only deliver to the people on it who are subscribed to that campaign's topic. If you find yourself making a list called "newsletter subscribers", you want a topic. ## Brand **Who the mail is from.** A name, a logo, a sender address, a postal address, and the sending domain underneath it. A workspace can hold several brands, which is what an agency needs. Topics belong to a brand, so a recipient's consent is always to a specific sender rather than to your account in general. Brands are reviewed before they can send. See [getting approved](/guide/approval). ## How they fit together ``` Workspace └── Brand ............... who the mail is from ├── Topic ........... what they consented to receive │ └── Subscription one contact's consent to one topic └── Campaign ........ one send, to one topic Contact .................. the person, shared across the workspace └── List membership ..... your own segmentation ``` The rule worth remembering: **lists decide who you are considering, topics decide who you may actually send to.** === # Test and live keys > Two environments, two key types, and what each one can reach. Source: https://docs.zevcampaign.com/guide/test-and-live --- Every API key is one of two environments and one of two types. The prefix tells you which, at a glance and in a log. | Prefix | Environment | Type | Safe in a browser | |---|---|---|---| | `sk_live_` | Live | Secret | No | | `sk_test_` | Test | Secret | No | | `pk_live_` | Live | Publishable | Yes | | `pk_test_` | Test | Publishable | Yes | ## Test keys A test key validates everything and sends nothing. Your request is authenticated, the body is checked, the topic is resolved, the contact is written. What does not happen is the part with real-world consequences: no email leaves, and no webhook fires at your endpoint. That last one catches people out, and it is deliberate. A sandbox call that reached your production webhook would have your CRM record a subscriber who does not exist. Test keys write to the same audience as live keys. If you subscribe `ada@example.com` with a test key, Ada is really in your contacts. Use addresses you recognise as fake, and clean them up before you launch. ## Publishable keys A publishable key can do exactly one thing: `POST /v1/subscribe`. That is the whole permission. It cannot read your contacts, cannot export anything, cannot send, cannot change settings. Every other endpoint refuses it, with `publishable_key_not_allowed`, even if the key belongs to your workspace and the endpoint would otherwise work. This is what makes it safe to put in a page's source, where anybody can read it. The worst somebody can do with a stolen publishable key is add addresses to a topic — which is why confirmation email matters, and why we rate-limit that route. **Restrict the origins.** When you create a publishable key you can name the domains allowed to use it. Do. It turns a copied key into a key that only works from your own site. ## Secret keys Secret keys reach the rest of the API: reading contacts, importing, suppressing, managing lists and topics. Keep them on your server. We show a secret key **once**, at creation. We store only a hash, so if you lose it we genuinely cannot recover it and you will need to create another. Rotating is uneventful: create a new key, deploy it, then revoke the old one. Both work during the overlap. ## Scoping a key to one brand A key can be limited to a single brand. If it is, every call through it sees only that brand's topics, lists and campaigns, and a call naming another brand's topic is refused. If you run an agency, scope one key per client. A mistake then affects one client instead of all of them. ## Switching to live Swap the key. There is no other switch to flip, no separate base URL and no "go live" button. Before you do, check: - your webhook endpoint is reachable from the public internet - you are verifying webhook signatures ([how](/webhooks/signatures)) - your brand and sending domain are approved ([how](/guide/approval)) - the test contacts you created are gone === # Consent and double opt-in > What counts as consent, what we record, and what happens when somebody leaves. Source: https://docs.zevcampaign.com/guide/consent --- A form submission is not consent. Anyone can type anyone's address into a box on the open internet, and plenty of people do, out of malice or mistyping. It becomes consent when somebody proves they can read that inbox. That is why double opt-in is the default on every topic. ## How it works 1. Somebody submits your form. We create the contact and record the subscription as **pending**. 2. We email them a confirmation link, from your brand. 3. They click it. The subscription becomes **subscribed**, and we store the moment and the IP address it came from. 4. `contact.subscribed` fires on your webhook. Until step 3, they are not on your list and no campaign will reach them. ## What we keep as evidence For every confirmed subscription we store when the confirmation was sent, when it was clicked, and the IP address that clicked it. This is what answers a complaint months later. "Someone says they never signed up" is unarguable without it and a thirty-second lookup with it. ## Turning it off A topic can skip confirmation, and sometimes it should: you might be migrating a list that was already double opted-in elsewhere, or have another lawful basis for contacting these people. It is a per-topic setting and it is deliberately not the default. If you turn it off, you are asserting you already have consent, and you are accountable for that assertion. Your deliverability is the first thing that suffers if you are wrong. ## Confirmation links expire A confirmation link is good for 14 days. After that it is refused and the person can sign up again. A link that works forever is a link that still works when the message is forwarded, or found in a shared mailbox two years later. ## Unsubscribing Every campaign carries an unsubscribe link, and we add the headers that let Gmail and Outlook show their own one-click button. Both are required, not optional, and we will not send without them. By default the link unsubscribes from the **topic**, not from everything. Someone tired of your weekly digest keeps getting your product announcements, which is usually what they meant. Recipients can also open a preferences page and choose per topic, or leave entirely. The preferences page only shows topics they already have a relationship with — never your full catalogue, which would tell them about mail they are not receiving. ## When somebody leaves, they stay gone An unsubscribed contact cannot be resubscribed through the API. Not with a secret key, not by importing them again, not by posting the form again. `POST /v1/contacts` with an unsubscribed address refuses with `contact_unsubscribed` and changes nothing. Only the person themselves can undo it, from a link in mail you already sent them. This one is not configurable: an opt-out that your next CSV import can quietly reverse is not an opt-out. ## Bounces and complaints Both suppress the address automatically, and you do not need to handle it yourself. A **permanent bounce** means the address does not exist. We stop sending to it. A **complaint** means they pressed "report spam". We stop sending to them immediately, and treat it as the strongest possible signal — far worse for you than an unsubscribe, because mailbox providers are watching that number. Soft bounces (a full mailbox, a server having a bad afternoon) do not suppress on their own. Repeated ones eventually do. === # Getting approved to send > Why a new workspace can only send to itself at first, and the three things that lift it. Source: https://docs.zevcampaign.com/guide/approval --- A brand-new workspace can send only to addresses it has verified as its own. Everything else works: you can build your audience, design campaigns, and send to yourself to see exactly what lands. What you cannot do yet is mail strangers. This is the single thing new customers most often hit, so it is worth explaining rather than leaving you to discover it at the moment you press Send. ## Why Mailbox providers decide whether your mail reaches an inbox partly on who is sending it and whether the domain checks out. Getting that right before your first campaign is far easier than recovering from a bad start, and a sending reputation is much quicker to lose than to rebuild. So the default for a new workspace is: prove the domain is yours, tell us who you are, and then send to whoever you like. It usually takes less than a business day. ## The three things **1. A verified sending domain.** Add your domain and publish the DNS records we give you. This proves you control it and lets us sign your mail so mailbox providers trust it. **2. An approved brand.** Your brand name, legal name, and a real postal address. The postal address is a legal requirement on marketing email in most countries, not a formality we invented. **3. Our review.** We check that the brand and domain line up and that nothing looks like impersonation. We email you either way. Your dashboard shows all three as a checklist with the current state of each, including when something is waiting on **us** rather than on you. If a step says we are reviewing, there is nothing for you to do. ## Sending to yourself in the meantime Add addresses under **Settings → Verified recipients**. Each one gets a confirmation email; once confirmed, you can send campaigns to it. This is a real end-to-end send — same rendering, same tracking, same unsubscribe handling — so you can test the whole loop before you are approved. Confirmation emails from your signup form follow the same rule, so verify your own address first if you want to test that flow. ## Verified ≠ ready A domain can pass DNS verification and still not be ready to send, because registering it with the mail carrier is a separate step that occasionally fails quietly. Your domain's status distinguishes these, and there is a **Re-sync with carrier** action if it is stuck. If a domain says verified but sending still refuses, that is the button to press. ## After approval Nothing changes about how you call the API. The restriction simply stops applying, and campaigns go to your whole audience. One thing to know: editing identity-sensitive parts of a brand — its name, legal name, sender address or postal address — sends it back for review and pauses sending until that finishes. The dashboard warns you before you save such a change. Cosmetic edits like a logo or colours do not. === # Merge tags > Personalising a campaign, and what happens when a value is missing. Source: https://docs.zevcampaign.com/guide/merge-tags --- Merge tags substitute per-recipient values into subject lines and bodies. ``` Hi {{contact.first_name}}, here is what is new at {{brand.name}}. ``` ## What is available Two namespaces, and only two. **`{{contact.*}}`** — the standard fields plus any custom attribute you have defined on your workspace. | Tag | Value | |---|---| | `{{contact.email}}` | Their address | | `{{contact.first_name}}` | First name | | `{{contact.last_name}}` | Last name | | `{{contact.}}` | Any custom attribute | **`{{brand.*}}`** — who the mail is from. | Tag | Value | |---|---| | `{{brand.name}}` | Brand name | | `{{brand.legal_name}}` | Registered legal name | | `{{brand.postal_address}}` | Postal address | | `{{brand.website_url}}` | Website | There is no `{{order.*}}`, no `{{event.*}}` and no `{{promo.*}}`. If you need per-recipient data beyond this, put it in a custom attribute; the tag list is generated from your actual schema, so anything you can store you can merge. ## A missing value skips the recipient If a campaign uses `{{contact.first_name}}` and a contact has no first name, **that recipient is skipped**. They are not sent the email, and the send is not counted against your plan. This is a deliberate choice and worth understanding, because the alternatives are worse. Sending "Hi ," is visibly broken. Sending "Hi {{contact.first_name}}," is embarrassing and marks you as careless. Substituting a cheerful default like "there" means you cannot tell the difference between somebody called Ada and somebody you know nothing about. Skipping is the only option that neither sends something bad nor pretends the data exists. The campaign report shows exactly who was skipped and which tag was missing, so you can fill the gap and send to them afterwards. If you would rather reach everyone, do not personalise that campaign, or make sure the attribute is populated first. A quick check before sending is to filter your contacts on the attribute being empty. ## Before you send The dashboard previews a campaign against a real contact and tells you how many recipients would be skipped and why. Worth a look on any campaign using a tag you are not certain every contact has. === # Plans and billing > The trial, what happens if an invoice goes unpaid, and how long we keep your data. Source: https://docs.zevcampaign.com/guide/billing --- ZevCampaign has a trial and then paid plans. There is no free tier. That is a deliberate choice rather than an oversight. A free marketing-email tier attracts a great deal of mail that nobody asked to receive, and keeping that off the platform is what protects the deliverability of the people who are doing it properly. ## The trial Every new workspace gets a 14-day trial with a real plan's features and a capped send volume, so you can build and test properly. The trial is **per person**, not per workspace, counted against the account that created it. A second workspace you create does not get a second trial; it starts read-only until you choose a plan. During the trial you can also only send to addresses you have verified as your own, until your brand and domain are approved. See [getting approved](/guide/approval). Choose a plan any time before the trial ends and nothing pauses. ## Currency Your workspace's billing currency is chosen when you create it and **cannot be changed afterwards**. Pick carefully. It is fixed because a currency that moved mid-subscription would silently change what every past invoice meant, which is not something we are willing to let happen to your accounts. We bill in Nigerian naira and US dollars today, and add currencies as we open new markets. A plan with no price in your currency is simply not purchasable there. ## If an invoice goes unpaid Sending stops at the first missed payment. Everything else keeps working: you can sign in, read your reports, and export your contacts. We are direct about this because the alternative is worse for you. Every message we send costs us money at the carrier, so we cannot keep sending on an unpaid account; and a vague warning that something "might" be restricted leaves you guessing about whether your Thursday campaign will go out. Settle the invoice and sending resumes immediately, including anything you had scheduled. ## What happens over time If nothing is settled, the workspace moves down a ladder. At every rung before the last, your data is intact and paying brings everything straight back. | Stage | Sending | Your data | What happens next | |---|---|---|---| | **Invoice overdue** | Paused | Untouched | We keep trying to collect for about a week | | **Read-only** | Paused | Untouched | Kept for about two months | | **Deletion scheduled** | Paused | Untouched | A dated notice, then roughly a month of reminders | | **Deleted** | — | Removed | Invoices and payment history are kept | That is around three months from a missed payment to anything being deleted, with several emails along the way, each naming the exact date and how to stop it. Nothing is ever deleted without a date you have been told in writing, more than once, and a way to stop it right up until it happens. If you are mid-conversation with our team, the clock is paused. ## If you cancel Cancelling takes effect at the end of the period you have paid for, not immediately. You keep what you paid for, to the day, and you can change your mind until then. After that the same retention runway applies, so export anything you want to keep. Your contacts and campaign reports are exportable from the dashboard at any point, including while read-only. ## What a purge removes Your contacts, lists, topics, campaigns, templates, reports and uploaded images. Your invoices and payment history are **kept**, so your accounts stay intact and you can still download what you need. Coming back after a purge means a fresh workspace, which starts empty. ## Changing plan Upgrades apply immediately and are charged pro-rata for the rest of the period. Downgrades are checked against your current usage first. If you are over the new plan's limits on any dimension, we tell you every one of them and by how much — not just the first — so you can see the whole job at once rather than discovering it one retry at a time. === # Building with an AI agent > Everything a model needs to integrate ZevCampaign, in the formats it reads best. Source: https://docs.zevcampaign.com/guide/agents --- These docs are built to be read by machines as well as people. If you are working with Claude, ChatGPT, Cursor, Copilot or anything similar, start here rather than pasting screenshots. ## The three entry points **`/llms.txt`** — a curated index of every page, following the [llms.txt convention](https://llmstxt.org). Titles, one-line descriptions, and a link to each page's raw markdown. Start here when you want the model to find its own way around. ``` https://docs.zevcampaign.com/llms.txt ``` **`/llms-full.txt`** — every page concatenated into one file, with page boundaries marked so the model can cite back to a source URL. Use this when you would rather spend the tokens once and have the whole product in context. ``` https://docs.zevcampaign.com/llms-full.txt ``` **`.md`** — append `.md` to any URL here and you get that page's clean markdown, no navigation, no HTML. ``` https://docs.zevcampaign.com/api/subscribe.md ``` ## Copy for AI Every page has a **Copy for AI** button in its header. It copies the page's markdown together with a short preamble that tells the model what you are building and where to find the rest. Paste it as your first message. ## A prompt that works ```text I am integrating ZevCampaign, an email marketing platform, into my app. Read https://docs.zevcampaign.com/llms.txt first, then fetch the pages you need. I want to: - post signups from my front-end form - receive and verify webhooks on my server - keep my own database in sync with unsubscribes Use my language's standard HTTP client. Ask me before assuming anything about my stack. ``` ## Four things to tell your agent Models reliably get these wrong on a first pass, because most email APIs work differently. Worth stating them up front. **Use a publishable key in the browser, a secret key on the server.** Publishable keys can only reach `/v1/subscribe`. A model that reaches for a secret key in front-end code has made a real security mistake, not a style one. **The subscribe response is deliberately vague.** It returns `confirmation_sent` or `subscribed` and nothing else, on purpose. An agent writing branching logic for "already subscribed" is writing code for a case that will never arrive. **Unsubscribes are one-way.** Re-importing an unsubscribed contact is refused, not silently applied. Code that "syncs" a local list by re-posting everybody will hit `contact_unsubscribed`, and that is correct behaviour rather than an error to retry around. **Verify webhook signatures.** An unverified endpoint accepts forged events from anyone who learns the URL. The method is on [verifying signatures](/webhooks/signatures), and it is five lines. ## Also worth loading - [API reference](/api/) — every endpoint, with its fields and error codes - [Event catalogue](/webhooks/events) — every webhook with its payload === # Authentication > Bearer keys, what each type may reach, and how to handle them. Source: https://docs.zevcampaign.com/api/auth --- Every request carries an API key as a bearer token. ``` Authorization: Bearer sk_live_your_key_here ``` No other scheme is accepted. A missing or malformed header is `missing_api_key`; a key we do not recognise, or one that has been revoked, is `invalid_api_key`. Both return `401`. ## Which key to use where | | Secret (`sk_`) | Publishable (`pk_`) | |---|---|---| | Lives on | Your server | Anywhere, including page source | | Can reach | The whole API | `POST /v1/subscribe` only | | Origin restricted | No | Optionally, and you should | A publishable key on any other endpoint is refused with `publishable_key_not_allowed` and `403`, even though the key is valid. That is the point of it. See [test and live keys](/guide/test-and-live) for the environment half of this. ## Origin restrictions A publishable key can name the domains allowed to use it. A request from anywhere else is refused with `origin_not_allowed`. Set this on every publishable key you create. It costs nothing and it turns a key someone copied out of your page source into one that only works from your own site. ## Brand scoping A key can be locked to one brand. Calls through it only see that brand's topics and lists, and naming another brand's topic is refused with `topic_not_on_key_brand`. Worth doing if you run several brands from one workspace: a mistake then touches one of them rather than all of them. ## Handling keys We show a secret key once, at creation, and store only a hash. If you lose it we cannot recover it, and you will need a new one. - Keep them in environment variables or a secret manager, never in source control - Rotate by creating the new key, deploying, then revoking the old one; both work during the overlap - Revoke immediately if one leaks. Revocation takes effect on the next request If a key is revoked, every request using it starts failing with `invalid_api_key`. There is no grace period, which is the behaviour you want from a revoke. === # Errors > The error shape, the types, and the codes worth handling. Source: https://docs.zevcampaign.com/api/errors --- Every error has the same shape. ```json { "error": { "type": "invalid_request_error", "code": "topic_not_found", "message": "No topic with that id on this workspace. Check the value against your topics list.", "param": "topic_id" } } ``` - **`type`** — the broad category. Branch on this. - **`code`** — the specific reason. Match on this when you need to. - **`message`** — written for a human. Safe to log, not meant to be parsed. - **`param`** — which field caused it, when one field did. Match on `code`, never on `message`. Messages get reworded; codes do not. ## Types | Type | Status | Meaning | |---|---|---| | `authentication_error` | 401 | The key is missing, malformed or revoked | | `permission_error` | 403 | The key is valid but may not do this | | `invalid_request_error` | 400 | Something about the request is wrong | | `not_found_error` | 404 | No such resource on this workspace | | `rate_limit_error` | 429 | Too many requests. See [rate limits](/api/rate-limits) | A `5xx` means the fault is ours. Retry with backoff. ## Codes worth handling **`contact_unsubscribed`** — you tried to create or update a contact who has opted out. Nothing was changed. This is not a failure to work around: see [consent](/guide/consent#when-somebody-leaves-they-stay-gone). Skip them and carry on. **`publishable_key_not_allowed`** — a publishable key reached something other than `/v1/subscribe`. Almost always a secret key that should have been used on the server. **`origin_not_allowed`** — a publishable key was used from a domain it is not allowed on. Add the domain, or check you have not shipped a staging key. **`topic_not_found`** — no such topic on this workspace. Also returned for an archived topic, so a caller cannot probe which ids ever existed. **`topic_not_on_key_brand`** — the key is scoped to one brand and that topic belongs to another. **`invalid_limit`** — `limit` was outside 1–100. We refuse rather than quietly clamp, so you find out now instead of wondering why page sizes look odd. **`invalid_cursor`** — the cursor was not one we issued. Pass `meta.next_cursor` back exactly as given; do not construct one. **`team_inactive`** — the workspace cannot act right now, usually billing. See [plans and billing](/guide/billing). ## Validation errors A malformed body returns `invalid_request_error` with `param` naming the field. ```json { "error": { "type": "invalid_request_error", "code": "validation_failed", "message": "email must be a valid email address.", "param": "email" } } ``` Unknown fields are rejected rather than ignored. A typo'd `frist_name` fails loudly instead of silently dropping the value. ## Retrying Retry `429` and `5xx` with exponential backoff. Do not retry `4xx` otherwise — the request will fail the same way every time. Creating a contact is idempotent on the email address, so a retry after a timeout merges rather than duplicating. === # Pagination > Cursor paging, and why there are no page numbers. Source: https://docs.zevcampaign.com/api/pagination --- List endpoints are cursor-paginated. ```bash curl "https://api.zevcampaign.com/v1/contacts?limit=50" \ -H "Authorization: Bearer sk_live_your_key_here" ``` ```json { "data": [ "…50 contacts…" ], "meta": { "limit": 50, "has_more": true, "next_cursor": "Y3RfOHZxMnhrNG0" } } ``` Pass the cursor back for the next page: ```bash curl "https://api.zevcampaign.com/v1/contacts?limit=50&cursor=Y3RfOHZxMnhrNG0" \ -H "Authorization: Bearer sk_live_your_key_here" ``` When `has_more` is `false`, `next_cursor` is `null` and you are done. ## Walking everything ```js let cursor = null; const all = []; do { const url = new URL('https://api.zevcampaign.com/v1/contacts'); url.searchParams.set('limit', '100'); if (cursor) url.searchParams.set('cursor', cursor); const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.ZEVCAMPAIGN_KEY}` }, }); const { data, meta } = await res.json(); all.push(...data); cursor = meta.next_cursor; } while (cursor); ``` Loop on `next_cursor`, not on `data.length`. A page can come back short and still have more behind it. ## `limit` Between 1 and 100. Defaults to 20. Anything outside that is refused with `invalid_limit` rather than clamped. Silently turning `limit=1000` into `limit=100` means your pagination looks broken and you spend an afternoon finding out why. ## Why no page numbers Offset paging drifts on a moving list. If somebody subscribes while you are on page 3, page 4 starts one row late and you never see that row. On an audience that changes constantly, that is a contact quietly missing from an export. Cursors are anchored to a row, so they stay correct while the list changes underneath them. ## Treat cursors as opaque A cursor is a string we issued. Pass it back unchanged. Do not decode it, build one, or store one for later: a cursor from last week may point at something that has moved. One we did not issue is refused with `invalid_cursor`. === # Rate limits > The per-route limits, and what to do when you hit one. Source: https://docs.zevcampaign.com/api/rate-limits --- Limits are per API key, per minute, and vary by route. | Route | Per minute | |---|---| | `POST /v1/subscribe` | 30 | | `POST /v1/contacts` | 120 | | `GET` endpoints | 120 | | `POST`/`DELETE` on list members | 60 | | `POST`/`DELETE` on suppressions | 120 | | `GET /v1/reports/*` | 30 | Subscribe is the tightest because it is the one route a browser can reach, and an unlimited public endpoint that sends email is an invitation to use your form to mail a stranger repeatedly. [Reports](/api/reports) are lower than other `GET` routes because each one adds up figures across your whole workspace rather than fetching a single record. Send back the `ETag` as `If-None-Match` and an unchanged report answers `304 Not Modified`, which is cheap enough that 30 a minute comfortably covers a dashboard refreshing every few seconds. Requests count against the limit whether they return `200` or `304`. ## When you hit one ```json { "error": { "type": "rate_limit_error", "code": "rate_limited", "message": "Too many requests. Retry after 24 seconds." } } ``` `429`, with a `Retry-After` header in seconds. Wait that long, then retry. ## Backing off properly ```js async function call(url, options, attempt = 0) { const res = await fetch(url, options); if (res.status !== 429 || attempt >= 5) return res; const after = Number(res.headers.get('Retry-After') ?? 1); // Jitter, so a fleet of workers that hit the limit together does // not come back together and hit it again. const wait = after * 1000 + Math.random() * 1000; await new Promise((r) => setTimeout(r, wait)); return call(url, options, attempt + 1); } ``` ## Importing a large audience For a bulk import, prefer the dashboard's CSV import. It is built for volume and gives you a report of what merged, what was skipped and why. If you must do it over the API, 120 contacts a minute is about two a second. Space the calls rather than firing them in parallel and retrying the failures, which just converts one limit into a slower version of the same limit. ## Confirmation emails have their own limit Separately from the route limit, we will not send a second confirmation email to the same address within a short window, however many times the form is submitted. Your call still succeeds and still returns `confirmation_sent`. We simply do not mail them again. A signup box that emails on every submission is a way to mail a stranger repeatedly by pressing a button. === # Subscribe > The one endpoint a browser may call, for signup forms on your own site. Source: https://docs.zevcampaign.com/api/subscribe --- `POST /v1/subscribe` Takes a signup from a form on your own website. This is the only endpoint a **publishable** key may reach, so it is the only one you can safely call from a browser. ## Request ```bash curl https://api.zevcampaign.com/v1/subscribe \ -H "Authorization: Bearer pk_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "topic_id": "tp_7xk2m9qv4w", "email": "ada@example.com", "first_name": "Ada", "attributes": { "city": "Lagos" } }' ``` | Field | Type | Required | Notes | |---|---|---|---| | `topic_id` | string | Yes | What they are subscribing to | | `email` | string | Yes | Max 320 characters | | `first_name` | string | No | 1–120 characters | | `last_name` | string | No | 1–120 characters | | `attributes` | object | No | Custom attributes defined on your workspace | | `website` | string | No | Honeypot. See below | ## Response ```json { "data": { "status": "confirmation_sent" } } ``` `status` is one of: - **`confirmation_sent`** — the topic uses double opt-in. We have emailed them a confirmation link. - **`subscribed`** — the topic does not require confirmation, and they are on the list now. ## The response is deliberately vague You get the same answer whether the address was brand new, already subscribed, still pending confirmation, or previously unsubscribed. This is on purpose. A more specific answer would turn your public form into a way for anyone to check whether a given person is subscribed to a given brand — which is that person's business, and not something we will disclose on your behalf to whoever can read your page source. Behind that identical response we do the right thing: an already confirmed address is not re-mailed, and an unsubscribed one is not resurrected. So write one success path. There is no "already subscribed" branch to handle, and code that waits for one is waiting for a case that never arrives. ## The honeypot Add a `website` field to your form, hide it from humans, and pass whatever comes back. ```html ``` No person sees it, so no person fills it. Bots fill every input they find. A filled one is answered exactly like a real submission and silently dropped, so the bot learns nothing and does not adapt. Use `position:absolute;left:-9999px` rather than `display:none` or `hidden` — the more sophisticated bots skip inputs that are obviously hidden. ## A complete form ```html
``` ## Errors | Code | Status | Meaning | |---|---|---| | `topic_not_found` | 404 | No such topic, or it is archived | | `topic_not_on_key_brand` | 400 | The key is scoped to a different brand | | `origin_not_allowed` | 403 | This domain is not on the key's allowlist | | `rate_limited` | 429 | More than 30 a minute on this key | ## Rate limit 30 requests per minute per key, and we will not send a second confirmation email to the same address within a short window however often the form is submitted. === # Contacts > Reading and importing your audience. Source: https://docs.zevcampaign.com/api/contacts --- Contacts are the people in your workspace. One per email address, no matter how many lists or topics they touch. These endpoints need a **secret** key. ## List contacts `GET /v1/contacts` ```bash curl "https://api.zevcampaign.com/v1/contacts?limit=20&status=subscribed" \ -H "Authorization: Bearer sk_live_your_key_here" ``` | Query | Notes | |---|---| | `limit` | 1–100, default 20 | | `cursor` | From `meta.next_cursor` | | `status` | `subscribed`, `unsubscribed`, `bounced`, `complained` | | `topic_id` | Only contacts subscribed to this topic | ```json { "data": [ { "id": "ct_8vq2xk4m", "email": "ada@example.com", "first_name": "Ada", "last_name": "Lovelace", "status": "subscribed", "attributes": { "city": "Lagos" }, "created_at": "2026-03-14T09:30:00.000Z" } ], "meta": { "limit": 20, "has_more": false, "next_cursor": null } } ``` `status` is derived, not stored: it reflects whether they have unsubscribed, bounced or complained. `bounced` and `complained` mean we will not send to them regardless of their topic subscriptions. ## Get one contact `GET /v1/contacts/{id}` ```bash curl https://api.zevcampaign.com/v1/contacts/ct_8vq2xk4m \ -H "Authorization: Bearer sk_live_your_key_here" ``` 404 with `contact_not_found` if there is no such contact on your workspace. ## Create or update a contact `POST /v1/contacts` ```bash curl https://api.zevcampaign.com/v1/contacts \ -H "Authorization: Bearer sk_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "email": "ada@example.com", "first_name": "Ada", "attributes": { "city": "Lagos", "plan": "pro" }, "topic_ids": ["tp_7xk2m9qv4w"] }' ``` | Field | Type | Required | |---|---|---| | `email` | string | Yes | | `first_name` | string | No | | `last_name` | string | No | | `attributes` | object | No | | `topic_ids` | string[] | No | ```json { "data": { "id": "ct_8vq2xk4m", "email": "ada@example.com", "status": "subscribed", "created_at": "2026-03-14T09:30:00.000Z" } } ``` Idempotent on the address: calling twice merges rather than duplicating, so a retry after a timeout is safe. ### This asserts consent Unlike [`/v1/subscribe`](/api/subscribe), no confirmation email is sent. Passing `topic_ids` here subscribes them directly. A secret key is you telling us you already have consent for these people, and you are accountable for that. If you are taking signups from a form, use `/v1/subscribe` and let us do the confirmation. ### Unsubscribed contacts are refused ```json { "error": { "type": "invalid_request_error", "code": "contact_unsubscribed", "message": "That contact has unsubscribed from this workspace, so their details were not changed and no subscription was added. Only the contact can undo that, from a link in an email you sent them.", "param": "email" } } ``` Nothing is changed, not even the name. This is not an error to work around: it is what makes an opt-out mean something. Skip them and move on. A sync that re-posts your whole local list will hit this for everybody who has left, and that is the system working. ## Merge behaviour When an address already exists, your workspace's merge policy decides what happens to conflicting values: - **Keep existing** — the stored value wins; new fields still fill blanks - **Overwrite** — the incoming value wins - **Per field** — the incoming value wins only where it is non-empty Per field is the default and is usually what you want: an import with a blank last name does not erase a last name you already had. ## Rate limit 120 requests per minute. For a bulk import, prefer the dashboard's CSV import, which is built for volume and reports what merged and what was skipped. === # Topics > Reading the topics people can consent to. Source: https://docs.zevcampaign.com/api/topics --- Topics are what somebody consents to receive. See [core concepts](/guide/concepts#topic) for why they exist and why they are not lists. ## List topics `GET /v1/topics` ```bash curl https://api.zevcampaign.com/v1/topics \ -H "Authorization: Bearer sk_live_your_key_here" ``` ```json { "data": [ { "id": "tp_7xk2m9qv4w", "name": "Weekly newsletter", "description": "A Friday round-up of what we shipped.", "brand_id": "br_3nf8q1", "requires_confirmation": true, "subscriber_count": 1842, "created_at": "2026-01-08T11:02:00.000Z" } ], "meta": { "limit": 20, "has_more": false, "next_cursor": null } } ``` If your key is scoped to a brand, you see only that brand's topics. `requires_confirmation` tells you whether a signup goes through double opt-in. When it is `true`, [`/v1/subscribe`](/api/subscribe) answers `confirmation_sent`; when `false`, `subscribed`. `subscriber_count` counts confirmed subscribers only. Pending confirmations are not included, because somebody who has not clicked the link is not a subscriber. ## Creating topics Topics are created in the dashboard, not over the API. A topic is a promise to a recipient about what they will receive, and its name appears on the unsubscribe page. Creating them from a script tends to produce a catalogue of `list-1`, `list-2`, `test-topic`, which is exactly what a person reading their preferences page should never see. ## Using a topic id Pass it as `topic_id` when subscribing, or in `topic_ids` when creating a contact. Both validate the format and refuse an id that does not belong to your workspace, or to your key's brand if it is scoped. === # Lists > Your own segmentation, and adding or removing members. Source: https://docs.zevcampaign.com/api/lists --- Lists are how you group contacts for your own purposes. They are not permission: see [core concepts](/guide/concepts#list). ## List lists `GET /v1/lists` ```bash curl https://api.zevcampaign.com/v1/lists \ -H "Authorization: Bearer sk_live_your_key_here" ``` ```json { "data": [ { "id": "ls_4m2kx9", "name": "Lagos customers", "description": "Anyone who has bought from the Lagos store.", "brand_id": "br_3nf8q1", "member_count": 412, "created_at": "2026-02-01T08:00:00.000Z" } ], "meta": { "limit": 20, "has_more": false, "next_cursor": null } } ``` ## Add members `POST /v1/lists/{id}/members` ```bash curl https://api.zevcampaign.com/v1/lists/ls_4m2kx9/members \ -H "Authorization: Bearer sk_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "contact_ids": ["ct_8vq2xk4m", "ct_1p9wz3"] }' ``` ```json { "data": { "added": 2, "already_members": 0, "member_count": 414 } } ``` Idempotent. A contact already on the list counts under `already_members` rather than failing the call, so a retry is safe. ## Remove members `DELETE /v1/lists/{id}/members` ```bash curl -X DELETE https://api.zevcampaign.com/v1/lists/ls_4m2kx9/members \ -H "Authorization: Bearer sk_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "contact_ids": ["ct_8vq2xk4m"] }' ``` ```json { "data": { "removed": 1, "member_count": 413 } } ``` Removing somebody from a list does **not** unsubscribe them. They keep every topic subscription they had; you have only changed your own segmentation. To stop mailing somebody entirely, see [suppressions](/api/suppressions). ## Membership does not grant permission Adding a contact to a list does not let you mail them about anything they have not consented to. A campaign sent to a list still only reaches the people on it who are subscribed to that campaign's topic. If you add 500 contacts to a list and 200 of them are not subscribed to the topic, 300 get the campaign. This surprises people, so it is worth saying plainly: the list narrows *who you are considering*, the topic decides *who you may send to*. === # Suppressions > Addresses and domains you never want mailed, whatever else happens. Source: https://docs.zevcampaign.com/api/suppressions --- A suppression is a hard stop. A suppressed address is never sent to, no matter which lists it is on or what it is subscribed to. Bounces and complaints suppress automatically; you do not need to handle those. These endpoints are for suppressions *you* decide on: a competitor's domain, an address someone asked you to remove by phone, a role account you would rather not mail. ## List suppressions `GET /v1/suppressions` ```bash curl https://api.zevcampaign.com/v1/suppressions \ -H "Authorization: Bearer sk_live_your_key_here" ``` ```json { "data": [ { "id": "sup_9k2m4x", "email": "ada@example.com", "domain": null, "reason": "manual", "note": "Asked to be removed by phone, 14 March.", "created_at": "2026-03-14T10:00:00.000Z" } ], "meta": { "limit": 20, "has_more": false, "next_cursor": null } } ``` ## Suppress an address or a domain `POST /v1/suppressions` ```bash curl https://api.zevcampaign.com/v1/suppressions \ -H "Authorization: Bearer sk_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "email": "ada@example.com", "note": "Asked to be removed by phone, 14 March." }' ``` Or a whole domain: ```bash curl https://api.zevcampaign.com/v1/suppressions \ -H "Authorization: Bearer sk_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "domain": "competitor.com", "note": "Never mail a competitor." }' ``` | Field | Type | Notes | |---|---|---| | `email` | string | One of `email` or `domain` is required | | `domain` | string | A bare hostname, like `competitor.com` | | `note` | string | Why. Strongly recommended | **Write the note.** In a year, "why is this address suppressed" is a question somebody will ask, and a suppression with no explanation tends to get removed by whoever is cleaning up — occasionally the one that was there for a legal reason. ## Remove a suppression `DELETE /v1/suppressions/{id}` ```bash curl -X DELETE https://api.zevcampaign.com/v1/suppressions/sup_9k2m4x \ -H "Authorization: Bearer sk_live_your_key_here" ``` Removing a suppression does not resubscribe anybody. It only lifts the block. If the contact had unsubscribed, they are still unsubscribed, and only they can change that. ## What you cannot remove Suppressions from a **complaint** stand. Someone pressed "report spam"; that is the clearest possible statement, and letting it be undone from an API would make it meaningless. Hard bounces can be removed, but think first: the address did not exist when we tried it. If it has since been created, let the person sign up again rather than reviving a dead address, which is a fast way to end up in a spam trap. ## Domain suppressions A domain suppression blocks every address at that domain, now and in future. Useful for competitors and for domains you have been asked not to contact. Be careful with large free providers. Suppressing `gmail.com` would silently remove most of your audience from every campaign, and the first sign is a report with a number far smaller than you expected. === # Reports > Aggregate numbers for your own dashboards, without pulling every record. Source: https://docs.zevcampaign.com/api/reports --- Reports give you the figures directly: how many contacts you have, how many campaigns you have sent, how each one performed. Pull them into your own admin screens, a weekly digest, or whatever you already use to watch your business. Use these instead of paging through [contacts](/api/contacts) and counting. One request returns the number, and it stays fast however large your audience gets. Every field is a count, a rate or a campaign's own name. No recipient details are returned, so you can feed these into your reporting tools without carrying anyone's personal data into them. ## Workspace totals `GET /v1/reports/summary` ```bash curl https://api.zevcampaign.com/v1/reports/summary \ -H "Authorization: Bearer sk_live_your_key_here" ``` ```json { "data": { "contacts": { "total": 18402, "subscribed": 17642, "unsubscribed": 422, "bounced": 310, "complained": 28 }, "lists": { "total": 12, "memberships": 21044 }, "topics": { "total": 4 }, "campaigns": { "total": 86, "sent": 81, "scheduled": 2, "draft": 3, "sending": 0 }, "sends": { "used_this_period": 24150, "period_started_at": "2026-10-01T00:00:00.000Z", "scope": "workspace" }, "scope": { "type": "workspace", "brand_id": null } }, "as_of": "2026-10-10T13:04:11.000Z" } ``` `contacts.subscribed` counts everyone you can currently email: not unsubscribed, not bounced, not complained. The other three buckets account for the rest, so the four add up to `total`. `sends.used_this_period` is your send allowance used so far in the current period, and it always covers the whole workspace. One allowance pays for every brand, so there is no per-brand figure to report. This endpoint takes no `from` or `to`, on purpose. Its figures are states: "how many contacts you have" is true at a moment, and there is no total between two dates. For numbers over a period, use [activity](#activity-over-a-period) below, where every figure is a count of things that happened. ## Engagement per campaign `GET /v1/reports/campaigns` One row per campaign you have sent, newest first. ```bash curl https://api.zevcampaign.com/v1/reports/campaigns?limit=2 \ -H "Authorization: Bearer sk_live_your_key_here" ``` ```json { "data": [ { "id": "cmp_7k2m9x", "name": "October announcement", "subject": "Your October update is here", "sent_at": "2026-10-05T10:00:00.000Z", "recipients": 12480, "accepted": 12402, "delivered": 12311, "opened": 5240, "clicked": 812, "bounced": 78, "complained": 3, "unsubscribed": 41, "failed": 0, "open_rate": 42.56, "click_rate": 6.6, "bounce_rate": 0.63, "complaint_rate": 0.02 } ], "meta": { "limit": 2, "has_more": true, "next_cursor": "MjAyNi0xMC0wNFQxMDowMDowMC4wMDBafGNtcF84ajNuMnE" }, "as_of": "2026-10-10T13:04:00.000Z", "scope": { "type": "workspace", "brand_id": null } } ``` Only campaigns you have sent appear here. A draft has nothing to report, and including it would give you rows of zeros to filter out. Paginate with `cursor`, the same way as everywhere else in the API. See [pagination](/api/pagination). ### Filtering to a period Add `from` and `to` to narrow it to campaigns sent in a window: ```bash curl "https://api.zevcampaign.com/v1/reports/campaigns?from=2026-09-01&to=2026-10-01" \ -H "Authorization: Bearer sk_live_your_key_here" ``` Both accept a plain date (`2026-09-01`) or a full timestamp (`2026-09-01T00:00:00Z`). A plain date is read as UTC. Either can be used on its own: `from` alone means everything since, `to` alone means everything before. With neither, you get your most recent campaigns with no window applied. `to` is exclusive. September and October therefore never both report the same campaign, so you can request consecutive months and add the results up. ### How the rates are worked out Open and click rates are measured against **deliveries**, not recipients: ``` open_rate = opened / delivered click_rate = clicked / delivered ``` An email that bounced never reached a person, so counting it would drag your open rate down for a reason that has nothing to do with the campaign. Bounce and complaint rates are measured against **recipients**, because there a failed delivery is the thing being measured: ``` bounce_rate = bounced / recipients complaint_rate = complained / recipients ``` All four are percentages, rounded to two decimal places. Work them out yourself from the raw counts if you would rather use a different denominator. ## Activity over a period `GET /v1/reports/activity` A time series: what happened, bucketed by day, week or month. This is the endpoint for "how did last month go" and for charting growth. ```bash curl "https://api.zevcampaign.com/v1/reports/activity?from=2026-10-01&to=2026-10-04&interval=day" \ -H "Authorization: Bearer sk_live_your_key_here" ``` ```json { "data": [ { "period_start": "2026-10-01T00:00:00.000Z", "contacts_added": 142, "unsubscribes": 3, "campaigns_sent": 1, "opens": 5240, "clicks": 812 }, { "period_start": "2026-10-02T00:00:00.000Z", "contacts_added": 0, "unsubscribes": 0, "campaigns_sent": 0, "opens": 1103, "clicks": 95 }, { "period_start": "2026-10-03T00:00:00.000Z", "contacts_added": 96, "unsubscribes": 11, "campaigns_sent": 2, "opens": 7418, "clicks": 1204 } ], "meta": { "limit": 3, "has_more": false, "next_cursor": null }, "totals": { "contacts_added": 238, "unsubscribes": 14, "campaigns_sent": 3, "opens": 13761, "clicks": 2111 }, "period": { "from": "2026-10-01T00:00:00.000Z", "to": "2026-10-04T00:00:00.000Z", "interval": "day" }, "as_of": "2026-10-10T13:04:00.000Z", "scope": { "type": "workspace", "brand_id": null } } ``` | Parameter | Default | Notes | |---|---|---| | `from` | 30 days before `to` | ISO date or timestamp | | `to` | now | Exclusive | | `interval` | `day` | `day`, `week` or `month` | `totals` adds up the buckets in the response, so it always matches the series rather than being counted separately. `period` echoes the window that was actually applied, including any default you did not set, so you can see what you got. A quiet period comes back as a row of zeros rather than being left out. The series is continuous, so you can chart it without reconstructing the calendar yourself. ### Things worth knowing **Buckets are aligned to the interval, not to your `from`.** A weekly series starts its first bucket on the Monday of the week containing `from`, which may be before `from` itself. The counts only include what falls inside your window, so the first and last buckets of a series can be partial. If you are comparing periods, line your window up with the interval. **The series covers events, not states.** Contacts added, unsubscribes, campaigns sent, opens and clicks are all things that happened at a moment, so a window applies to them cleanly. Per-email outcomes like deliveries and bounces belong to the campaign that produced them, so they live on [engagement per campaign](#engagement-per-campaign) instead of being spread across days. **One request, one window.** There is no pagination here, because the window is capped instead: at most 400 buckets, so a year of days or three decades of months. Ask for more and you get a clear error telling you to use a coarser interval or split the range. What comes back is always the whole answer. **Timestamps are UTC**, everywhere, in and out. ## Keeping the numbers current Reports are cached, and the cache is tied to your data rather than to a clock. Change something and the next request reflects it: add a contact, and the contact total moves straight away. The one exception is opens, clicks and deliveries. Those arrive continuously while a campaign is going out and for a while afterwards, so they can trail by up to 30 seconds. It applies to the open and click columns on both the per-campaign report and the activity series. Every response carries `as_of` to tell you the moment the figures were valid, so you never have to guess. ## Polling without wasting your rate limit Every response carries an `ETag`. Send it back as `If-None-Match` and you get `304 Not Modified` while nothing has changed: ```bash curl -i https://api.zevcampaign.com/v1/reports/summary \ -H "Authorization: Bearer sk_live_your_key_here" \ -H 'If-None-Match: W/"wd0-_HMvJ7tTg7X3nsGoDJrgNEE"' ``` ``` HTTP/1.1 304 Not Modified ETag: W/"wd0-_HMvJ7tTg7X3nsGoDJrgNEE" Cache-Control: private, max-age=0, must-revalidate ``` A `304` has no body, so it is far cheaper for both of us, and it is never stale: the `ETag` changes the moment your figures do. If you poll these endpoints on a schedule, store the `ETag` and send it back. It is the single best thing you can do for a dashboard that refreshes often. Requests still count against your rate limit whether they return `200` or `304`. See [rate limits](/api/rate-limits). ## Brand-scoped keys A key restricted to one brand reports that brand only, and `scope` in every response tells you which you are looking at: ```json { "scope": { "type": "brand", "brand_id": "br_3nf8q1" } } ``` A workspace-wide key reports `{ "type": "workspace", "brand_id": null }`. The URL is the same either way, so check `scope` rather than assuming, especially if you hold keys for several brands. The send allowance is the exception noted above: it is always the workspace figure, because the allowance is not divided between brands. ## Limits These endpoints do more work than a single-record fetch, so they have their own, lower rate limit of 30 requests a minute. With `If-None-Match` in place that is plenty for a dashboard that refreshes every few seconds. They need a secret key (`sk_…`). A publishable key will not reach them, because these are whole-business figures and do not belong in a browser. === # Verifying signatures > Proving an event came from us, in about five lines. Source: https://docs.zevcampaign.com/webhooks/signatures --- Every webhook carries a signature header. Verify it. An endpoint that does not is one that accepts forged events from anyone who learns the URL — and a forged `contact.unsubscribed` for your best customer is a cheap thing for someone to send. ## The header ``` X-Zev-Signature: t=1773481800000,v1=5f2a9c... ``` - **`t`** — when we signed it, in milliseconds since the epoch. - **`v1`** — HMAC-SHA256 of `{t}.{raw body}`, hex encoded, using your endpoint's signing secret. The timestamp is part of what is signed, which is what stops somebody replaying a captured request later. ## Verifying Three steps, and the order matters: 1. Recompute the HMAC over `{t}.{raw body}` with your secret. 2. Compare it to `v1` in **constant time**. 3. Reject anything older than a few minutes. ```js import crypto from 'node:crypto'; export function verifySignature(rawBody, header, secret) { const parts = Object.fromEntries( String(header ?? '') .split(',') .map((p) => p.split('=')), ); const { t, v1 } = parts; if (!t || !v1) return false; // Reject anything older than five minutes, so a captured request // cannot be replayed tomorrow. const age = Math.abs(Date.now() - Number(t)); if (!Number.isFinite(age) || age > 5 * 60 * 1000) return false; const expected = crypto .createHmac('sha256', secret) .update(`${t}.${rawBody}`) .digest('hex'); // Constant time. `===` leaks how much of the signature matched, // one byte at a time, which is enough to forge one given patience. const a = Buffer.from(expected, 'hex'); const b = Buffer.from(v1, 'hex'); return a.length === b.length && crypto.timingSafeEqual(a, b); } ``` ```python import hmac, hashlib, time def verify_signature(raw_body: bytes, header: str, secret: str) -> bool: parts = dict(p.split("=", 1) for p in (header or "").split(",")) t, v1 = parts.get("t"), parts.get("v1") if not t or not v1: return False if abs(time.time() * 1000 - int(t)) > 5 * 60 * 1000: return False expected = hmac.new( secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, v1) ``` ## You need the raw body Sign the bytes we sent, not a re-serialised object. `JSON.parse` then `JSON.stringify` will change key order, spacing or unicode escaping, and the HMAC will not match. This is the single most common reason verification fails on a first attempt. In Express: ```js // Raw body for this route only, so the rest of your app keeps // getting parsed JSON. app.post( '/webhooks/zevcampaign', express.raw({ type: 'application/json' }), (req, res) => { const ok = verifySignature( req.body, // a Buffer, unparsed req.get('X-Zev-Signature'), process.env.ZEVCAMPAIGN_WEBHOOK_SECRET, ); if (!ok) return res.sendStatus(401); const event = JSON.parse(req.body.toString('utf8')); res.sendStatus(200); // …handle it }, ); ``` Next.js app router: ```js export async function POST(request) { const raw = await request.text(); // before any parsing const ok = verifySignature( raw, request.headers.get('x-zev-signature'), process.env.ZEVCAMPAIGN_WEBHOOK_SECRET, ); if (!ok) return new Response('invalid signature', { status: 401 }); const event = JSON.parse(raw); return new Response('ok'); } ``` ## Rotating the secret Roll it from the dashboard. The new secret applies to the next delivery, so deploy the new value before rotating, or accept a short window of rejected events that will be retried. ## If verification fails In order of likelihood: 1. **The body was parsed before verifying.** The usual cause. 2. **The wrong secret.** Each endpoint has its own, and staging and production differ. 3. **Clock skew.** If your server's clock is minutes off, the age check rejects everything. Run NTP. 4. **The signature was compared with `===`.** Works, but leaks timing. Fix it anyway. === # Event catalogue > Every webhook event, when it fires, and what its payload carries. Source: https://docs.zevcampaign.com/webhooks/events --- Twelve events in four groups. Subscribe only to the ones you act on: an endpoint receiving everything and ignoring most of it is an endpoint that falls behind. ## Audience | Event | Fires when | |---|---| | `contact.subscribed` | Someone confirmed a subscription, or was subscribed through the API. On a double opt-in topic this fires on CONFIRMATION, not on the form submission, so it always means consent. | | `contact.unsubscribed` | Someone opted out, from a link in an email or the preference centre. Treat it as authoritative and stop contacting them from your own systems too. | ## Delivery | Event | Fires when | |---|---| | `email.delivered` | The recipient's mail provider accepted the message. | | `email.bounced` | A permanent failure. The contact is suppressed automatically; you do not need to do it yourself. | | `email.complained` | The recipient reported it. The contact is suppressed automatically. Worth alerting on: complaints are the fastest way to lose a sending reputation. | | `email.failed` | We could not send to this recipient at all. Unlike a bounce, nothing left our side. | ## Engagement | Event | Fires when | |---|---| | `email.opened` | A tracking pixel loaded. Indicative, not certain: images are blocked, prefetched and cached, so treat it as weak evidence and never as proof someone read it. | | `email.clicked` | A tracked link was followed. Much stronger evidence of engagement than an open. | ## Campaigns | Event | Fires when | |---|---| | `campaign.sending` | A campaign started dispatching. | | `campaign.sent` | Every recipient of a campaign has been attempted. | | `campaign.paused` | A campaign was paused mid-send. | | `campaign.cancelled` | A campaign was cancelled mid-send. Queued recipients were not sent to and their quota was refunded. | ## Payload fields Every per-recipient event (`email.*`) carries the same base: | Field | Notes | |---|---| | `send_id` | This one recipient's send. Unique per recipient per campaign | | `campaign_id` | | | `campaign_name` | | | `contact_id` | | | `email` | | | `occurred_at` | When it happened at the provider, not when we saw it | Plus, per event: **`email.bounced`** | Field | Notes | |---|---| | `bounce_type` | `permanent`, `transient` or `undetermined` | | `reason` | The provider's own wording. Useful, not parseable | | `suppressed` | Whether we have stopped sending to this address | Only a permanent bounce suppresses. A transient one means a full mailbox or a bad afternoon, and the address is still mailable. **`email.complained`** | Field | Notes | |---|---| | `complaint_type` | The feedback loop's category, when given | | `suppressed` | Always `true` | **`email.failed`** | Field | Notes | |---|---| | `reason` | Why it never reached the provider | Distinct from a bounce: nothing left our side. A missing merge-tag value, a render error, an address already suppressed. Do not treat this as a dead address, or you will suppress perfectly good ones. **`email.opened`** | Field | Notes | |---|---| | `country` | Two-letter code, when we can tell. `null` otherwise | **`email.clicked`** | Field | Notes | |---|---| | `url` | The link followed | | `country` | Two-letter code, or `null` | | `device_type` | `mobile`, `tablet`, `desktop`, or `null` | | `client` | Mail client, or `null` | We report `null` rather than guessing. A confident wrong answer is worse in a report than an honest gap. **`contact.subscribed`** | Field | Notes | |---|---| | `contact_id`, `email` | | | `topic_id`, `topic_name` | | | `brand_id` | | | `source` | `double_opt_in`, `api`, `import` or `dashboard` | | `occurred_at` | | `source` tells you how strong the consent is. `double_opt_in` means they clicked a link in their own inbox. **`contact.unsubscribed`** | Field | Notes | |---|---| | `contact_id`, `email` | | | `scope` | `topic`, `brand` or `all` | | `topic_id`, `topic_name` | Set when `scope` is `topic` | | `brand_id` | Set for `topic` and `brand` | | `campaign_id` | Which campaign the link was in, when it was one | | `occurred_at` | | **Read the `scope`.** `topic` means they left one topic, not your whole workspace. Treating it as a global opt-out loses you a subscriber who wanted to stay on everything else. **`campaign.*`** | Field | Notes | |---|---| | `campaign_id`, `campaign_name` | | | `subject` | | | `brand_id` | | | `status` | | | `recipient_count` | What the send was sized at | | `occurred_at` | | `campaign.sent` adds `delivered`, `bounced`, `failed` and `skipped` as they stood when the send finished. Delivery keeps moving afterwards, so treat them as a starting point and read the campaign for current numbers. ## Which to subscribe to **Keeping a CRM in sync:** `contact.subscribed`, `contact.unsubscribed`. These change who you are allowed to contact anywhere, including from your own transactional mail. **List hygiene:** `email.bounced`, `email.complained`. We suppress automatically on our side; subscribe if you want to mirror it in your own database. **Alerting:** `email.complained`. Complaints are the fastest way to lose a sending reputation, and a sudden cluster is worth waking somebody for. **Engagement scoring:** `email.opened`, `email.clicked`. Weight clicks far above opens. **Operational dashboards:** `campaign.sent` tells you a send finished without polling for it.