# Zeebra documentation # Getting started The few steps between a new workspace and your first campaign. Zeebra keeps your contacts and sends your email campaigns from your own domain, through Amazon SES. Everything lives in a **workspace**: its contacts, lists, segments, templates, campaigns, sending domains and members. Use one workspace per product or team, and switch between them from the menu at the top left. ## 1. Fill in your details Open **Settings → Workspace** and add: - **Postal address.** Anti-spam laws require it in every marketing email, so campaigns can't be sent without it. It goes in the footer for you. - **Company name, brand colour and logo.** Used in the email footer and the pages people see when they unsubscribe. - **Timezone and default country.** A new workspace starts in your browser's time zone and, when we can tell, your country. The time zone is used for schedules, reports and summary emails; the default country reads phone numbers typed without a `+`. With no default country, every phone number needs its country code (like `+44 20 7946 0958`). ## 2. Add your sending domain Open **Settings → Email sending**, add a domain such as `news.yourcompany.com`, and add the DNS records it shows. See [Sending domain and DNS](/help/sending-domain). ## 3. Bring in your contacts Import a CSV from your old email tool, add people by hand, or collect signups with a [signup form](/help/signup-forms). Each contact is one of two kinds: - **Opted in**: they agreed to get your emails. Only they get campaigns. - **Prospect**: you have their address but not their agreement. They never get campaigns. See [Importing contacts](/help/importing-contacts). ## 4. Organise them - **Tags** label contacts (VIP, speaker, 2026 attendee). - **Lists** are fixed groups you add people to. - **Segments** are rules ("opted in, tagged VIP, opened in the last 90 days") worked out each time you use them. ## 5. Write and send Write a template or a campaign in the visual editor, in Markdown or in HTML, then review it, send yourself a test, and send it. See [Campaigns and sending rules](/help/campaigns). ## Working with code or AI agents Everything above can also be done through the REST API or by an AI agent over MCP. See [API and MCP](/help/api-and-mcp). # Sending domain and DNS Which domain to send from, the DNS records to add, and the warm-up of a new domain. Every Zeebra email comes from **your own domain**, signed so inboxes know it really is you. You add a few DNS records once, at the company that runs your domain's DNS (Cloudflare, GoDaddy, Route 53…). ## Use a subdomain Send campaigns from a subdomain such as `news.yourcompany.com`, not from `yourcompany.com` itself. If a campaign ever gets complaints, the damage stays on the subdomain, and your everyday email (invoices, sign-in codes, your team's inboxes) is not affected. ## Add the domain 1. Open **Settings → Email sending** and add the domain. 2. Zeebra shows the records to add. Copy each one exactly. 3. Click **Check again** once they are in. DNS changes can take up to an hour to show. When the domain's DNS provider supports it, a **Set up automatically** button adds the records for you after you sign in to the provider. ## The records | Purpose | Type | What it does | | ------------------- | ----- | ----------------------------------------------------------------------------------------------------------------- | | DKIM (3 records) | CNAME | Signs every email, so inboxes can check it came from you and wasn't changed. | | Bounces (MAIL FROM) | MX | Bounces come back to Amazon SES at `bounce.`, so Zeebra can stop emailing addresses that don't work. | | SPF (MAIL FROM) | TXT | Says Amazon SES may send for `bounce.`. | | DMARC | TXT | Tells inboxes what to do with email that fails the checks, and where to send reports. Start with `p=none`. | The domain is **verified** once DKIM and MAIL FROM pass. A DMARC record is strongly recommended: Gmail and Yahoo expect one from bulk senders. ## No domain yet? Your Zeebra address Until your own domain is ready, you can send from an address of your own on Zeebra's shared domain: `hello@.`. On **Settings → Email sending**, pick the name (letters, numbers and dashes; some names like `www` or `mail` are kept for Zeebra) and click **Set it up**. Zeebra adds the DNS records itself; it's usually ready in a few minutes, and the page shows if something went wrong, with **Try again**. - It's a subdomain just for your workspace, with its own signature (DKIM) and its own reputation: another workspace's problems never affect it. - Replies go to the workspace owner, and the daily limit is low (20 emails on the first day, growing to 200 a day, and it stays there). Add your own domain to send more. - Your published campaigns are on the web at `.` too. - Owners can change the name. A new address is set up from scratch: the current one keeps sending until the new one is ready, then stops working. Once your own domain is verified, send from it instead: the shared address is no longer offered. ## Senders A sender is who the email is from: a name, an address on a verified domain (`hello@news.yourcompany.com`) and, if you like, a reply-to address where answers should go. One sender is the default for new campaigns. ## Warm-up Mailbox providers distrust a domain that suddenly sends a lot. So a new domain has a daily cap that grows each day from its first campaign email: | Day | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | | ------ | --- | --- | --- | --- | ----- | ----- | ----- | ------ | ------ | ------ | ------ | ------ | ------- | | Emails | 50 | 100 | 250 | 500 | 1,000 | 2,500 | 5,000 | 10,000 | 20,000 | 35,000 | 50,000 | 75,000 | 100,000 | Emails over the day's cap simply wait for the next day. After day 13 there is no cap. Once all of a verified domain's records are found, its card folds them under one **DNS records** line; click it to see them again. If your default sender still uses Zeebra's shared address, a card offers to switch it to your verified domain. ## Your public pages on your own domain The campaigns you publish on the web are listed on your public pages. On the Email sending page, under **Public pages**, you can put them on a subdomain of your own, like `news.yourcompany.com`. Add the two records shown: a CNAME that points the subdomain at Zeebra, and a TXT record that proves the domain is yours (so nobody else can claim it with an old CNAME). Click **Check now**: it goes live once both are found. One domain at a time; if you remove a live one, its links redirect to your public pages on Zeebra. Tracked and unsubscribe links in your emails stay on Zeebra's own domain. ## Test it On the Email sending page, send a test email to yourself. If it lands in spam, check that every record shows as in place. # Business groups Share contacts and companies between the workspaces of your businesses, with one profile and one consent. Run more than one business on Zeebra? A **business group** connects their workspaces so they share contacts and companies. Each workspace keeps its own campaigns, lists, segments, tags, templates, senders, notes and team. ## Setting one up An **owner** of a workspace opens **Settings → Business group** and creates the group: they own it. Then they connect their other workspaces (only workspaces you own can be connected, and a workspace is in one group at most). For each workspace, choose: - **Can edit** or **View only**: a view-only workspace gets every shared contact and company and their updates, but can't change their shared details. Its own tags, lists, custom properties and owners stay its own to change. - **Share everything new**: every contact and company added to that workspace (by hand, an import, a signup form or the API) is shared at once. Connecting a workspace gives it a copy of everything the group already shares. When it has its own contact with the same email, or its own company with the same domain (else the same name), that one is **linked** instead of copied. The page shows how many were linked and copied. Members and admins of a connected workspace see the group, its workspaces and their access, but only the group's owner changes it. ## What's shared A shared **contact** has one profile in every workspace: first and last name, email, phone, job title, company, city and country. Change it in a workspace that can edit and every workspace has the change. A shared **company** has one name, domain, website, industry, size, phone, city and country. Each workspace keeps its own: tags, lists, topics, notes, custom properties, the company's owner, campaign history and engagement. ## Consent - **Opting in anywhere opts in everywhere.** A contact who opts in to one workspace (a signup, an import with consent, Mark as opted in) is opted in, with the same consent record, in every workspace of the group. - **Unsubscribing, bouncing or complaining anywhere applies everywhere**, and the address goes on every workspace's do-not-email list. The contact's history says which workspace it came from. - Opting in never brings back a contact who unsubscribed, bounced or complained somewhere. Only the contact signing up again does, and that brings them back in the workspaces where they had only unsubscribed. - Email topics stay each workspace's own. ## Sharing and stopping - On a contact or company page, **Share with group** shares it. Sharing a contact shares its company too. - In the Contacts and Companies tables, select records and choose **Share with …** or **Stop sharing**. - The **Shared** badge marks shared records; hover it to see which workspaces have them. The **Shared with** card on a record's page lists the other workspaces and, for a contact, its status there. Nothing else from another workspace is ever shown. - The **Shared** and **This workspace only** views, and the **Shared with business group** filter, work in tables, segments and the API. **Stop sharing** keeps the record in this workspace as a plain one; the other workspaces keep theirs. Disconnecting a workspace or deleting the group works the same way: no contact or company is deleted. **Deleting a shared company** deletes only this workspace's copy: the other workspaces keep theirs, still shared among themselves (a company left in one workspace stops being shared). A contact's company is shared, so its shared contacts leave the company in every workspace and keep its name. From a workspace that can only view shared records, the change stays in that workspace. **Merging** shared contacts or companies ([Duplicates](/help/duplicates)): the record you keep stays shared, or takes the other's place when only the other was. A merged company's shared contacts move to the kept company in every workspace. Only a workspace that can edit shared records merges shared records. ## Review duplicates When two workspaces' records are linked, the sharing workspace's values win and the other fills in what's missing. Where they had different values (two spellings of a name, two phone numbers), the owner sees them under **Review duplicates** and keeps the right one, or **Keep newest for all**. Contacts and companies that two of your workspaces already had before they were connected aren't shared until you say so: **Share and link them** shares them all at once. ## In the API Contacts and accounts have `shared` (true or false), and `list_contacts` and `list_accounts` take `filter[shared]=true`. `get_business_group` shows the workspace's group; `share_contact`, `unshare_contact`, `share_account` and `unshare_account` share and stop sharing. Copies made by sharing have the source `shared`. See the [API reference](/docs/api). # Companies Your workspace's companies, how contacts join them, and the Companies page. **Companies** (under Contacts in the sidebar) are the companies your contacts work at. They're your workspace's own: only your team sees them, and you can change anything on them. ## How contacts join a company You rarely add companies by hand. A contact joins one when it's added, imported or signs up (or, once AI search launches, is saved from it): 1. **Saved from Zeebra's data** (AI search, coming soon): the company of the decision-maker, with its website, industry, city and size filled in. 2. **A company website or domain** in an import column or the API. 3. **The company name**, compared without case or extra spaces: "ACME co" joins "Acme Co". 4. **Their work email**, when no company name is given: sara@acme.com joins the company with the domain acme.com. Free mail (gmail.com, outlook.com, hotmail.com, yahoo.com, icloud.com…) and addresses flagged as a typo never count. When nothing matches, the company is made: with the name given, or one taken from the domain (acme.com.sa becomes "Acme"). A contact's **Company** property always shows its company's name; renaming the company renames it on its contacts. To move a contact, open it and pick another company in the **Company** panel, or type a name and choose **New company**. Clear it to take them out of any company. ## The Companies page The table works like the contacts table: tabs (All, My companies, Recently added, With contacts), search, quick and advanced filters, saved views, columns and sort. Select companies to: - **Add their contacts to a list**, - **Edit** a property on all of them (an owner, an industry…), - **Merge** two of them (select exactly two), - **Delete** them. Their contacts are kept, without a company. **New company** asks for a name and, if you know them, a domain, industry and city. A domain belongs to one company only. ## A company's page - **Properties**: name, domain, industry, size, owner, notes, website, phone, location and your own custom ones. Add properties in Settings → Properties → Companies. - **Contacts**: everyone at the company, with the contacts table's filters and bulk actions. **Add contact** picks one of your contacts or adds someone new. - **Recent emails**: the newest campaign emails its contacts got. - **History**: the companies merged into it. ## Merging duplicates Two companies can be the same one, like "Acme" made from a contact's company name and "Acme Co" made from the work email domain. **Manage duplicates** (on the Companies page) lists the ones that look alike: the same domain or website, or the same name. You can also choose **Merge with…** on a company's page, or select two companies in the table and choose **Merge**. You see both side by side and pick the company to keep and, for each property, the value it keeps (its own, or the other's where it has none). Then: - Every contact moves to the company you keep, and their **Company** shows its name. - The link to Zeebra's data stays, from whichever company had it. - Segments and saved views that pick the other company use the kept one. - The other company is deleted, and the kept one's **History** notes the merge. Changed your mind? **Unmerge** it from Manage duplicates → History. See [Duplicates](/help/duplicates). ## Segments and filters Segments and the contacts table can target contacts by their company: "Company industry is Events", "Company city is London", "Company owner is Sara", or any of your custom company properties. ## In emails `{{company.name}}`, `{{company.industry}}`, `{{company.city}}`, `{{company.domain}}` and `{{company.website}}` put the contact's company details in an email. `{{company}}` still works too. ## In imports Map columns to **Company**, **Company website or domain**, **Company industry** and **Company city**. Headers like "Website", "Domain" and "Industry" are matched for you. ## The same in the API In the API, companies are **accounts**: `list_accounts`, `get_account`, `add_account`, `update_account`, `delete_account` and `merge_accounts`. Contacts have an `account_id`: send it to `add_contact` or `update_contact` (or a `company` name), and filter `list_contacts` with `account_id`. Custom company properties are `list_properties` with `entity` `account`. See the [API reference](/docs/api). # Duplicates Find contacts and companies that are the same one, merge them, and undo a merge. **Manage duplicates** finds contacts and companies that look like the same one, so you can merge them. Open it from the **⋯** menu on the Contacts page or the button on the Companies page. ## How duplicates are found Choose **Scan for duplicates**. The scan runs in the background, and once you've scanned, it runs again every day. Two records are suggested when they have: - **Contacts**: the same email or the same phone number. - **Companies**: the same domain (from the domain or the website), or the same name. Free mail like gmail.com and sites like linkedin.com never count. - **Your own rules**: under **Duplicate rules**, pick up to 9 properties (any of yours, custom ones too). Records whose properties all have the same value, none empty, are suggested. Text ignores case and extra spaces. Owners and admins manage rules. A big workspace's scan stops at 50,000 records or 10,000 pairs; the page then says the results are partial. ## Reviewing a pair **Review** shows both records side by side. Pick the record to keep, then the value it keeps for each property: by default its own, or the other's where it has none. **Only properties that differ** hides the rest. **Merge** saves it; **Not a duplicate** moves the pair to **Rejected**, where **Restore** brings it back. To merge records the scan didn't suggest, choose **Merge with…** on a contact or company page and pick the other one. ## What a merge does The other record is deleted and everything linked to it moves to the one you keep: - **Contacts**: lists, tags, email topics, campaign emails and history move. The strictest status wins, so a contact who unsubscribed, bounced or complained is never emailed again because of a merge. A topic either one unsubscribed from stays unsubscribed. An opted-in contact's consent record is kept. - **Companies**: every contact moves to the company you keep, and segments and saved views that pick the other company use the kept one. In a business group, the record you keep stays shared, or takes the other's place when only the other was shared. Only a workspace that can edit shared records merges shared ones. ## Merging many at once Select pairs in **To review** and choose **Merge selected**, then which record each pair keeps: the oldest, the newest, the most recently updated, or the one with the most recent activity. Each pair keeps its record's values and fills the empty ones from the other. Pairs that share a record have to be reviewed one at a time. ## Undoing a merge **History** lists every merge, rejection and restore. **Unmerge** brings back the merged record and puts everything that moved back where it was. If something the merge changed was changed again since (the kept contact's email, say), the unmerge is refused and tells you what changed. ## In the API `list_duplicates`, `scan_duplicates` and `reject_duplicate` work with suggestions; `merge_contacts` and `merge_accounts` merge two records (`take` names the properties to take from the duplicate), and `unmerge_duplicates` undoes a merge. See the [API reference](/docs/api). # How contacts work Opted-in contacts and prospects, what each status means, and where the Contacts page keeps its actions. A **contact** is one email address in your workspace, with its name, company, tags, lists and your own **properties**. Each address is in a workspace once. ## Opted in or prospect - **Opted in**: they agreed to get your emails. Only they get campaigns. Zeebra keeps how they agreed (your note, the signup form, the import) on the contact. - **Prospect**: you have their address but not their agreement, for example someone from a list of business cards (or, once AI search launches, a lead saved from it). Prospects are kept and organised, and never get campaigns. ## Statuses - **Active**: can be emailed. - **Pending**: signed up through a form with confirmed opt-in and hasn't clicked the confirmation link yet. - **Dormant**: list cleanup asked if they still want your emails and they didn't answer. Kept, not emailed. - **Unsubscribed**, **bounced** or **complained**: never emailed again. Someone who unsubscribed stays on your do-not-email list even if you delete the contact. ## The Contacts page - **Import CSV** brings in a file from your old tool or a spreadsheet. See [Importing contacts](/help/importing-contacts). - **Add contact** adds one person by hand. It's a button while the workspace has only a few contacts, then it moves into the **⋯** menu. A contact you add joins straight away: Zeebra doesn't send them a confirmation email, so only add people as opted in when they really agreed. - The **⋯** menu also has **Export**, which downloads the contacts your search and filters show (the number is on the menu item), and **Tags, properties & do-not-email**, which opens Settings → Contacts. - **Manage duplicates** finds contacts that are the same person (the same email or phone, or your own rules) and merges them. See [Duplicates](/help/duplicates). Search and filter the table, save the view, and select contacts to tag them, add them to a list or change their status in bulk. Open a contact to see everything Zeebra knows about them and their activity. ## Tags, lists and segments - **Tags** label contacts (VIP, speaker, 2026 attendee). - **Lists** are fixed groups you add people to. - **Segments** are rules ("opted in, tagged VIP, opened in the last 90 days") worked out each time you use them. Campaigns go to lists and segments, minus your exclusions. See [Campaigns and sending rules](/help/campaigns). # Importing contacts Import a CSV from Mailchimp, HubSpot or a spreadsheet, and how Zeebra cleans it. Open **Contacts → Import** and upload a CSV file with a header row. Exports from Mailchimp, beehiiv, Substack, Kit, MailerLite and HubSpot are recognised as they are; any spreadsheet saved as CSV works too. The import page links a short guide to exporting your list from each of them: [beehiiv](/help/export-from-beehiiv), [Substack](/help/export-from-substack), [Mailchimp](/help/export-from-mailchimp), [Kit](/help/export-from-kit), [MailerLite](/help/export-from-mailerlite) and [HubSpot](/help/export-from-hubspot). ## 1. Match the columns Zeebra guesses which column is which (email, first and last name, company, job title, phone, city, status, tags). Check the guesses, skip columns you don't need, map a column to one of your own **properties**, or turn it into a new one. Values are checked against the property: a date that doesn't exist, text in a number, or a choice that isn't an option is left out. Only the email column is required. A **status** column from your old tool is respected: people marked unsubscribed, bounced or complained come in that way and are never emailed. ## 2. Say how these people agreed - **Opted in**: they asked for your emails (a signup form, a ticket purchase with a ticked box…). You confirm this and note how they agreed; the note is kept on every contact. - **Prospects**: you have their address but no agreement. They are kept and organised, but never get campaigns. Only import people as opted in when they really agreed. Sending to people who didn't is what gets email accounts suspended. ## 3. Add them to a list or tag them (optional) Choose a list (or create one) and tags for everyone in the file. ## What the import cleans Each address is checked, in this order: 1. **Not an address**: skipped. 2. **Twice in the file**: imported once. 3. **A typo in the domain** (`gmial.com`): kept, flagged, with a suggested fix you can accept in bulk. 4. **Role addresses** (`info@`, `sales@`): skipped unless you keep them. 5. **Throwaway inboxes**: skipped. 6. **Domains that can't receive email**: skipped. 7. **On your do-not-email list** (or Zeebra's global one): never added. When it finishes, the import shows how many were created, updated, skipped and flagged, and why. ## Contacts you already have An existing contact is updated with the file's non-empty values. Its status only ever gets stricter: an import can't bring back someone who unsubscribed, and an opted-in contact stays opted in. ## From code The API's `import_contacts` operation takes the CSV text and the same options; `get_import` shows its progress. See [API and MCP](/help/api-and-mcp). # The contact page What a contact's page shows: the History timeline, where their country comes from, their emails, and how to get the same from the API. Open any contact from the Contacts page to see everything your workspace knows about them, in one place. ## At the top - **Country**: where they are, with its flag. See [Country](#country) below. - **Inbox**: the domain of their address (gmail.com, their company's domain…), with its logo. - **Added**: when they joined your workspace. - **Emails**, **Opened** and **Clicked**: how many of your campaigns they got, and the share they opened and clicked. Only real opens and clicks count: machine opens (Apple Mail Privacy Protection, security scanners) and bot clicks are left out. ## History History is one timeline of everything that happened to the contact, **newest first**: - **Each campaign email** they got: when it was sent and delivered (or bounced), when they first opened and clicked it, the links they clicked, and whether it made them unsubscribe or mark it as spam. - **Every status change**: from what to what, when, who made it (a team member, the API, an import, an unsubscribe link, a bounce from Amazon SES, list cleanup…) and the note left with it. - **Opt-ins**, with how they agreed: your consent note, the signup form, or the import. - **List cleanup's** "still want these emails?" message and their answer. - **How they were added**: by hand, an import, a signup form, the API or saved from Zeebra's data. This history is kept for privacy and anti-spam questions (GDPR, CAN-SPAM, PDPL and similar laws): it answers "why did this person get this email?" and "when did they say stop?". The page shows the newest entries, with **Show older activity** below (up to 500); the API lists every email. ## Country Zeebra keeps a contact's country. It is taken from the country our network provider (Cloudflare) names for each visit in its country header, never from their IP address: we don't look the address up and we don't keep it. It's taken only from what the person's own browser does: 1. the **signup form** that adds them, 2. their **confirmation** click (only the owner of the address can do that, so it wins over the form), 3. otherwise their **first real click** in a campaign. Until one of those happens the country is "Not known yet". A country in an imported file is used when the contact has none, and never replaces one from their own signup, confirmation or click. Country is a property: filter the contacts table or a segment on it. ## The same in the API - `get_contact` returns the contact with its country, consent record, lists and its history (the newest 100 status changes, opt-ins and list cleanup emails). - `list_contact_emails` lists the campaign emails they got, newest first, a page at a time: the campaign, when it was sent, delivered or bounced, the first real open and click, the links clicked, and whether they unsubscribed or complained from it. See the [API reference](/docs/api) for the details, and [How contacts work](/help/contacts) for statuses and lead types. # Campaigns and sending rules Write, review, test and send a campaign, and the rules that protect your sending. A campaign is one email to an audience of opted-in contacts. Its page has three steps, shown at the top: **Write**, **Review** and **Send**. A draft saves as you type, and you can go back to any step. ## Write it The Write step holds the subject, the preview text (shown after the subject in inboxes), the design and the content. Start from a template or from scratch, in one of three formats: - **Visual editor**: text, images, buttons and columns. - **Markdown**: quick to write, also by hand or by an AI agent. - **HTML**: your own design. Zeebra cleans it and adds what's missing. Merge tags fill in each person's details: `{{first_name|there}}` writes their first name, or "there" when it's empty. Your own contact properties work as `{{custom.ticket_type}}` (the property's key, shown under Settings → Properties). In the visual editor, type `/` and choose **Contact fields** (or type the field's name after the `/`); the subject and preview text have an **Insert tag** menu. Add more fields under Settings → Properties. The footer with your postal address and the unsubscribe link is added for you. ## Test and review The Review step checks the campaign. Each problem has a button that takes you to what fixes it: - **Blocks** (must be fixed): no subject, no content, no unsubscribe link, larger than 100 KB (Gmail cuts off longer emails), no postal address, sender's domain not verified, nobody in the audience, sending paused for the workspace. - **Warnings** (can be accepted): a subject over 70 characters, no preview text, unknown merge tags, no test sent, images without alt text, `http://` links. What passed is listed under them. Send yourself a test from there (it goes to workspace members only), and open the preview on desktop, on a phone and in Gmail, as any of your contacts would get it. ### Tips for more opens Under the checklist, the Review step may show up to three **tips**: things in the saved draft that tend to send email to the spam folder or cost opens. Each one highlights the exact words it's about. They're only advice and never stop a send. They point out: - a subject in capital letters, or a run of `!!!` or `???`; - classic spam phrases such as "act now", "100% free", "risk-free" or "click here" (in English or Arabic); - a "Re:" or "Fwd:" on an email nobody wrote first; - link shorteners such as bit.ly; - an email that is mostly images with little text, or has a great many links; - no preview text, so inboxes show your first words instead. When the content check's AI is on, it may add a tip of its own about the wording. Most emails get none. The API and MCP give the same tips (`get_campaign_tips`). ## Where it goes The Send step starts with where the campaign goes. Besides email, a campaign can be posted on your **public page**: a web page listing your past campaigns, like a newsletter's website, that anyone can read. **Email only** (the default) sends it to your contacts' inboxes and nothing is public; **Email + public page** sends it and also posts it there; **Public page only** posts it without sending any email. The public page has no tracking or personal links, and merge tags show their fallbacks. ## Choose who gets it Choose who it's from, then pick lists and segments, and exclusions to take people out. Only opted-in contacts who can be emailed get it: prospects, unsubscribed, bounced and people on the do-not-email list are skipped. You can also send to only the most engaged people first and **send to more** later. The number of people who get it updates as you choose, with who is skipped and why. Choose a **topic** if the email belongs to one (newsletter, event invites): people who left the topic don't get it. ## Send or schedule The button stays off until the review has nothing left to fix. It shows how many people get the campaign, and you confirm that number (and accept any warnings) before it goes. If the audience changes before you send, you confirm again. Send now, or schedule up to a year ahead (in your workspace's timezone). The audience is worked out when sending starts, so people who join in between are included. A scheduled campaign can be unscheduled; a sending one can be paused, resumed or canceled. ## The rules that protect your sending Zeebra sends at a pace that keeps your domain's reputation, and stops by itself when something looks wrong: - **New contacts first get a slice.** People you have never emailed before are the riskiest. A first slice of them (5%, between 200 and 2,000) gets the email; the rest wait about three hours while bounces and complaints come in. - **Big sends are spread out.** A workspace that suddenly sends much more than before is spread over a few hours (at most 72). - **New domains warm up.** See [Sending domain and DNS](/help/sending-domain). - **Automatic pause.** A campaign pauses when over 2% of its emails bounce (at least 5) or over 0.08% are marked as spam (at least 2). You decide whether to resume. - **Automatic block.** Over 5% bounces (at least 10) or 0.3% complaints (at least 3) blocks the workspace's sending until our team reviews it. - **Gmail asks to slow down.** When Gmail rate limits your sending domain (it answers "421 4.7.28"), the campaigns sending from it pause for 6 hours and then go on by themselves. For the rest of that day and the next, the domain sends at most half of what it sent in the 24 hours before (at least 100 a day), then the usual pace comes back. The campaign page and the Send step say until when, and you get an email about it. Unsubscribes take effect at once, and people who bounce or complain are never emailed again. ## Results Each campaign shows delivered, opens, clicks, bounces, complaints and unsubscribes, its most clicked links, results by inbox provider and how it compares with your recent campaigns. Automatic opens (such as Apple Mail Privacy Protection) and link scanners are counted apart, so the numbers show real people. # Signup forms Collect opted-in contacts on your website with a hosted page or an embedded form. A signup form turns website visitors into opted-in contacts, with their consent recorded (when, where, and their confirmation). ## Create a form Open **Signup forms** and create one. Choose: - the headline, button text and whether to ask for a first name; - the **list** new people join, and the **topics** they sign up for; - **confirmed opt-in** (on by default): people get an email with a button to confirm, and only become subscribed once they press it. This keeps out typos, fake addresses and people signed up by someone else; - a **welcome email** sent once they are subscribed; - the **allowed sites** the form may appear on (see below). ## Put it on your site Each form has a hosted page you can link to, and a snippet that shows the form on any page: ```html ``` Copy the exact snippet from the form's page. ## Allowed sites List up to 10 sites (like `example.com`, which covers its subdomains) and the form only shows and takes signups on their pages: browsers refuse to show it inside any other site. The hosted page can't tell which site sent people to it, so with a list it is off, unless you turn on **Also take signups on the hosted page**. With no sites listed, both work anywhere. Every form also keeps scripts out: a hidden field only bots fill in, and a form sent less than 2 seconds after it appeared, more than a day after, or a second time is refused, with a note saying what to do. ## What happens when someone signs up - **New address**: a contact is created as opted in, pending until they confirm (with confirmed opt-in). - **Already subscribed**: nothing changes, and they aren't emailed again. - **Unsubscribed before**: they must confirm, even if the form doesn't ask for it. - **Bounced or reported spam**: refused. ## The check of your own emails When you save a form with your own confirmation or welcome email, our content check reads it, as it reads campaigns. Most pass straight away. If one needs a look from our team, the form shows **In review** next to it, and until it's approved new signups get the built-in confirmation email and no welcome email, so signups keep working. If our team doesn't approve it, you get an email saying why; change it and save to have it checked again. ## From your own code If your sign-up happens in your own app, call the API's `add_signup` operation with a **signups key**. That kind of key can only add signups, so it is safe in website code. See [API and MCP](/help/api-and-mcp). # API and MCP API keys, the REST API, and connecting AI agents (Claude, ChatGPT, Cursor) over MCP. Everything you do in Zeebra can also be done from code with the REST API, or by an AI agent over MCP (the Model Context Protocol). Both run the same operations, with the same checks and limits as the app. The [API reference](/docs/api) lists every operation. ## API keys Workspace owners and admins create keys in **Settings → API**. A workspace can have up to 20. A key is shown **once**, when you create it: store it somewhere safe. There are two kinds: - **Full access**: everything in the API reference. Keep it on your server. - **Signups only**: can only add signups (`add_signup`), so it is safe in website or app code. Revoke a key you no longer use; anything using it stops working at once. The API page shows when each key was last used. ## The REST API Send the key as a bearer token: ```bash curl https://zeebra.ai/api/v1/overview \ -H "Authorization: Bearer YOUR_KEY" ``` - Requests and answers are JSON. Results are in `data`; lists that come in pages also have `meta` (`page`, `per_page`, `total`, `last_page`). Ask for a page with `?page=2&per_page=50`. - Contacts can be filtered and sorted on any property, custom ones too (as `field:`): `/api/v1/contacts?filter[status]=active&filter[tag]=VIP&filter[field:plan]=is:Pro&sort=-created_at`. A filter's value is `operator:value` (`contains:acme`, `gte:10`, `between:1,5`, `in_period:last_30_days`, `is_set:`) or just a value. For AND/OR, send a rule tree in `where`, like a segment's. `get_segment_fields` lists the fields and operators; `export_contacts` takes the same filters. - Errors answer `{"error": {"code": "…", "message": "…"}}` with a matching status: `unauthorized` (401), `subscription_required` (402, the workspace needs a plan: sending, test emails, imports, exports, domains, new keys, webhooks and signups wait for one), `forbidden` (403), `not_found` (404), `conflict` (409, the campaign's state doesn't allow it), `validation_failed` (422, with `fields`), `rate_limited` (429, with `Retry-After`), `upstream_error` (502, Amazon SES or DNS). - Each key can make up to **300 requests a minute**. - The [OpenAPI document](/api/v1/openapi.json) describes every operation for code generators and API tools. ## Sending safely Sending can't be undone, so sending takes two steps: 1. `review_campaign` returns the checks and `audience.recipients`, the number of people it will go to. `checks` lists every check with its `id`, `level` (`block` or `warning`), whether it `passed`, and what to fix. 2. `send_campaign` (or `schedule_campaign`) takes that number as `confirm_recipients`. If the audience changed in between, it is refused and you review again. A campaign's `publish_to` says where it goes: `email` (the default), `both` (email, and your public pages once it goes out) or `web` (the public pages only). A web-only campaign sends no email, so its review has only the content checks and you send it with `confirm_recipients` set to `0`. ## AI agents over MCP The MCP server is at `https://zeebra.ai/mcp`. Every API operation is a tool there; the ones that send or delete are marked as destructive, so agents ask you before using them. **With an app that supports OAuth** (Claude, ChatGPT): add Zeebra as a connector with the MCP address. You sign in to Zeebra, choose which workspace the app may use (one you own or manage), and approve. Connected apps are listed in **Settings → API**, where you can disconnect them. **With an API key** (Claude Code, Cursor, Codex and other tools): give the MCP address and an `Authorization: Bearer YOUR_KEY` header. For example, in Claude Code: ```bash claude mcp add --transport http zeebra https://zeebra.ai/mcp \ --header "Authorization: Bearer YOUR_KEY" ``` ## Webhooks and the command line [Webhooks](/help/webhooks) send your server a signed request when contacts change and emails are delivered, opened, clicked, bounce or are marked as spam. The [zeebra CLI](/help/cli) runs every operation from a terminal or an AI agent's shell. ## For AI tools reading the docs [llms.txt](/llms.txt) lists these docs for language models, and [llms-full.txt](/llms-full.txt) has all of them, including the full API reference, in one file. # The zeebra CLI Use your workspace from the command line, or let an AI agent such as Claude Code or Codex use it, with one small script. `zeebra` is a small command-line tool for the [Zeebra API](/docs/api). Every API operation is a command, with the same checks and limits as the app. It suits scripts and AI coding agents (Claude Code, Codex, Cursor): they can run `zeebra ops` to see what's possible and `zeebra --help` to see how. ## Install It is one PHP file and needs PHP 8.2 or newer (macOS with Homebrew PHP, Linux, or Windows with WSL). ```bash curl -fsSL https://zeebra.ai/cli/zeebra -o zeebra chmod +x zeebra sudo mv zeebra /usr/local/bin/ # or anywhere on your PATH ``` ## Log in Create a **full access** API key in **Settings → API**, then: ```bash zeebra login --url https://zeebra.ai ``` It asks for the key, checks it, and saves both in `~/.config/zeebra/config.json`, readable only by you. `zeebra logout` removes them. In CI or for an agent you can set `ZEEBRA_URL` and `ZEEBRA_API_KEY` instead; they win over the saved login. ## Run operations ```bash zeebra ops # every operation, by section zeebra ops campaign # only the ones that mention "campaign" zeebra list_contacts --help # an operation's inputs zeebra list_contacts --filter status=active --filter tag=VIP --per-page 50 zeebra get_contact --id 1234 zeebra create_campaign --name "November news" --format markdown --markdown @news.md --subject "What's new" zeebra add_to_list --id 3 --contact-ids 12,13,14 zeebra create_webhook --json '{"url": "https://example.com/hooks/zeebra", "events": ["contact.unsubscribed"]}' ``` - Inputs are `--name value` (or `--name=value`); dashes and underscores both work (`--per-page`, `--per_page`). A bare `--flag` is `true`. - `@file` reads a value from a file (`--markdown @email.md`, `--csv @contacts.csv`); `@-` reads standard input. - Lists take commas, repeats or JSON: `--tags a,b`, `--tags a --tags b`, `--tags '["a","b"]'`. - Objects take `key=value` (repeat it) or JSON: `--filter status=active`, `--where '{"all": […]}'`. - `--json '{…}'` (or `--json @input.json`) gives all inputs at once; `--options` next to it win. - Operation names work with dashes too: `zeebra list-contacts`. ## Output In a terminal, results are a table; when piped (or for an agent) they are the API's JSON (`{"data": …, "meta": …}`). Choose with `-o json` or `-o table`. Lists in pages say how to get the next one (`--page 2`). ## Destructive operations Operations that can't be undone (deleting, sending a campaign) ask first in a terminal. Without a terminal they need `--yes`. An agent should show you what it is about to do and only then add `--yes`. Sending a campaign also needs `--confirm-recipients` with the count from `review_campaign`, as in the API. ## Exit codes | Code | Meaning | | ---- | ---------------------------------------------------------------------------------------- | | 0 | It worked. | | 1 | Another error, or you answered "no". | | 2 | The command was mistyped (an unknown input, a missing one, a wrong type). | | 3 | Not logged in, or the key was refused or can't do this (401, 403). | | 4 | Not found (404). | | 5 | Invalid input or a conflict, like a campaign that can't be sent in its state (422, 409). | | 6 | Too many requests: up to 300 a minute per key (429). | | 7 | Zeebra or the network had a problem (5xx, no connection). | Errors are printed to standard error as `Error (code): message`, with the inputs at fault. ## Staying up to date The list of operations comes from your Zeebra (`https://zeebra.ai/cli/manifest.json`) and is kept for an hour, so new operations show up without updating the CLI. `zeebra ops --refresh` fetches it now. If the CLI itself is too old for the server it says so; download it again with the same `curl` command. ## For AI agents Tell your agent to use `zeebra`, for example in `CLAUDE.md` or `AGENTS.md`: ```markdown Use the `zeebra` CLI for our email platform. Run `zeebra ops` to find an operation and `zeebra --help` for its inputs. Output is JSON. Never add --yes or send a campaign without asking me first. ``` Agents that speak MCP can also connect to the [MCP server](/help/api-and-mcp) instead. # Webhooks Get a signed request on your server when contacts change and emails are delivered, opened, clicked, bounce or are marked as spam. Webhooks let your own app follow what happens in Zeebra without asking: when a contact unsubscribes, Zeebra sends your server a request at once, so your records stay in step. Zeebra stays the one place that decides who may be emailed. ## Add an endpoint Workspace owners and admins add endpoints in **Settings → Webhooks** (or with `create_webhook` in the API). An endpoint is an `https://` URL on your server and the events it wants. A workspace can have up to 10. When you add one, Zeebra shows its **signing secret** (`whsec_…`) **once**. Store it with your app's other secrets. **New signing secret** makes another; for 24 hours after that, deliveries are signed with both, so you can switch without missing an event. **Send a test event** sends a `webhook.test` event straight away and shows what your server answered. Each endpoint has a **delivery log** of its last 100 deliveries: the event, your server's status code and answer, how long it took and which attempt it was, with **Resend**. ## Events | Event | When | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `contact.created` | A contact was added: by hand, an import, a signup form or the API. | | `contact.updated` | A contact's details or status changed (`changed` lists what). | | `contact.unsubscribed` | A contact unsubscribed from all email (`previous_status`, `via`). | | `contact.opted_in` | A contact agreed to get email: a signup, an import, or marked by your team (`via`). | | `email.delivered` | A campaign email reached the recipient's mail server. | | `email.opened` | A recipient opened a campaign email for the first time. Machine opens (Apple Mail Privacy Protection, scanners) never count. | | `email.clicked` | A recipient clicked a link in a campaign email for the first time (`url`). Bot clicks never count. | | `email.bounced` | An email bounced (`bounce.type` is `permanent`, `transient` or `undetermined`, with the receiving server's `diagnostic`). Only permanent bounces stop the contact. | | `email.complained` | A recipient marked an email as spam. | | `campaign.sent` | A campaign finished sending, or was published on your public pages only. | Choose **Every event** to get all of them, including events added later. Replies aren't an event: replies go straight to your sender's own mailbox, so Zeebra never sees them. ## What Zeebra sends A `POST` with a JSON body: ```json { "id": "evt_01jd2v7k3m9q8x4r5t6y7z8a9b", "type": "contact.unsubscribed", "created_at": "2026-11-05T09:30:00+00:00", "workspace_id": 7, "data": { "contact": { "id": 1234, "email": "sara@example.com", "status": "unsubscribed", "…": "…" }, "previous_status": "active", "via": "unsubscribe_link" } } ``` `data` uses the same shapes as the API: a `contact` as `get_contact` returns it, a `campaign` as `get_campaign` does (without its content), and an `email` like `list_contact_emails` (with `to` and its `campaign`), plus a `contact` when Zeebra knows them. The headers: - `Zeebra-Signature: t=1762335000,v1=5257a869…` (see below) - `Zeebra-Event-Id` and `Zeebra-Event-Type`: the event's `id` and `type` - `Zeebra-Delivery-Id`: this delivery - `User-Agent: Zeebra-Webhooks/1.0` Answer with any `2xx` status within **10 seconds**. Do slow work after answering (on a queue). Zeebra doesn't follow redirects. ## Check the signature `v1` is the HMAC-SHA256, in hex, of the timestamp `t`, a dot, and the raw request body, keyed with your signing secret. To check a request: 1. Read `t` and every `v1` from the `Zeebra-Signature` header. 2. Compute `HMAC-SHA256(secret, t + "." + raw_body)` in hex. 3. Accept the request when it equals one of the `v1` values (compare in constant time) and `t` is within 5 minutes of now. Use the raw body exactly as it arrived, before any JSON parsing. PHP: ```php function zeebraSignatureIsValid(string $header, string $body, string $secret): bool { preg_match('/(?:^|,)\s*t=(\d+)/', $header, $time); preg_match_all('/v1=([a-f0-9]{64})/', $header, $signatures); $timestamp = (int) ($time[1] ?? 0); $expected = hash_hmac('sha256', $timestamp.'.'.$body, $secret); if (abs(time() - $timestamp) > 300) { return false; } foreach ($signatures[1] as $signature) { if (hash_equals($expected, $signature)) { return true; } } return false; } ``` Node.js: ```js import crypto from 'node:crypto'; function zeebraSignatureIsValid(header, rawBody, secret) { const parts = header.split(',').map((part) => part.trim().split('=')); const timestamp = Number(parts.find(([key]) => key === 't')?.[1]); const signatures = parts .filter(([key]) => key === 'v1') .map(([, value]) => value); const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex'); if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) return false; return signatures.some( (signature) => signature.length === expected.length && crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expected), ), ); } ``` ## Retries and switching off A delivery that doesn't get a `2xx` (an error status, no answer within 10 seconds, or no connection) is tried again after **1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours**, six attempts in all. The delivery log says when the next try is. The same event can arrive more than once (a retry after your server answered too late, or a **Resend**). Use the event's `id` to handle each event once. Events can also arrive out of order; use `created_at` and the record's own dates. An endpoint that fails **20 times in a row over at least a day** is switched off, and the workspace's owners and admins get an email. Events that happen while it is off aren't sent. Fix your server, send a test event, then switch it back on (this clears its failures). ## Safety Webhooks only go to public `https://` addresses. Zeebra looks up your host before every delivery and refuses private, local and reserved addresses, so an endpoint can't reach a private network. ## With the API `list_webhook_event_types`, `list_webhooks`, `get_webhook`, `create_webhook`, `update_webhook`, `delete_webhook`, `rotate_webhook_secret`, `test_webhook`, `list_webhook_deliveries` and `resend_webhook_delivery` do the same from code, the [CLI](/help/cli) or an AI agent. See the [API reference](/docs/api#create_webhook). # Export your contacts from HubSpot Download your HubSpot contacts as a CSV and bring them into Zeebra. 1. In HubSpot, open **CRM**, then [Contacts](https://app.hubspot.com/l/contacts/objects/0-1). 2. Open the view for the people to move (**All contacts** for everyone), then click **Export** at the top right of the table. 3. Choose **CSV** as the file format. The export only has the columns in the view; click **Customize** to include every property. Then click **Export**. 4. HubSpot emails you a download link (it works for 30 days). Big files come zipped, so unzip them first. 5. In Zeebra, open [Contacts → Import](/contacts/import) and drop the CSV. Zeebra matches the email, names, company, job title, phone and marketing contact status for you; map the other properties you want to keep to your own. A HubSpot contact hasn't always agreed to your marketing emails. Import people as opted in only when they did, and the others as prospects. Include the **Unsubscribed from all email** property in the export: people for whom it's **true** come in unsubscribed and are never emailed. See [Importing contacts](/help/importing-contacts) for the next steps. # Export your list from Kit Download your Kit (formerly ConvertKit) subscribers as a CSV and bring them into Zeebra. 1. In Kit, open [Subscribers](https://app.kit.com/subscribers). 2. Click **Status** and choose **Confirmed** for your active subscribers, or **All** to bring everyone with their status. 3. Tick the checkbox at the top of the list, then click **Select all** so every page is included. 4. Click the three dots (**⋮**), then **Export**. Kit emails a download link to the account owner; it's also in your notifications in Kit. The link works for five days. 5. In Zeebra, open [Contacts → Import](/contacts/import) and drop the CSV. Zeebra matches the email, first name, status and tags for you; map the custom fields you want to keep to your own properties. ## People who unsubscribed Kit's export has a status column. Zeebra reads unsubscribed (**cancelled**), **bounced** and **complained** people from it, so they are never emailed. If you only exported confirmed subscribers, export the others the same way and paste their addresses into your do-not-email list (**Settings → Contacts**). See [Importing contacts](/help/importing-contacts) for the next steps. # Export your list from Mailchimp Download your Mailchimp audience as CSV files and bring them into Zeebra. 1. In Mailchimp, open [Audience](https://admin.mailchimp.com/audience/contacts/). If you have more than one audience, pick the one to move from the **Audience** drop-down. 2. Click **Export audience**, then **Export CSV**. Mailchimp emails you when the export is done. You can also download it from **More options → Export history** for 30 days. 3. The download is a ZIP file with one CSV per group: subscribed, unsubscribed, non-subscribed and cleaned. Unzip it. In Zeebra, open [Contacts → Import](/contacts/import) and import the **subscribed** file. Zeebra matches the email, names and tags for you; map the merge fields you want to keep to your own properties. ## People who unsubscribed or bounced Mailchimp's files have no status column: the file name is the status. Don't import the **unsubscribed** and **cleaned** files as they are. Paste their addresses into your do-not-email list instead (**Settings → Contacts**), so nobody on them is ever emailed, even if they turn up in a later import. See [Importing contacts](/help/importing-contacts) for the next steps. # Export your list from MailerLite Download your MailerLite subscribers as a CSV and bring them into Zeebra. 1. In MailerLite, open [Subscribers](https://dashboard.mailerlite.com/subscribers). Only account admins can export. 2. Keep the dropdown on **Active subscribers**. Use the arrow next to the **Subscriber** column to select everyone in the list. 3. Click **Set columns** and turn on the fields you want to keep (and **Groups**). The export only has the columns you can see. 4. Click **Actions** and choose the CSV export. Download the file when it's ready. 5. In Zeebra, open [Contacts → Import](/contacts/import) and drop the CSV. Zeebra matches the email (MailerLite's **Subscriber** column), names and other common columns for you; map the fields you want to keep to your own properties. ## People who unsubscribed or bounced Use the same dropdown to show your **unsubscribed** and **bounced** subscribers and those who sent **spam complaints**, and export each list. Paste their addresses into your do-not-email list (**Settings → Contacts**), so nobody on them is ever emailed. See [Importing contacts](/help/importing-contacts) for the next steps. # Export your list from Substack Download your Substack subscribers as a CSV and bring them into Zeebra. 1. In [Substack](https://substack.com), open your publication's dashboard, then **Subscribers**. 2. Click **Export**. If you have filters on, choose your whole list. Substack prepares a CSV of your subscribers, free and paid. 3. When it's ready, click **Download last export**. 4. In Zeebra, open [Contacts → Import](/contacts/import) and drop the CSV. You can also get the list from your publication's **Settings**, under **Import/Export**: a new export there is a ZIP with your posts and a subscriber CSV inside. Substack's **active_subscription** column says whether someone **pays**, not whether they're subscribed, so Zeebra leaves it out: free and paid readers both come in as subscribed. Map it to a property if you want to keep who pays. The **email_disabled** column is **true** for readers who turned Substack's emails off. Zeebra reads it: they come in unsubscribed and are never emailed. Paid plans stay with Substack: Zeebra brings in the people and the columns you map. See [Importing contacts](/help/importing-contacts) for the next steps. # Export your list from beehiiv Download your beehiiv subscribers as a CSV and bring them into Zeebra. 1. In beehiiv, open **Settings**, then [Export Data](https://app.beehiiv.com/settings/publication/export_data). If you have more than one publication, pick it first. 2. Under **Export Subscribers**, click **Export All Subscribers**. That's the full export, with custom fields and stats. **Export All Basic Subscribers** only has the email, status and tier. 3. beehiiv emails you a download link when the file is ready. It's also under **Historical Exports** on the same page. The link works for 24 hours; after that, export again. 4. In Zeebra, open [Contacts → Import](/contacts/import) and drop the CSV. Zeebra matches the email, names, status and tags for you; map the custom fields you want to keep to your own properties. beehiiv's status column comes along: people it shows as **inactive** come in unsubscribed and are never emailed, and **pending** people (who never confirmed) come in pending. See [Importing contacts](/help/importing-contacts) for the next steps. # API reference Base URL: https://zeebra.ai/api/v1. Authenticate with a workspace API key: `Authorization: Bearer `. Up to 300 requests a minute per key. Results are in `data` (lists in pages also have `meta`); errors answer `{"error": {"code", "message"}}`. The same operations are tools of the MCP server at https://zeebra.ai/mcp. OpenAPI: https://zeebra.ai/api/v1/openapi.json ## Workspace ### get_overview: Get the overview `GET /api/v1/overview` The workspace at a glance: contacts by lead type and status, lists, segments, campaigns by status, email results of the last 30 days, and whether it can send. A good first call. ## Contacts ### list_contacts: List contacts `GET /api/v1/contacts` Lists the workspace's contacts, newest first, a page at a time. Filter on any filterable property, custom ones included (filter, or a rule tree in where, like segments), and sort on any sortable property (prefix with - for descending). - `filter` (object, query): Property key => value. Keys: q (search: every word in a name, email, company or searchable property), email, tag (name), list_id, account_id (their company, see list_accounts), segment_id, created_after (date), or any filterable property key (get_segment_fields lists them; custom ones are field:). A value is "operator:value" with the property's operators (contains:acme, gte:10, between:1,5, in_period:last_30_days, within_last:7, is_set:), or just a value: is for text, in for choices (comma separated), eq for numbers, has_any for multiple choice, true or false for yes-or-no. - `where` (object, query): A rule tree like a segment's, for AND/OR: {"match": "all"|"any", "rules": [{"field": "city", "op": "is", "value": "London"}, {"match": "any", "rules": [...]}]}. Applied with filter. - `sort` (string, query): Up to 3 sortable property keys, comma separated, - for descending: -created_at (the default), email, field:plan,-last_engaged_at. - `page` (integer, query): Page number, from 1. - `per_page` (integer, query): Items per page, up to 100. Returns: list of Contact (paginated). ### get_contact: Get a contact `GET /api/v1/contacts/{id}` Gets one contact with its tags, custom property values, country, consent record, the ids of the lists it is on, and its history: every status change with who made it and how (the newest 100), opt-ins with their consent, and list cleanup's emails. list_contact_emails has the emails it got. - `id` (integer, path, required): The contact's id. Returns: Contact. ### list_contact_emails: List a contact's emails `GET /api/v1/contacts/{id}/emails` Lists the campaign emails a contact got, newest first, a page at a time: each with its campaign, when it was sent and delivered or bounced, their first real open and click (machine opens never count), the links they clicked, and whether they unsubscribed or marked it as spam from it. - `id` (integer, path, required): The contact's id. - `page` (integer, query): Page number, from 1. - `per_page` (integer, query): Items per page, up to 100. Returns: list of ContactEmail (paginated). ### add_contact: Add a contact `POST /api/v1/contacts` Adds one contact. Opted-in contacts get campaigns and need a consent note saying how they agreed; prospects never get campaigns. Refuses an address that already exists or is on a do-not-email list. Use add_signup for people signing up on a website. - `email` (string, body, required) - `first_name` (string, body) - `last_name` (string, body) - `title` (string, body) - `company` (string, body): Their company's name: they join the account of that name, made if there is none. Left out, a work email's domain finds or makes it. - `account_id` (integer, body): Their company: an account of the workspace (list_accounts). Wins over company. - `phone` (string, body) - `city` (string, body) - `lead_type` (string, body): one of opted_in, prospect. opted_in: agreed to get your emails. prospect: not yet (the default). - `consent_note` (string, body): Required for opted_in: how they agreed, e.g. "Signed up at our booth, 3 May". - `fields` (object, body): Custom property values by key (see list_properties), checked against each property's type and rules. Required properties need a value. - `tags` (array, body): Tag names; new ones are created. - `list_ids` (array, body): Lists to add the contact to. Returns: Contact. ### update_contact: Update a contact `PATCH /api/v1/contacts/{id}` Changes a contact's name, company (account_id, or a company name) and other properties. Only what you send changes; in fields, a null value clears that custom property. The email address, lead type and status have their own rules and can't be changed here. A contact shared with the business group changes in every workspace of it; in a workspace with view access its shared fields (names, company, job title, phone, city) are refused. - `id` (integer, path, required): The contact's id. - `first_name` (string or null, body) - `last_name` (string or null, body) - `title` (string or null, body) - `company` (string or null, body): Their company's name: they join the account of that name (made if there is none); null takes them out of theirs. - `account_id` (integer or null, body): Their company: an account of the workspace (list_accounts), or null for none. Wins over company. - `phone` (string or null, body) - `city` (string or null, body) - `fields` (object, body): Custom property values by key; null clears one. Returns: Contact. ### change_contact_status: Change a contact's status `POST /api/v1/contacts/{id}/status` Sets a contact's status. Unsubscribed, bounced and complained are final: the address goes on the do-not-email list and only the person can come back by signing up again. - `id` (integer, path, required): The contact's id. - `status` (string, body, required): one of active, pending, dormant, unsubscribed, bounced, complained - `note` (string, body): Why, kept in the contact's history. Returns: Contact. ### tag_contacts: Tag contacts `POST /api/v1/contacts/tag` Adds a tag to up to 1,000 contacts. The tag is created if it doesn't exist (names match without case). - `contact_ids` (array, body, required) - `tag` (string, body, required) ### untag_contacts: Untag contacts `POST /api/v1/contacts/untag` Takes a tag off up to 1,000 contacts. The tag itself stays (delete_tag removes it everywhere). - `contact_ids` (array, body, required) - `tag` (string, body, required) ### delete_contact: Delete a contact `DELETE /api/v1/contacts/{id}` (destructive: confirm with the user first) Deletes a contact and its history. If they had unsubscribed, bounced or complained, the address stays on the do-not-email list. - `id` (integer, path, required): The contact's id. ### import_contacts: Import contacts from CSV `POST /api/v1/contacts/imports` Imports a CSV (with a header row) in the background, cleaning addresses like the import page: typos flagged, role, throwaway and undeliverable addresses skipped, suppressed ones never added. Columns are matched by their headers unless you give a mapping. Existing contacts are updated, and their status only gets stricter. Check progress with get_import. - `csv` (string, body, required): The file's text, up to 10 MB. - `mapping` (object, body): Header => email, first_name, last_name, name, title, company, company_domain, company_industry, company_city, phone, city, country, status, unsubscribed, tags, field:, or null to skip. Left out: guessed from the headers. - `lead_type` (string, body, required): one of opted_in, prospect - `consent_confirmed` (boolean, body): Required true for opted_in: you confirm these people agreed to get your emails. - `consent_note` (string, body): How they agreed (kept on each contact). - `list_id` (integer, body): Add everyone imported to this list. - `tag_ids` (array, body): Tag everyone imported. - `keep_role_addresses` (boolean, body): Keep info@, sales@… addresses (skipped by default). - `file_name` (string, body): A name for the import, shown on the import page. Returns: Import. ### get_import: Get an import `GET /api/v1/contacts/imports/{id}` Shows an import's progress and report: created, updated, skipped and flagged counts by reason. - `id` (integer, path, required): The import's id. Returns: Import. ### export_contacts: Export contacts as CSV `GET /api/v1/contacts/export` Returns the contacts matching the filters (as in list_contacts) as CSV text that import_contacts reads back, up to 50,000 rows. Emails and phones found through Zeebra are left out where their licence forbids export. - `filter` (object, query): The same filters as list_contacts. - `where` (object, query): The same rule tree as list_contacts. ## Accounts ### list_accounts: List accounts `GET /api/v1/accounts` Lists the workspace's companies (accounts), by name, a page at a time, each with how many contacts it has. Filter on any filterable account property, custom ones included, like list_contacts (filter, or a rule tree in where), and sort on any sortable one. list_contacts with filter account_id lists an account's contacts. - `q` (string, query): Search: every word in the name, domain or a searchable property. - `filter` (object, query): Property key => value, as in list_contacts: name, domain, industry, city, country, owner_id, employees, contacts_count, created_at, field:… A value is "operator:value" (contains:tech, gte:10, is_set:) or just a value. - `where` (object, query): A rule tree like a segment's, for AND/OR, on account properties. - `sort` (string, query): Up to 3 sortable property keys, comma separated, - for descending: name (the default), -contacts_count, -created_at. - `page` (integer, query): Page number, from 1. - `per_page` (integer, query): Items per page, up to 100. Returns: list of Account (paginated). ### get_account: Get an account `GET /api/v1/accounts/{id}` Gets one of the workspace's companies with its custom property values and how many contacts it has. - `id` (integer, path, required): The account's id. Returns: Account. ### add_account: Add an account `POST /api/v1/accounts` Adds a company to the workspace. Its domain is cleaned (https://www.acme.com/about becomes acme.com), must be the company's own (not gmail.com or a social profile) and one account's only; a website fills in a missing domain. Contacts added later with a work email at the domain join it. - `name` (string, body, required) - `domain` (string, body) - `website` (string, body) - `industry` (string, body) - `city` (string, body) - `country` (string, body): A country code (SA) or name. - `phone` (string, body): Without a "+", read as a number of the workspace's default country. - `employees` (integer, body) - `description` (string, body) - `owner_id` (integer, body): A team member's user id. Defaults to the API key's member, when it has one. - `fields` (object, body): Custom property values by key (list_properties with entity account). Required properties need a value. Returns: Account. ### update_account: Update an account `PATCH /api/v1/accounts/{id}` Changes a company's properties. Only what you send changes; null clears a property (not the name). Renaming it renames it on its contacts (their company). - `id` (integer, path, required): The account's id. - `name` (string, body) - `domain` (string or null, body) - `website` (string or null, body) - `industry` (string or null, body) - `city` (string or null, body) - `country` (string or null, body): A country code (SA) or name. - `phone` (string or null, body): Without a "+", read as a number of the workspace's default country. - `employees` (integer or null, body) - `description` (string or null, body) - `owner_id` (integer or null, body): A team member's user id. - `fields` (object, body): Custom property values by key (list_properties with entity account); null clears one. Returns: Account. ### delete_account: Delete an account `DELETE /api/v1/accounts/{id}` (destructive: confirm with the user first) Deletes a company. Its contacts are kept, without a company link (their company name stays). - `id` (integer, path, required): The account's id. ## Duplicates ### list_duplicates: List duplicate suggestions `GET /api/v1/duplicates` Lists pairs of contacts or accounts that look like the same one, newest first, with both records' values and why they matched (email, phone, domain, name, rule:, manual). Pairs come from scans: scan_duplicates runs one, and a workspace that scanned is scanned again daily. - `type` (string, query): one of contact, account. contact or account. - `status` (string, query): one of pending, rejected, merged - `page` (integer, query): Page number, from 1. - `per_page` (integer, query): Items per page, up to 100. ### scan_duplicates: Scan for duplicates `POST /api/v1/duplicates/scan` Starts a scan of the workspace's contacts or accounts for duplicates in the background; list_duplicates shows what it finds. - `type` (string, body): one of contact, account. contact or account. ### reject_duplicate: Reject a duplicate suggestion `POST /api/v1/duplicates/{id}/reject` Marks a suggested pair as not duplicates: it leaves the list and scans don't suggest it again. - `id` (integer, path, required): The suggestion's id (list_duplicates). ### merge_contacts: Merge two contacts `POST /api/v1/contacts/{id}/merge` (destructive: confirm with the user first) Merges a duplicate contact (id) into another (into_id, the one kept) and deletes the duplicate. The kept contact keeps its values, and takes the duplicate's where it has none or where you name the property in take. Lists, tags, topics, campaign emails and history move to it. The strictest status wins (unsubscribed, bounced or complained is never undone), and an opted-in contact's consent is kept. unmerge_duplicates undoes it. - `id` (integer, path, required): The duplicate: merged into the other, then deleted. - `into_id` (integer, body, required): The record kept. - `take` (array, body): Property keys whose value comes from the duplicate even where the kept record has one ("phone", "field:plan"). Returns: Contact. ### merge_accounts: Merge two accounts `POST /api/v1/accounts/{id}/merge` (destructive: confirm with the user first) Merges a duplicate company (id) into another (into_id, the one kept) and deletes the duplicate. The kept account keeps its values, and takes the duplicate's where it has none or where you name the property in take. Its contacts move to the kept account, and segments filtering on the duplicate's id use the kept one. Shared with a business group: the kept account keeps its shared link, or takes the duplicate's. unmerge_duplicates undoes it. - `id` (integer, path, required): The duplicate: merged into the other, then deleted. - `into_id` (integer, body, required): The record kept. - `take` (array, body): Property keys whose value comes from the duplicate even where the kept record has one ("phone", "field:plan"). Returns: Account. ### unmerge_duplicates: Undo a merge `POST /api/v1/duplicates/merges/{id}/unmerge` Undoes a merge (its merge_id from merge_contacts or merge_accounts): the merged record comes back and everything that moved goes back. Refused, with the reason, when something the merge changed was changed again since. - `id` (integer, path, required): The merge's merge_id. ## Business groups ### get_business_group: Get the business group `GET /api/v1/business-group` Gets the business group the workspace is in (business_group, null when it isn't in one): its name, this workspace's access (edit: changes to shared records reach every workspace; view: their common fields can't be changed here), whether new contacts and companies are shared at once, the names of its workspaces, and how many contacts and companies it shares. ### share_contact: Share a contact `POST /api/v1/contacts/{id}/share` Shares a contact (and its company) with every workspace of the business group. A workspace that has a contact with the same email gets it linked, not copied; the others get a copy. The copies keep one profile (name, email, phone, job title, company, city, country) and one consent: opted in anywhere is opted in everywhere, and unsubscribing, bouncing or complaining anywhere applies everywhere. Tags, lists, notes and custom properties stay each workspace's own. - `id` (integer, path, required): The contact's id. Returns: Contact. ### unshare_contact: Stop sharing a contact `DELETE /api/v1/contacts/{id}/share` Stops sharing this workspace's copy of a contact: it stays here as a plain contact, and the other workspaces keep theirs. Nothing is deleted. - `id` (integer, path, required): The contact's id. Returns: Contact. ### share_account: Share an account `POST /api/v1/accounts/{id}/share` Shares a company (account) with every workspace of the business group. A workspace with a company of the same domain (else the same name) gets it linked; the others get a copy. The copies keep one name, domain, website, industry, size, phone and location; owner, notes and custom properties stay each workspace's own. - `id` (integer, path, required): The account's id. Returns: Account. ### unshare_account: Stop sharing an account `DELETE /api/v1/accounts/{id}/share` Stops sharing this workspace's copy of a company: it stays here as a plain company. Nothing is deleted. - `id` (integer, path, required): The account's id. Returns: Account. ## Tags ### list_tags: List tags `GET /api/v1/tags` Lists the workspace's tags with how many contacts have each. Returns: list of Tag. ### create_tag: Create a tag `POST /api/v1/tags` Creates a tag. tag_contacts also creates tags as it goes. - `name` (string, body, required) - `color` (string, body): one of zinc, red, amber, green, blue, violet, pink. Defaults to one picked from the name. Returns: Tag. ### update_tag: Rename or recolour a tag `PATCH /api/v1/tags/{id}` Changes a tag's name or colour. - `id` (integer, path, required): The tag's id. - `name` (string, body) - `color` (string, body): one of zinc, red, amber, green, blue, violet, pink Returns: Tag. ### delete_tag: Delete a tag `DELETE /api/v1/tags/{id}` (destructive: confirm with the user first) Deletes a tag and takes it off every contact. The contacts stay. - `id` (integer, path, required): The tag's id. ## Properties ### list_properties: List properties `GET /api/v1/properties` Lists everything a contact (or an account, one of your companies) has, in order: system properties (email, first_name, status, tags…) and the workspace's custom ones, keyed "field:". A custom property's is what contacts' fields use, and {{custom.}} puts its value in an email. Each says its type, rules, options and segment operators. - `entity` (string, query): one of contact, account. contact (the default) or account: your companies (the Companies page; see list_accounts). - `include_archived` (boolean, query): Also list archived custom properties. Returns: list of Property. ### create_property: Create a property `POST /api/v1/properties` Adds a custom property. Its key is made from the label ("Ticket type" becomes ticket_type) unless you give one, and never changes. Select and multi_select properties need their options. - `entity` (string, body): one of contact, account. contact (the default) or account: your companies (the Companies page; see list_accounts). - `label` (string, body, required) - `type` (string, body, required): one of text, long_text, number, currency, date, select, multi_select, boolean, email, phone, url, country, user - `key` (string, body): Lowercase letters, digits and underscores. Defaults to one made from the label. - `description` (string, body) - `options` (array, body): Choices of a select or multi_select property: {label, color?}, and the value when changing an existing option (values never change, labels can). - `settings` (object, body): Validation by type. text: min_length, max_length, regex. long_text: min_length, max_length. number: min, max, decimals. currency: currency (like EUR; default the billing currency), min, max, decimals. date: date_rule (any, past, future, range), date_from, date_to. multi_select: min_selections, max_selections. - `group` (string or null, body): A group key from list_properties (contact_info, work, other…). Left out on create: "other". - `required` (boolean, body): Contacts can't be added or saved without it (imports and signups skip it). - `searchable` (boolean, body): Text types only: the contacts search matches it. Returns: Property. ### update_property: Update a property `PATCH /api/v1/properties/{id}` Changes a custom property's label, description, options, rules, group or flags. Its key and type stay, so stored values, segments and merge tags keep working. System properties: use update_system_property. - `id` (integer, path, required): The custom property's id (from list_properties). - `label` (string, body) - `description` (string or null, body) - `options` (array, body): Choices of a select or multi_select property: {label, color?}, and the value when changing an existing option (values never change, labels can). - `settings` (object, body): Validation by type. text: min_length, max_length, regex. long_text: min_length, max_length. number: min, max, decimals. currency: currency (like EUR; default the billing currency), min, max, decimals. date: date_rule (any, past, future, range), date_from, date_to. multi_select: min_selections, max_selections. - `group` (string or null, body): A group key from list_properties (contact_info, work, other…). Left out on create: "other". - `required` (boolean, body) - `searchable` (boolean, body) Returns: Property. ### archive_property: Archive a property `POST /api/v1/properties/{id}/archive` Hides a custom property everywhere. Its values stay on records and come back with restore_property; segments using it keep working. - `id` (integer, path, required): The custom property's id (from list_properties). Returns: Property. ### restore_property: Restore a property `POST /api/v1/properties/{id}/restore` Brings an archived custom property back, with its values. - `id` (integer, path, required): The custom property's id (from list_properties). Returns: Property. ### update_system_property: Change a system property `PATCH /api/v1/properties/system/{key}` Changes how a system property (first_name, company, city…) shows in this workspace: its label (null: the default), description, group, whether it is required and whether record pages hide it. Never its type. - `key` (string, path, required): The system property's key, like company. - `entity` (string, body): one of contact, account. contact (the default) or account: your companies (the Companies page; see list_accounts). - `label` (string or null, body) - `description` (string or null, body) - `group` (string or null, body): A group key from list_properties (contact_info, work, other…). Left out on create: "other". - `required` (boolean, body) - `hidden` (boolean, body) Returns: Property. ## Topics ### list_topics: List topics `GET /api/v1/topics` Lists the kinds of email people can unsubscribe from on their own (newsletter, event invites…). Returns: list of Topic. ### create_topic: Create a topic `POST /api/v1/topics` Adds a topic. Contacts are in it unless they leave, or only once they join when default_subscribed is false. - `name` (string, body, required) - `description` (string, body) - `default_subscribed` (boolean, body) Returns: Topic. ### update_topic: Update a topic `PATCH /api/v1/topics/{id}` Changes a topic's name, description or default. - `id` (integer, path, required): The topic's id. - `name` (string, body) - `description` (string or null, body) - `default_subscribed` (boolean, body) Returns: Topic. ### delete_topic: Delete a topic `DELETE /api/v1/topics/{id}` (destructive: confirm with the user first) Deletes a topic and people's choices for it. Templates and campaigns that used it go to everyone again. - `id` (integer, path, required): The topic's id. ## Suppressions ### list_suppressions: List suppressions `GET /api/v1/suppressions` Lists the workspace's do-not-email list, newest first: unsubscribes, bounces, complaints and addresses added by hand. - `q` (string, query): Part of an address. - `reason` (string, query): one of bounce, complaint, unsubscribe, manual - `page` (integer, query): Page number, from 1. - `per_page` (integer, query): Items per page, up to 100. Returns: list of Suppression (paginated). ### add_suppressions: Add to the do-not-email list `POST /api/v1/suppressions` Adds up to 1,000 addresses to the do-not-email list. Contacts with these addresses are unsubscribed. Invalid addresses are skipped. - `emails` (array, body, required) - `note` (string, body) ### remove_suppression: Remove from the do-not-email list `DELETE /api/v1/suppressions/{id}` (destructive: confirm with the user first) Removes an address added by hand. Unsubscribes, bounces and complaints can't be removed: the person has to sign up again. - `id` (integer, path, required): The suppression's id. ## Lists ### list_lists: List lists `GET /api/v1/lists` Lists the workspace's contact lists with how many contacts each has. list_contacts with filter list_id shows who is on one. Returns: list of List. ### get_list: Get a list `GET /api/v1/lists/{id}` Gets one list and its contact count. - `id` (integer, path, required): The list's id. Returns: List. ### create_list: Create a list `POST /api/v1/lists` Creates an empty list. Add contacts with add_to_list. - `name` (string, body, required) Returns: List. ### update_list: Rename a list `PATCH /api/v1/lists/{id}` Renames a list. - `id` (integer, path, required): The list's id. - `name` (string, body, required) Returns: List. ### delete_list: Delete a list `DELETE /api/v1/lists/{id}` (destructive: confirm with the user first) Deletes a list. Its contacts are kept. - `id` (integer, path, required): The list's id. ### add_to_list: Add contacts to a list `POST /api/v1/lists/{id}/contacts` Adds up to 1,000 of the workspace's contacts to a list. Contacts already on it are left as they are. - `id` (integer, path, required): The list's id. - `contact_ids` (array, body, required) Returns: List. ### remove_from_list: Remove contacts from a list `DELETE /api/v1/lists/{id}/contacts` Takes up to 1,000 contacts off a list. The contacts are kept. - `id` (integer, path, required): The list's id. - `contact_ids` (array, body, required) Returns: List. ## Segments ### list_segments: List segments `GET /api/v1/segments` Lists the workspace's segments with their last counted size (count_segment counts again). Returns: list of Segment. ### get_segment_fields: Get segment fields `GET /api/v1/segments/fields` Lists what segment rules can filter on (every filterable contact property, custom ones as field:) and the operators of each kind of field. Read this before writing rules. ### get_segment: Get a segment `GET /api/v1/segments/{id}` Gets a segment and its rules. - `id` (integer, path, required): The segment's id. Returns: Segment. ### create_segment: Create a segment `POST /api/v1/segments` Saves a segment. Rules that can't be worked out are refused, never dropped (a dropped rule would widen who gets emailed). - `name` (string, body, required) - `rules` (object, body, required): A rule tree: {"match": "all"|"any", "rules": [{"field": "lead_type", "op": "in", "value": ["opted_in"]}, {"match": "any", "rules": [...]}]}. Groups nest up to 3 deep. Dates also take {"op": "in_period", "value": "last_month"} and between ["2026-01-01", "2026-03-31"]; numbers between [1, 5]. get_segment_fields lists the fields, operators and periods. Returns: Segment. ### update_segment: Update a segment `PATCH /api/v1/segments/{id}` Renames a segment or replaces its rules. - `id` (integer, path, required): The segment's id. - `name` (string, body) - `rules` (object, body): A rule tree: {"match": "all"|"any", "rules": [{"field": "lead_type", "op": "in", "value": ["opted_in"]}, {"match": "any", "rules": [...]}]}. Groups nest up to 3 deep. Dates also take {"op": "in_period", "value": "last_month"} and between ["2026-01-01", "2026-03-31"]; numbers between [1, 5]. get_segment_fields lists the fields, operators and periods. Returns: Segment. ### delete_segment: Delete a segment `DELETE /api/v1/segments/{id}` (destructive: confirm with the user first) Deletes a segment. Refused while another segment uses it. Its contacts are kept. - `id` (integer, path, required): The segment's id. ### count_segment: Count a segment `GET /api/v1/segments/{id}/count` Counts the contacts in a segment now, and how many of them can be emailed (active, with a good address). - `id` (integer, path, required): The segment's id. ## Templates ### list_templates: List templates `GET /api/v1/templates` Lists the workspace's email templates, last changed first (without their content; get_template has it). Returns: list of Template. ### get_template: Get a template `GET /api/v1/templates/{id}` Gets a template with its content. - `id` (integer, path, required): The template's id. Returns: Template. ### create_template: Create a template `POST /api/v1/templates` Saves reusable email content. Write markdown (format markdown), full or partial HTML (format html), or an editor document (format editor). Merge tags: {{first_name|there}} (with a fallback), {{email}}, {{custom.}}. The unsubscribe footer with your postal address is added for you. - `name` (string, body, required) - `format` (string, body, required): one of editor, html, markdown. markdown, html or editor. - `subject` (string, body) - `preview_text` (string or null, body): Shown next to the subject in the inbox. - `markdown` (string or null, body) - `html` (string or null, body) - `document` (object or null, body): A Tiptap editor document (format editor). - `topic_id` (integer or null, body): The topic it belongs to (list_topics); people who left the topic don't get it. - `theme` (string or null, body): one of standard, letter, newsroom, oasis, sand, night, typewriter, poster. A colour theme; null uses the workspace's. Returns: Template. ### update_template: Update a template `PATCH /api/v1/templates/{id}` Changes a template. Only the fields you send change. Campaigns made from it keep their own copy. Merge tags: {{first_name|there}} (with a fallback), {{email}}, {{custom.}}. The unsubscribe footer with your postal address is added for you. - `id` (integer, path, required): The template's id. - `name` (string, body) - `format` (string, body): one of editor, html, markdown. markdown, html or editor. - `subject` (string, body) - `preview_text` (string or null, body): Shown next to the subject in the inbox. - `markdown` (string or null, body) - `html` (string or null, body) - `document` (object or null, body): A Tiptap editor document (format editor). - `topic_id` (integer or null, body): The topic it belongs to (list_topics); people who left the topic don't get it. - `theme` (string or null, body): one of standard, letter, newsroom, oasis, sand, night, typewriter, poster. A colour theme; null uses the workspace's. Returns: Template. ### delete_template: Delete a template `DELETE /api/v1/templates/{id}` (destructive: confirm with the user first) Deletes a template. Campaigns made from it keep their content. - `id` (integer, path, required): The template's id. ### preview_template: Preview a template `POST /api/v1/templates/{id}/preview` Renders a template as it will be sent: subject, preheader, HTML, plain text, size and content warnings, filled in for one of your contacts or with sample values. - `id` (integer, path, required): The template's id. - `contact_id` (integer, body): Fill merge tags for this contact. ### send_test_template: Send a test of a template `POST /api/v1/templates/{id}/test` Emails a test of the template to a member of the workspace (never to contacts), from one of its senders. - `id` (integer, path, required): The template's id. - `to` (string, body, required): A workspace member's address. - `sender_id` (integer, body): Who it is from (list_senders); the default sender when left out. ## Campaigns ### list_campaigns: List campaigns `GET /api/v1/campaigns` Lists campaigns, newest first, with their status and counts (without content). - `status` (string, query): one of draft, scheduled, sending, paused, sent, canceled - `page` (integer, query): Page number, from 1. - `per_page` (integer, query): Items per page, up to 100. Returns: list of Campaign (paginated). ### get_campaign: Get a campaign `GET /api/v1/campaigns/{id}` Gets a campaign with its content, audience and counts. - `id` (integer, path, required): The campaign's id. Returns: Campaign. ### create_campaign: Create a campaign `POST /api/v1/campaigns` Creates a draft from a template (template_id) or with new content, from the default sender. Choose the audience with lists and segments; exclusions are taken out. Only opted-in contacts who can be emailed get it. Merge tags: {{first_name|there}} (with a fallback), {{email}}, {{custom.}}. The unsubscribe footer with your postal address is added for you. - `name` (string, body, required) - `template_id` (integer, body): Copy this template's content. - `format` (string, body): one of editor, html, markdown. markdown, html or editor. - `subject` (string, body) - `preview_text` (string or null, body): Shown next to the subject in the inbox. - `markdown` (string or null, body) - `html` (string or null, body) - `document` (object or null, body): A Tiptap editor document (format editor). - `topic_id` (integer or null, body): The topic it belongs to (list_topics); people who left the topic don't get it. - `theme` (string or null, body): one of standard, letter, newsroom, oasis, sand, night, typewriter, poster. A colour theme; null uses the workspace's. - `sender_id` (integer, body): Who it is from (list_senders). - `audience` (object, body): Who gets it: {"lists": [ids], "segments": [ids]}. - `exclusions` (object, body): Who never gets it, even if in the audience: {"lists": [ids], "segments": [ids]}. - `audience_limit` (integer or null, body): Only the N most engaged recipients; send_campaign_to_more reaches the rest later. - `publish_to` (string, body): one of email, both, web. Where it goes: email (the default), both (email, and the workspace's public pages once it goes out) or web (the public pages only: no email goes out, and send_campaign takes confirm_recipients 0). Returns: Campaign. ### update_campaign: Update a campaign `PATCH /api/v1/campaigns/{id}` Changes a draft or scheduled campaign that hasn't started: content, sender, topic, audience. Only the fields you send change. Merge tags: {{first_name|there}} (with a fallback), {{email}}, {{custom.}}. The unsubscribe footer with your postal address is added for you. - `id` (integer, path, required): The campaign's id. - `name` (string, body) - `format` (string, body): one of editor, html, markdown. markdown, html or editor. - `subject` (string, body) - `preview_text` (string or null, body): Shown next to the subject in the inbox. - `markdown` (string or null, body) - `html` (string or null, body) - `document` (object or null, body): A Tiptap editor document (format editor). - `topic_id` (integer or null, body): The topic it belongs to (list_topics); people who left the topic don't get it. - `theme` (string or null, body): one of standard, letter, newsroom, oasis, sand, night, typewriter, poster. A colour theme; null uses the workspace's. - `sender_id` (integer, body): Who it is from (list_senders). - `audience` (object, body): Who gets it: {"lists": [ids], "segments": [ids]}. - `exclusions` (object, body): Who never gets it, even if in the audience: {"lists": [ids], "segments": [ids]}. - `audience_limit` (integer or null, body): Only the N most engaged recipients; send_campaign_to_more reaches the rest later. - `publish_to` (string, body): one of email, both, web. Where it goes: email (the default), both (email, and the workspace's public pages once it goes out) or web (the public pages only: no email goes out, and send_campaign takes confirm_recipients 0). Returns: Campaign. ### delete_campaign: Delete a campaign `DELETE /api/v1/campaigns/{id}` (destructive: confirm with the user first) Deletes a draft. A campaign that was scheduled or sent can be canceled instead. - `id` (integer, path, required): The campaign's id. ### duplicate_campaign: Duplicate a campaign `POST /api/v1/campaigns/{id}/duplicate` Makes a new draft with the same content, sender, topic and audience. - `id` (integer, path, required): The campaign's id. Returns: Campaign. ### send_test_campaign: Send a test of a campaign `POST /api/v1/campaigns/{id}/test` Emails a test to a member of the workspace (never to contacts), so the user can check it before sending. - `id` (integer, path, required): The campaign's id. - `to` (string, body, required): A workspace member's address. ### review_campaign: Review a campaign `GET /api/v1/campaigns/{id}/review` Runs the checks before sending. blocks must be fixed first; warnings can be accepted. checks lists every check: id, level (block or warning), passed, message (what to fix) and label (a short line for the ones that passed, or null). A web-only campaign (web_only true) only gets the content checks, and its audience is 0. audience.recipients is the count to show the user and pass as confirm_recipients, with who is skipped and why. pace says how it would go out if sent now: spread_hours (0 = straight away), the domain warm-up cap when it holds part of it to later days, and first_slice, the never-emailed contacts that go first; gmail_paused says so while Gmail rate limits the sending domain (what is sent waits until gmail_paused_until). - `id` (integer, path, required): The campaign's id. ### get_campaign_tips: Get tips for more opens `GET /api/v1/campaigns/{id}/tips` Tips on the saved content that tend to land email in spam or cost opens: a subject in capitals, runs of !!! or ???, classic spam phrases, a fake Re: or Fwd:, link shorteners, images with little text, too many links, no preview text, and up to three more from the AI content check when it runs. They never stop a send. tips: at most 3, most important first, each with code (the rule, or ai), field (subject, preview_text or content), quote (the exact words it is about) and tip (what to change). Empty when nothing stands out. ai: whether the AI check read this content. - `id` (integer, path, required): The campaign's id. ### send_campaign: Send a campaign now `POST /api/v1/campaigns/{id}/send` (destructive: confirm with the user first) Starts sending to the reviewed audience. Can't be undone: show the user the subject and the recipient count from review_campaign, and get their go-ahead first. A web-only campaign goes on the public pages instead (confirm_recipients 0). - `id` (integer, path, required): The campaign's id. - `confirm_recipients` (integer, body, required): The recipient count from review_campaign, after the user confirmed it. Refused if the audience changed since. 0 for a web-only campaign (publish_to web): nobody gets an email. Returns: Campaign. ### schedule_campaign: Schedule a campaign `POST /api/v1/campaigns/{id}/schedule` (destructive: confirm with the user first) Schedules sending for later (up to a year ahead). A time without an offset is in the workspace's timezone. The audience is worked out when sending starts. Confirm with the user first, like send_campaign. - `id` (integer, path, required): The campaign's id. - `confirm_recipients` (integer, body, required): The recipient count from review_campaign, after the user confirmed it. Refused if the audience changed since. 0 for a web-only campaign (publish_to web): nobody gets an email. - `scheduled_at` (string, body, required) Returns: Campaign. ### unschedule_campaign: Unschedule a campaign `POST /api/v1/campaigns/{id}/unschedule` Takes a scheduled campaign that hasn't started back to a draft. Nothing was sent. - `id` (integer, path, required): The campaign's id. Returns: Campaign. ### send_campaign_to_more: Send a campaign to more people `POST /api/v1/campaigns/{id}/send-more` (destructive: confirm with the user first) Raises the audience limit of a sent or sending campaign (null: everyone), and the next people in the same order get it. Can't be undone: confirm with the user first. - `id` (integer, path, required): The campaign's id. - `audience_limit` (integer or null, body): The new total; null sends to everyone in the audience. Returns: Campaign. ### pause_campaign: Pause a campaign `POST /api/v1/campaigns/{id}/pause` Pauses a scheduled or sending campaign. Emails already handed to Amazon SES still go; the rest wait. - `id` (integer, path, required): The campaign's id. Returns: Campaign. ### resume_campaign: Resume a campaign `POST /api/v1/campaigns/{id}/resume` (destructive: confirm with the user first) Resumes a paused campaign, so sending carries on. If it paused itself for high bounce or complaint rates, resuming accepts those rates. Confirm with the user first. - `id` (integer, path, required): The campaign's id. Returns: Campaign. ### cancel_campaign: Cancel a campaign `POST /api/v1/campaigns/{id}/cancel` (destructive: confirm with the user first) Stops a campaign for good: nobody else gets it. A scheduled campaign that hasn't started goes back to a draft. - `id` (integer, path, required): The campaign's id. Returns: Campaign. ### get_campaign_report: Get a campaign's report `GET /api/v1/campaigns/{id}/report` The results of a campaign: counts (sent, delivered, opens, clicks, bounces, complaints, unsubscribes), rates, the most clicked links, results by inbox provider, opens hour by hour, and how it compares with recent campaigns. Machine opens (Apple Mail Privacy Protection and the like) are counted apart. - `id` (integer, path, required): The campaign's id. ## Sending ### list_domains: List sending domains `GET /api/v1/domains` Lists the domains the workspace sends from, whether each is verified, and the DNS records to add (DKIM, MAIL FROM, DMARC) with which are in place. Returns: list of Domain. ### add_domain: Add a sending domain `POST /api/v1/domains` Adds a domain to send from and returns the DNS records to add at its DNS provider. Use a subdomain such as news.example.com, so campaigns never affect the main domain's email. Then check_domain once the records are in. - `domain` (string, body, required) Returns: Domain. ### check_domain: Check a sending domain `POST /api/v1/domains/{id}/check` Asks Amazon SES and DNS whether the domain's records are in place, and returns its status. DNS changes can take up to an hour to show. - `id` (integer, path, required): The domain's id. Returns: Domain. ### list_senders: List senders `GET /api/v1/senders` Lists who the workspace's emails can be from (name, address, reply-to), the default first. Senders on an unverified domain can't send yet. Returns: list of Sender. ### list_test_emails: List test emails `GET /api/v1/test-emails` The newest test emails, from any member or key, with what became of each from Amazon SES's events: sent, delivered, delayed, bounced (detail says why; suppressed when the address is on SES's suppression list), complained, rejected or failed. Check it after a send_test_* to see whether the test arrived. Give campaign_id or template_id for that one's tests. - `campaign_id` (integer, query): Only tests of this campaign. - `template_id` (integer, query): Only tests of this template. - `limit` (integer, query): How many, newest first. Returns: list of TestEmail. ## Signups ### list_signup_forms: List signup forms `GET /api/v1/signup-forms` Lists the workspace's signup forms with their public id (form_id in add_signup), signup counts, and whether our content check holds their own confirmation or welcome email ("in_review", or "rejected" with our team's note): meanwhile new signups get the built-in confirmation email and no welcome email. Returns: list of SignupForm. ### add_signup: Add a signup `POST /api/v1/signups` (signups keys too) Signs someone up to the workspace's emails, like a signup form. With confirmed opt-in (the default, and always for someone who unsubscribed before) they get a confirmation email and stay pending until they click it. People already subscribed are left as they are. Works with a signups key. - `email` (string, body, required) - `first_name` (string, body) - `form_id` (string, body): A signup form's public id: its list, topics, confirmation and welcome emails apply. - `topics` (array, body): Without a form: topic ids or keys to join. - `double_opt_in` (boolean, body): Without a form: false skips the confirmation email (only if you confirmed them yourself). - `page_url` (string, body): Where they signed up, kept with their consent. - `keep_email` (boolean, body): Sign up an address that looks like a typo anyway. - `fields` (object, body): Custom contact property values by key (see list_properties), checked like add_contact. Required properties aren't required here. ## Public pages ### set_website_domain: Set the public pages' domain `POST /api/v1/website-domain` Puts the workspace's public pages (its published campaigns) on a subdomain of its own, like news.example.com. The answer lists the two DNS records to add: a CNAME that points the subdomain at Zeebra, and a TXT record that proves it's yours. It goes live once check_website_domain finds both, and right away when they're already in. One domain at a time: remove a live one first. Tracked and unsubscribe links stay on Zeebra's own domain. - `host` (string, body, required): A subdomain of a domain you manage. A link works too. Returns: WebsiteDomain. ### check_website_domain: Check the public pages' domain `POST /api/v1/website-domain/check` Looks up the DNS records of the public pages' domain again. One being set up goes live once both are in; for a live one, says whether its CNAME still points at Zeebra. DNS changes can take up to an hour to show. Returns: WebsiteDomain. ### remove_website_domain: Remove the public pages' domain `DELETE /api/v1/website-domain` (destructive: confirm with the user first) Takes the public pages off their own domain: they go back to their address on Zeebra, and links to the domain redirect there while it still points at Zeebra. ## Webhooks ### list_webhook_event_types: List webhook event types `GET /api/v1/webhooks/events` Lists the events an endpoint can subscribe to, with what each means. Every event is sent as {id, type, created_at, workspace_id, data}, signed in the Zeebra-Signature header. Returns: list of WebhookEventType. ### list_webhooks: List webhook endpoints `GET /api/v1/webhooks` Lists the workspace's webhook endpoints, the events each gets, and whether it is on. Returns: list of WebhookEndpoint. ### get_webhook: Get a webhook endpoint `GET /api/v1/webhooks/{id}` Gets one webhook endpoint (never its secret). - `id` (integer, path, required): The webhook endpoint's id. Returns: WebhookEndpoint. ### create_webhook: Add a webhook endpoint `POST /api/v1/webhooks` Adds an HTTPS endpoint that gets the chosen events. The answer has its signing secret (secret): store it now, it is never shown again. Verify each delivery's Zeebra-Signature header with it. Up to 10 endpoints per workspace. - `url` (string, body, required): An https:// URL on a public host. - `events` (array, body, required): Event types (list_webhook_event_types), or ["*"] for every event, including ones added later. - `description` (string or null, body): What it is for, e.g. "atndr CRM sync". Returns: WebhookEndpoint. ### update_webhook: Change a webhook endpoint `PATCH /api/v1/webhooks/{id}` Changes an endpoint's URL, events or description, or switches it off or back on (enabled). Switching it on clears its failures. Only the fields sent change. - `id` (integer, path, required): The webhook endpoint's id. - `url` (string, body) - `events` (array, body): Event types (list_webhook_event_types), or ["*"] for every event, including ones added later. - `description` (string or null, body): What it is for, e.g. "atndr CRM sync". - `enabled` (boolean, body) Returns: WebhookEndpoint. ### delete_webhook: Delete a webhook endpoint `DELETE /api/v1/webhooks/{id}` (destructive: confirm with the user first) Deletes an endpoint and its delivery log. It gets nothing more. - `id` (integer, path, required): The webhook endpoint's id. ### rotate_webhook_secret: Rotate a webhook secret `POST /api/v1/webhooks/{id}/secret` Gives the endpoint a new signing secret (in secret, shown once). For 24 hours deliveries carry a signature with the old secret too, so the receiver can switch without missing events. - `id` (integer, path, required): The webhook endpoint's id. Returns: WebhookEndpoint. ### test_webhook: Send a test event `POST /api/v1/webhooks/{id}/test` Sends a webhook.test event to the endpoint now (once, no retries; even when it is switched off) and returns the delivery: its status, the endpoint's HTTP status and answer. - `id` (integer, path, required): The webhook endpoint's id. Returns: WebhookDelivery. ### list_webhook_deliveries: List webhook deliveries `GET /api/v1/webhooks/{id}/deliveries` The endpoint's newest deliveries first (the last 100 are kept): each event, its status, attempts, and the endpoint's last answer. A retrying delivery says when it is tried next. - `id` (integer, path, required): The webhook endpoint's id. - `status` (string, query): one of pending, sending, retrying, succeeded, failed. Only deliveries with this status. - `page` (integer, query): Page number, from 1. - `per_page` (integer, query): Items per page, up to 100. Returns: list of WebhookDelivery (paginated). ### resend_webhook_delivery: Resend a webhook delivery `POST /api/v1/webhooks/{id}/deliveries/{delivery_id}/resend` Sends a delivery's event to its endpoint again, now, with the same event id (so the receiver can tell it's a repeat). Returns the new delivery. - `id` (integer, path, required): The webhook endpoint's id. - `delivery_id` (integer, path, required): The delivery's id. Returns: WebhookDelivery.