# SendBeam documentation Email marketing for the people who run more than one website. Every site gets its own workspace — lists, forms, automations, campaigns and a verified sending domain — under one login, priced by contacts across all of them and never per domain. This file is the complete SendBeam documentation as Markdown. Base URL for the API: https://sendbeam.io. Machine-readable API: https://sendbeam.io/openapi.json. --- # Getting started: Getting Started Learn what SendBeam does and get up and running in minutes. ## What is SendBeam? SendBeam is one email account for every site you run. Each site is a workspace with its own contacts, lists, forms, automations and sending domain; one account and one plan cover all of them. You verify the domain you send from, and SendBeam handles everything else: delivery, contacts, lists, campaigns, automations and analytics. Every message is signed for your own domain and sent from an aligned return path, so the reputation that mailbox providers judge is your domain's. Bounces and complaints are handled for you, and a workspace whose complaint or bounce rate climbs is warned and then paused before it can hurt anyone. Dedicated sending IPs are available on the Business plan. **Delivery is included.** There is no email service to sign up for and no API key to paste. Every workspace sends through SendBeam's managed delivery; you verify the domain you send from and your plan sets the monthly volume. See [Billing & plans](https://sendbeam.io/docs/admin/billing). ## Key features Here is what you can do with SendBeam today: - **Contacts & lists** — Import subscribers via CSV, add them manually, or capture them with forms and the API. Organise contacts into lists with optional double opt-in confirmation. - **Tags & segments** — Apply tags to contacts to drive automations, and build dynamic segments from contact fields and custom fields. - **Email builder** — Compose campaigns in the visual block builder, a rich-text editor, or your own HTML. Preview your email on desktop and mobile before sending. - **Templates** — Design reusable emails, filed by category, that automations send directly and campaigns can start from. - **Campaigns** — Send to all contacts, a specific list, or a saved segment. Schedule sends in advance or send immediately. - **Automations** — Build multi-step workflows triggered by a contact being created, a tag being added, a list being joined, or a form being submitted. - **Forms** — Create embeddable signup forms that add new subscribers to a list, and contact forms that email you the visitor's message. - **Reports** — Track opens, clicks, unsubscribes, and bounces per campaign and across the workspace, with a per-email log. - **Team management** — Invite teammates with role-based access (admin or member); one owner per workspace holds deletion and billing. - **Workspaces & billing** — Run one workspace per site under a single account and plan. Upgrade or cancel at any time under Account settings → Billing & Plans. ## Quickstart in three steps If you want to get your first email out the door as fast as possible, follow these three steps. Each step links to a full guide with more detail. Creating an account takes a minute: enter your email address, then a workspace name and password, then the code SendBeam emails you. The workspace is created once the code is verified. A few workspace names are reserved, and temporary-mailbox addresses are not accepted. 1. **Verify your sending domain** Go to **Settings > Email & Domains**, add your domain and publish the DNS records shown — currently three CNAMEs, and SendBeam shows exactly what to add. You can send from a shared platform address straight away. 2. **Add your first contact** Head to **Contacts** and click **Add contact**. Enter an email address and an optional name. For bulk imports, use the CSV import tool instead. 3. **Send your first campaign** Open **Campaigns**, click **New Campaign**, write your subject and body, choose your recipients, and hit send. Your campaign is dispatched through SendBeam's managed delivery straight away. **Tip:** Send a test to yourself first. Once the campaign is saved as a draft, open it from the Campaigns list and use the **Send test** button to deliver a copy to the address you sign in with before sending to your full list. ## What comes next? Once you have completed the quickstart, explore these areas to get more from SendBeam: - Set up [double opt-in](https://sendbeam.io/docs/lists/double-optin) on your lists to improve list quality and comply with GDPR. - Create a [signup form](https://sendbeam.io/docs/forms) and embed it on your website to grow your list automatically. - Build an [automation](https://sendbeam.io/docs/automations) that sends a welcome email whenever someone joins a list. - Explore [segments](https://sendbeam.io/docs/segments) to target subsets of your audience based on behaviour or tags. - Review [analytics](https://sendbeam.io/docs/analytics) after your first send to understand open and click rates. --- # Getting started: Sending Domain Delivery is managed by SendBeam. Verify your domain and choose the address your email comes from. ## How sending works SendBeam delivers your email through its own sending infrastructure. There are no API keys to create, no third-party accounts to connect and nothing to configure on the delivery side: every message is authenticated with DKIM and SPF for your domain, bounces and complaints are handled automatically, and the platform's guardrails protect your sending reputation. What you control is **which domain your email comes from** and the from-name and address recipients see. Your monthly sending volume is set by your [plan](https://sendbeam.io/docs/admin/billing). ## Start sending instantly Every new workspace can send straight away from its own shared platform address, `ws-@mail.sendbeam.io`, where the id is eight characters unique to the workspace (workspaces created earlier keep their `yourworkspace@mail.sendbeam.io` address). It is fully authenticated and fine for testing and for your first campaigns, and it is the only address on the shared domain the workspace can use. Most teams move to their own domain before sending to a real audience, because recipients recognise it and it builds reputation for your brand rather than the shared address. Workspaces on the **Free** plan send from a shared address on `mail2.sendbeam.io`, a pool kept separate from paying customers so one plan's sending behaviour never affects the other's reputation. Workspaces on a paid plan use the main shared pool on `mail.sendbeam.io`, and verifying your own domain takes you off the shared pools altogether. The move between pools is automatic: when a workspace upgrades or downgrades, its shared address (the same `ws-@` local part) follows its plan on the next send, with nothing to change in your settings. ## Add your own domain 1. Open **Settings > Email & Domains** and enter the domain you want to send from, for example `yourbrand.com` or a subdomain such as `news.yourbrand.com`. 2. SendBeam shows the CNAME records to add: DKIM signing records and a return-path record. Add them at your DNS provider (unproxied, if it offers proxying). 3. Once your DNS provider has enabled Domain Connect for SendBeam, the card also shows **Connect with provider**: you are taken to your provider, sign in there if you are not already, see the two records, and approve. SendBeam never receives a password or a token. Until a provider enables it the button is not shown, and adding the records yourself is the way. 4. SendBeam re-checks the records automatically about every minute; you can also click **Verify**. Verification usually completes within a few minutes, and the domain shows **Verified** when it is ready. > **Publish a DMARC record.** With DKIM and SPF aligned to your domain, a DMARC policy > (`v=DMARC1; p=quarantine` or stricter) tells mailbox providers to trust mail that > passes and reject forgeries. It improves placement and protects your brand. > ## Choose your from-address Once a domain is verified, pick the address on it that your email is sent from (`hello@`, `news@`, and so on) under the domain on the same page and click **Set as default**. The from-name is set in the **Sender details** section above it, where you can also send yourself a test email. Replies go to the from-address. A workspace can send only as its own shared address or as an address on a domain *this workspace* has verified. SendBeam checks the from-address when you save it and again on every send, so an address on an unverified domain, or on a domain verified in a different workspace, is never used. The from-name is cleaned of line breaks, quotes and angle brackets and kept to 80 characters. That is the whole setup. Next: add your first contact. --- # Getting started: Your First Contact Add your first subscriber to SendBeam. ## Navigate to Contacts Contacts are the foundation of everything in SendBeam. Every email address you send to must exist as a contact in your workspace. SendBeam stores each contact's email address, name, status, tags, and any custom fields captured for them. To reach the Contacts section, click **Contacts** in the left navigation bar. You will land on the contacts list view, which shows all contacts in the workspace. If you have just set up your workspace, this list will be empty. **One workspace, many contacts:** Contacts belong to the workspace, not to individual lists. A single contact can be a member of multiple lists and still appear only once in your contacts table. ## Add a contact manually For adding one or a small number of contacts, the manual form is the quickest option: 1. On the Contacts page, click the **Add Contact** button in the top-right corner. 2. Enter the contact's **Email address**. This is the only required field (up to 254 characters). 3. Optionally enter a **First name** and **Last name** (up to 100 characters each). These are used in merge tags so you can personalise campaigns with `{{first_name}}`. 4. Optionally choose a **Source** (Manual, Import, API or Form) to record where the contact came from. 5. Click **Add Contact**. The contact is created immediately with the status **Subscribed** and will appear in your contacts list. **Tip:** To put the contact on a list, open **Lists**, choose the list, and enter their email address in the **Add Contact to List** box. To change their status, open the contact and click **Edit**. ## Understanding contact status Every contact in SendBeam has a status that controls whether they receive emails. Setting the correct status is important for compliance with anti-spam regulations such as CAN-SPAM and GDPR. - **Subscribed** — The contact has opted in and will receive campaigns and automations. This is the status to use when you have explicit permission to email someone. - **Unsubscribed** — The contact has opted out of marketing emails. SendBeam will not send campaigns or automations to contacts with this status. Unsubscribes are set automatically when a contact clicks the unsubscribe link in an email. - **Bounced** — A previous email to this address hard-bounced. SendBeam marks contacts as bounced automatically from delivery events and will not attempt to send to them again. - **Complained** — The contact marked a message as spam. Like bounced contacts, complained contacts are excluded from future sends automatically. Double opt-in is tracked per list rather than as a status: a contact added to a double opt-in list stays **Subscribed** but is not mailed by campaigns sent to that list until they click the confirmation email. On the Free plan every campaign, whatever its audience, only reaches contacts who have confirmed a subscription. An address that unsubscribes, bounces, complains or is deleted goes on the workspace's **suppression list**. The API refuses to create it again, and a CSV import brings it in as Unsubscribed. Only the person can bring themselves back, by subscribing again through one of your [signup forms](https://sendbeam.io/docs/forms). ## Import contacts via CSV If you are migrating from another platform or have a spreadsheet of subscribers, the CSV import tool lets you add hundreds or thousands of contacts in one go. To import contacts: - On the Contacts page, click the **Import** button next to Add Contact. - Download the example file to see the expected column format. - Your CSV must include an `email` column. The other supported columns are `first_name` and `last_name`. - Drop your prepared CSV file (up to 5 MB) onto the upload area and click **Import**. - SendBeam will process the file and report how many contacts were imported, how many duplicates were skipped and how many rows were invalid. **Tip:** The import page lets you choose the status new contacts get (Subscribed or Unsubscribed), a source label, and whether existing contacts are skipped or updated. Only import as Subscribed contacts you have explicit permission to email. For a full walkthrough of the import tool, including how duplicates and plan limits are handled, see the [CSV Import](https://sendbeam.io/docs/contacts/importing) guide. --- # Getting started: Your First Campaign Send your first email campaign with SendBeam. ## Create a new campaign A campaign in SendBeam is a single email send to a group of contacts. Campaigns have a name (visible only to your team), a subject line (visible to recipients), a sender name and address, and the email body content. Once a campaign is sent, SendBeam tracks delivery events and surfaces them in the campaign statistics view. Before creating your first campaign, make sure you have: - Verified your sending domain in **Settings > Email & Domains** (or chosen the shared platform address). - Added at least one subscribed contact (or imported a list of contacts). To create a campaign: 1. Click **Campaigns** in the left navigation bar. 2. Click **New Campaign** in the top-right corner. 3. Enter an internal **Campaign name**. This is for your reference only and is not shown to recipients. Something like `May Newsletter` or `Product Launch` works well. 4. Enter the **Subject line** — this is what recipients will see in their inbox. Keep it concise and descriptive. 5. Check the **From name** and **From email**, which are pre-filled from your sender details. The from email must use a domain you have verified under **Settings > Email & Domains**, or the shared address every workspace starts with. For example, `hello@yourbrand.com`. 6. Click **Next**. **Subject line tip:** Aim for 40–60 characters. Shorter subject lines are less likely to be truncated on mobile devices. Avoid all-caps words and excessive punctuation, which can trigger spam filters. ## Choose your recipients SendBeam gives you three ways to target your audience: - **All Contacts** — Send to every contact in the workspace with a status of Subscribed. Use this for broad announcements where you want maximum reach. - **A specific list** — Target only members of a particular list. This is the most common option. Select the list from the dropdown. Only subscribed members of that list will receive the email. - **A segment** — Use a saved segment to target a dynamic subset of contacts based on filters such as tags, custom fields, or engagement. See the [Segments](https://sendbeam.io/docs/segments) guide for how to build segments. Click **Next** once you have chosen. The number of recipients actually queued is shown on the campaign page once the send starts. **Note:** Contacts with a status of Unsubscribed, Bounced, or Complained are automatically excluded from all sends, regardless of which recipient option you choose. ## Write your email SendBeam offers two ways to compose your email body: - **Visual builder** — Pick a starter layout, then add and edit content blocks (headers, text, images, buttons, dividers, columns and more) without writing any code. This is the recommended option for most users and is the view a new campaign opens in. - **Edit** — A rich-text editor for simple formatted messages, with a toolbar for bold, lists, links and images. - **HTML** — Paste or type raw HTML directly. Use this option if you have a custom-coded template from your design team or another tool. If your HTML has no `{"{{unsubscribe_url}}"}` tag, SendBeam adds a plain unsubscribe link at the bottom when it sends. Whichever editor you use, you can personalise content using merge tags: - `{"{{first_name}}"}` — Replaced with the contact's first name (empty if not set). - `{"{{last_name}}"}` — Replaced with the contact's last name. - `{"{{email}}"}` — The contact's email address. - `{"{{custom_fields.key}}"}` — Any custom field on the contact. - `{"{{unsubscribe_url}}"}` — A unique unsubscribe link for each recipient. ## Preview, send, and review stats Before sending, always preview your email to catch formatting issues: 1. Switch to the **Preview** view to see a rendered preview of your email in the browser. Use the desktop and mobile toggle to check how it looks on smaller screens, then click **Next**. 2. On the **Review & Send** step, check the summary. To test in a real inbox first, wait — the campaign is saved as a draft at this point — then open it from the Campaigns list and click **Send test**, which delivers a copy to the address you sign in with. 3. When you are satisfied, click **Send Now** to dispatch the campaign immediately, or pick a date and time under **Schedule**. Scheduled campaigns can be edited or cancelled at any time before they send. 4. After sending, SendBeam moves the campaign through **Sending** to **Sent**. Its page shows sent, delivered, opened, clicked, bounced, and unsubscribed counts as delivery events arrive. **Tip:** Stats update within seconds of events occurring. Opens and clicks depend on recipients loading images or clicking links, so a brand-new campaign can legitimately show zero for a while. Delivered, bounced and unsubscribed update as soon as the mail servers respond. Congratulations — you have sent your first campaign with SendBeam. From here, explore [Contacts](https://sendbeam.io/docs/contacts) to learn about tags and custom fields, or head to [Automations](https://sendbeam.io/docs/automations) to set up triggered email sequences. --- # Getting started: Moving In How the done-for-you Move-in service brings your subscribers, suppressions, tags and consent evidence across from another platform. Move-in is a done-for-you migration: you give us read-only access to your old platform, we bring your audience across into your SendBeam workspaces in the right order, run the two side by side while you switch your forms and domain over, and hand you a log of exactly what was moved. It is included for every site you run on the Pro and Business plans; request it at [sendbeam.io/move-in](https://sendbeam.io/move-in). If you would rather do it yourself, the same result is available through [CSV import](https://sendbeam.io/docs/contacts/importing) and the [suppression list](https://sendbeam.io/docs/contacts/suppressions). Everything below applies either way. ## What we move - **Subscribers** with their names, status and original signup date. - **Suppressions**: everyone who unsubscribed, bounced or complained on the old platform stays suppressed here, before a single contact is imported. - **Tags** (and, on platforms that use them, groups and lists) as SendBeam tags. - **Custom fields**: merge fields, subscriber fields and attributes become custom fields with tidy `snake_case` keys, up to 30 per contact. - **Language**: Mailchimp's own language field, or on any platform a field named `language`, `lang`, `locale`, `language_code` or `preferred_language`, fills the contact's [language](https://sendbeam.io/docs/contacts/adding#required-optional-fields) when it is empty, so a multi-language campaign reaches a migrated audience in the right version. - **Consent evidence**: the signup IP, confirmation time and signup source wherever the old platform kept them (see [below](#the-evidence-we-carry) for what each platform can provide). - **Up to 3 forms** rebuilt as SendBeam [forms](https://sendbeam.io/docs/forms), with the embed code ready to swap in. - Your **sending domain**, verified in SendBeam before any campaign goes out ([how sending domains work](https://sendbeam.io/docs/getting-started/sending-domain)). We support Mailchimp, MailerLite, Kit (ConvertKit), Brevo (Sendinblue) and EmailOctopus through their APIs. From any other platform we work from a CSV export, which carries subscribers, status and fields but usually no consent evidence. ## What cannot be moved - **Automations and sequences.** No platform exports them in a form another can run. We rebuild the ones you list for us as SendBeam [automations](https://sendbeam.io/docs/automations); existing subscribers are not re-enrolled in welcome sequences they have already received. - **Paid subscriptions** on Substack, Beehiiv, Ghost and similar. The billing relationship belongs to the old platform; paid readers must subscribe again through your new checkout. We move them as ordinary contacts, tagged so you can write to them about it. - **Engagement history** (opens, clicks, campaign reports). It stays on the old platform; SendBeam starts measuring from your first campaign here. - **Unconfirmed sign-ups.** Someone who never clicked the confirmation email on the old platform is not a subscriber. We leave them out by default; on request we import them as pending, which never sends them email. - **Templates and campaign archives.** Recreate the one or two layouts you use as SendBeam [templates](https://sendbeam.io/docs/templates). ## What we need from you 1. A **read-only API key** for the old platform, or a temporary seat with read access. Send it through the Move-in request page, never by email; we revoke or return it when the move is complete. 2. A **list of your sites** and which SendBeam workspace each audience should land in. One account can hold every site you run, so nothing needs to be merged unless you want it to be. 3. The **automations to rebuild**: a short description or screenshots of each one. 4. **DNS access** for your sending domain, or someone who can add the records we send you — two CNAMEs per domain. 5. A **signed scope**. The request page produces it; its reference number appears in your migration log. ## The order we do it in 1. **Suppressions first.** Every unsubscribed, bounced and complained address from the old platform is written to your [suppression list](https://sendbeam.io/docs/contacts/suppressions) before anything else. If a later step goes wrong, nobody who opted out can be emailed. 2. **Sending domain.** Your domain is verified and set as the default from-address so the first campaign from SendBeam arrives authenticated from the address people know. 3. **Import.** Subscribers are imported with their tags, fields and consent evidence, in chunks, with duplicates skipped so re-running a step never creates a second copy. We then compare counts against the source and stop if anything is short. 4. **Parallel run.** Both platforms stay live while you switch your forms and links. Send your first campaign from SendBeam to a small segment before the whole audience. 5. **30-day unsubscribe sync.** For 30 days after cutover we bring across anyone who unsubscribes through an old email that is still in inboxes, so late opt-outs are honoured here too. After that the old account can be closed. ## The evidence we carry Consent evidence lets you show, for each subscriber, when and how they signed up. Platforms differ in what they keep, and we only carry what the old platform actually recorded: | Platform | Signup date | Signup IP | Confirmation time | Signup source | Bounces / complaints | | --- | --- | --- | --- | --- | --- | | Mailchimp | Yes | Yes | Yes (double opt-in audiences only) | Yes | Bounces; complaints are folded into bounces | | MailerLite | Yes | Yes | Yes (double opt-in) | Yes | Both | | Kit | Yes | No | No | Yes (form, landing page, API, referrer, UTM) | Both | | Brevo | Yes | No | No | Double opt-in flag only | Both (each blacklisted address is looked up) | | EmailOctopus | Yes | No | No | Double opt-in flag only | No — not exposed by the API (unsubscribes only) | | CSV export | Usually | Rarely | Rarely | No | Only if exported separately | Each imported contact records the migration as its source, and the consent fields it arrived with are visible on the contact page. Your **migration log** lists the source platform, the scope reference, who ran the move and when, counts by status, the tags and field keys created, each import step's result and the final count check. Ask for it any time under your data-processing agreement. > **Nobody gets re-subscribed by a move.** Suppressed addresses are written first > and the import can never override them; an address on the suppression list only comes back > when the person signs up again through one of your forms. > ## Working copies and your data During the move we hold a working copy of your list: the exact files sent to your workspace. They live on an access-controlled machine, are never emailed or shared, and are **deleted once you confirm cutover**. Only the migration log, which contains counts and settings but no contact rows, is kept. The API key you gave us is revoked or returned at the same time. Ready to move? [Request a Move-in](https://sendbeam.io/move-in), or start with a [CSV import](https://sendbeam.io/docs/contacts/importing) if you would rather do it yourself. --- # Contacts: Contacts Manage your subscriber list — the foundation of your email marketing. Contacts are the people you send emails to. Every subscriber, customer, or lead you communicate with through SendBeam is stored as a contact in your workspace. Your contacts list is the foundation of your email marketing — the better your data, the better your results. SendBeam gives you a central place to view, search, and manage every contact across all of your lists, campaigns, and automations. Changes you make to a contact are reflected everywhere that contact appears. ## What is a contact? A contact is a single record representing one person. Each contact is uniquely identified by their email address — you cannot have two contacts with the same address in the same workspace. Every workspace on your account has its own contacts. Your plan's contact limit counts subscribed addresses across all of them: a person on two of your workspaces counts once, and contacts who unsubscribed, bounced or complained never count. Contacts can belong to one or more [lists](https://sendbeam.io/docs/lists), carry one or more [tags](https://sendbeam.io/docs/tags), and can be grouped dynamically using [segments](https://sendbeam.io/docs/segments). When you send a campaign, you choose which lists or segments receive it — SendBeam deduplicates automatically so no contact receives the same email twice. > Contacts are shared across the whole workspace. If a contact belongs to multiple lists and you > edit their profile, the update applies to all lists at once. > ## Contact fields Every contact record contains a set of standard fields plus any custom fields captured for that contact. ### Standard fields - **Email address** — required; must be unique within the workspace. - **First name** — optional; used in merge tags like `{"{{first_name}}"}`. - **Last name** — optional; used in the `{"{{last_name}}"}` merge tag. - **Status** — managed automatically from delivery events and unsubscribes, and editable by hand; see Contact statuses below. - **Source** — how the contact was added (manual, import, form, API). - **Created at** — timestamp when the contact was first added. - **Subscribed / unsubscribed at** — when the contact's status last changed. ### Custom fields Each contact can carry custom fields — free-form key/value pairs for data specific to your business, such as company name, plan or region. There is nothing to configure in advance: list the keys you want a [signup form](https://sendbeam.io/docs/forms) to capture, set them through the [API](https://sendbeam.io/docs/api) when creating or updating a contact, or edit them as JSON on the contact's edit page. Custom fields appear on the contact profile, can be used in [merge tags](https://sendbeam.io/docs/campaigns/merge-tags) as `{"{{custom_fields.key}}"}`, and can be filtered on in [segments](https://sendbeam.io/docs/segments). > Use consistent, lower-case keys such as `company` or `plan` across > forms and API calls, so segments and merge tags can rely on them. > ## Contact statuses Every contact has a subscription status that determines whether they can receive marketing emails from you. SendBeam manages these statuses automatically based on contact actions, but you can also change them manually. - **Subscribed** — the contact has opted in and will receive campaigns and automations sent to lists they belong to. - **Unsubscribed** — the contact has opted out, either by clicking an unsubscribe link or through a manual update. SendBeam will never send marketing emails to unsubscribed contacts. - **Bounced** — the contact's email address returned a hard bounce. SendBeam automatically marks these contacts to protect your sender reputation. - **Complained** — the contact marked one of your emails as spam. Like bounced contacts, these are suppressed automatically. > Manually re-subscribing a contact who previously bounced or complained is not recommended. > Sending to invalid addresses or spam reporters harms your deliverability and sender reputation. > ## What you can do with contacts From the Contacts section of SendBeam you can perform all of the following actions: - Add contacts one at a time using the manual add form. - Import contacts in bulk by uploading a CSV file. - Search contacts by name or email — the list updates as you type — and filter them by status, tag, list, source and the date they were added. - Edit individual contact profiles to update fields, custom fields or subscription status. - View a contact's campaign activity — which campaigns they were sent, and whether they opened or clicked. - Apply bulk actions to many contacts at once (add or remove a tag, export, delete). - Export contacts to a CSV file. Use the navigation links below to explore each topic in detail, or jump directly to a section from the left sidebar. --- # Contacts: Adding Contacts Add individual contacts to your workspace manually. You can add contacts to SendBeam one at a time using the manual add form. This is useful when you have a single new subscriber to enter, when adding a test contact, or when onboarding a small number of people without a CSV file handy. For adding many contacts at once, use the [CSV Import](https://sendbeam.io/docs/contacts/importing) feature instead — it is much faster for bulk operations. To add contacts from your own code, use the [API](https://sendbeam.io/docs/api). ## Adding a contact manually Follow these steps to add a new contact from within SendBeam: 1. Navigate to **Contacts** in the left sidebar. You will land on your full contacts list. 2. Click the **Add contact** button in the top-right corner of the page. 3. Enter the contact's **email address**. This is the only required field. The address must be a valid format, at most 254 characters, and must not already exist in the workspace. 4. Optionally fill in **First name** and **Last name** (up to 100 characters each). These are used in merge tags and make your emails feel more personal. 5. Choose a **Source** if you want to record where the contact came from (Manual, Import, API or Form). It defaults to Manual. 6. Click **Add contact**. The contact is created immediately with the status **Subscribed** and will appear in your contacts list. > New contacts added manually are given a status of **Subscribed**. If you need > the contact to have a different status, or want to set custom fields, open the contact and > click **Edit** straight after saving. > ## Required and optional fields The add contact form has one required field and a few optional ones: - **Email address (required)** — must be a valid email format of at most 254 characters. SendBeam checks for basic validity but does not send a verification email to the address when adding manually. - **First name (optional)** — up to 100 characters; used in personalisation merge tags. If left blank, `{"{{first_name}}"}` renders as empty text. - **Last name (optional)** — up to 100 characters; similarly used in the `{"{{last_name}}"}` merge tag. - **Source (optional)** — a label for where the contact came from. You can filter on it in segments. - **Language (optional)** — the language the person reads in. When a campaign is sent in more than one language, this decides which version they receive; contacts with no language get the campaign's own version. You can filter on it in segments. Nothing is guessed: it is set here, through the API, an import column or a form field named `language`. > You can always go back and fill in missing fields later by opening the contact's profile and > clicking **Edit**. Custom fields are edited there too, as a JSON object such as > `{"company": "Acme"}`. A contact holds a flat object of up to 50 custom > fields; each key is 1–64 characters and each value is text of up to 200 characters, a number > or true/false. Nested objects and arrays are not accepted. > ## Duplicate contacts SendBeam uses the email address as a unique identifier. If you attempt to add a contact with an email address that already exists in the workspace, the form will show an error and the new contact will not be created. Instead of creating a duplicate, you should: - Search for the existing contact using the search bar on the Contacts page. - Open their profile and update any fields that need changing. - Add them to any lists they should be on from the list's page. This prevents fragmented data and ensures all campaign history stays attached to one record. Addresses that previously unsubscribed, bounced, complained or were deleted are on the workspace's **suppression list**. Creating such a contact through the API is refused (`409`) and a CSV import brings it in as Unsubscribed. An earlier *unsubscribe* is cleared when the person signs up again through one of your [signup forms](https://sendbeam.io/docs/forms) (on confirmation, for double opt-in lists), or when you pass `resubscribe: true` on the API call or tick the re-subscribe box in the dashboard because they have asked you to. Bounced, complained and deleted addresses are never re-added this way. See [Suppression list](https://sendbeam.io/docs/contacts/suppressions). ## Adding to lists The add contact form does not assign lists. To put a contact on a list, open **Lists**, choose the list, and enter the contact's email address in the **Add Contact to List** box at the top of the list page. - The contact must already exist in the workspace. - If the list has [double opt-in](https://sendbeam.io/docs/lists/double-optin) enabled, the contact receives a confirmation email and only counts as a confirmed member once they click it. - Adding a subscribed contact to a list starts any active automation with the **List Joined** trigger for that list (on a double opt-in list, once they confirm). > Only add contacts to lists they have genuinely opted in to. Sending to contacts who have not > given permission can damage your sender reputation and may violate anti-spam regulations such > as CAN-SPAM and GDPR. > --- # Contacts: CSV Import Import contacts in bulk from a CSV file, with tags, custom fields, consent records and opt-out statuses. CSV import lets you add hundreds or thousands of contacts to SendBeam in a single operation. Whether you are moving an audience from another email platform, uploading a list from your CRM, or bringing in attendees from an event, the importer handles the heavy lifting — including tags, custom fields, the original opt-in dates, proof of consent and the people who have already unsubscribed. The import is simple: prepare a CSV with the right column headers, upload it, and review the result. Each stage is described below. ## Preparing your CSV file - The file must be in **CSV format** (`.csv` extension), **UTF-8 encoded**. Comma, semicolon and tab delimiters are detected from the header row. - The first row must be a **header row** with column names. - The file needs a column holding the **email address**. Header names are matched case-insensitively, with spaces and punctuation treated as underscores, so `Email`, `First Name` and `Opt-In Time` all work. Using the names below means the mapping screen is right first time; anything else you correct there in a click. - Quoted cells may contain commas, quotes (doubled) and line breaks. - The file may be at most **5 MB** — roughly 50,000 rows. Larger uploads are refused; split the CSV and import it in parts. > Download the **example file** from the import page to see exactly the > format SendBeam expects. It's the fastest way to get started without formatting errors. > ## Recognised columns | Column | What it does | | --- | --- | | `email` | **Required.** Up to 254 characters. `email_address` is accepted as an alias. | | `first_name`, `last_name` | Optional, up to 100 characters each; used by the `{"{{first_name}}"}` and `{"{{last_name}}"}` merge tags. | | `status` | `subscribed`, `unsubscribed`, `bounced`, `complained` or `pending`. Aliases: `active` → subscribed, `cleaned` → bounced, `cancelled` / `canceled` → unsubscribed, `junk` / `spam` → complained, `unconfirmed` → pending. A blank cell (or no column) takes the **default status** chosen on the import page. Rows marked `pending` are **never imported** — the person has not confirmed — and are counted as *pending rows*. Any other value makes the row invalid. | | `tags` | Tag names separated by `;` or `\|` (for example `vip;beta`). Up to 20 per row, names up to 60 characters. Tags are matched to existing tags case-insensitively and created when missing (at most 200 distinct new names per import). | | `subscribed_at` | The original opt-in time, kept as the contact's subscription date. Aliases: `opted_in_at`, `signup_at`, `optin_time`, `created_at`. Accepted formats: ISO 8601 (`2024-01-05T10:00:00Z`), `YYYY-MM-DD HH:MM:SS`, `YYYY-MM-DD`, `DD/MM/YYYY`, and Unix timestamps. Values without a time zone are treated as UTC. A date in the future is clamped to now; an unreadable value is treated as blank, which means "now". | | `unsubscribed_at` | When the person opted out (aliases `opted_out_at`, `unsub_time`). Used for unsubscribed, bounced and complained rows; when absent, the row's `subscribed_at` or the import time is used. | | `source` | A per-row source label (up to 40 characters) that overrides the source set on the import page. Rows with a blank cell take the page's value (default `import`). | | `language` | The language the person reads in, as a two-letter code (`fr`, `de`; `fr-FR` and `pt_BR` are reduced to the code). Aliases `lang`, `locale`, `language_code`, `preferred_language`. A value that is not a language is left blank rather than failing the row. On an existing contact it fills an empty language; it replaces one only when the import is set to update existing contacts. | | `consent_ip`, `consent_at`, `consent_source` | Proof of consent from your previous platform: the IP address the person signed up from, when they confirmed (aliases `confirm_time`, `opt_in_confirmed_at`; any of the date formats above), and the form or notice they saw. These are stored as the custom fields `consent_ip`, `consent_confirmed_at` (normalised to ISO 8601) and `consent_source`, so they travel with the contact and appear in exports. | | *any other column* | Becomes a **custom field** keyed by the lower-cased, snake_cased header (`Company Name` → `company_name`), available as `{"{{custom_fields.company_name}}"}` and in segment rules. Values are stored as text, cut to 200 characters; an empty cell stores nothing. A contact may hold at most 50 custom fields, so columns beyond that, columns whose key would be empty or repeated, and headers over 64 characters are ignored and listed under *columns ignored* in the result. | ### Sample file ``` email,first_name,last_name,status,tags,subscribed_at,source,consent_ip,consent_at,consent_source,company alice@example.com,Alice,Smith,subscribed,vip;beta,2024-01-05 10:00:00,footer-form,203.0.113.9,2024-01-05 10:02:11,Footer newsletter form,Acme Ltd bob@example.com,Bob,Jones,subscribed,,2023-11-20,,,,, carol@example.com,Carol,,unsubscribed,,2022-03-01,,,,, dave@example.com,Dave,,cleaned,,2021-07-14,,,,, erin@example.com,Erin,,pending,,2026-08-30,,,,, ``` This file creates Alice (tagged *vip* and *beta*, with her consent record and a `company` custom field), Bob, Carol as unsubscribed and Dave as bounced — both added to the suppression list with their original dates — and leaves Erin out because she never confirmed. A row whose email is missing, malformed or too long, whose name exceeds the limit, or whose status is not one of the values above, is counted as **invalid** and not imported. If the same address appears more than once in the file the rows are merged: the strongest status wins (complained over bounced over unsubscribed over subscribed) and tags and fields are combined. ## Statuses and the suppression list Rows with status `unsubscribed`, `bounced` or `complained` are imported as contacts with that status *and* recorded on the workspace's [suppression list](https://sendbeam.io/docs/contacts/suppressions). That is how you carry your opt-outs across: campaigns never reach them, and a later import can never bring them back as subscribed. They are a compliance record rather than audience, so they do **not** count towards your plan's contact limit — only rows that will be *subscribed* do. Addresses already on the suppression list — people who previously unsubscribed, bounced, complained or were deleted in this workspace — are imported as **unsubscribed** whatever the file says. They can only return by subscribing again through a signup form. ## Uploading the file 1. Go to **Contacts** in the sidebar, then click **Import** near the top of the page. 2. Drop your CSV file onto the upload area, or click it to browse for the file. 3. **Check the columns.** Every column in the file is listed with a few sample values and what it will become: a contact field (matched from the header — `Primary email`, a single `Name` column and a `Surname` are understood too), one of your existing custom fields (matched by key or label), or *Ignore*. A column SendBeam does not recognise is ignored until you map it, so a `MEMBER_RATING` or an internal id never becomes a field by accident. Choose **New custom field…** to keep a column as a field of its own, with a key and a **type** — a Birthday column becomes a Date field, a seats column a Number — and it is declared under [Settings → Custom fields](https://sendbeam.io/docs/contacts/custom-fields) as part of the import. Values that do not fit the type are left out of that row and reported; nothing is bent to fit. Exactly one column must be mapped to Email, and a field can be fed by one column only. 4. Choose the **default status** for rows without a status — *Subscribed* or *Unsubscribed*. Use Unsubscribed for a suppression list or an audience that has not opted in. 5. Optionally set a **source** label (default `import`) for rows that do not carry their own, so you can find this batch later in segments. 6. Optionally enter **tags for every contact** (separated by `;`) — handy for marking a migration batch — and pick a **list** to add the subscribed contacts to. On a double opt-in list (and every list in a Free-plan workspace) they are added as *awaiting confirmation*; the import never sends confirmation emails, so send them yourself from the list page if you need to re-confirm an audience. 7. Leave **Skip duplicate emails** ticked to keep existing contacts' names, or untick it to refresh their names and source from the file (see Handling duplicates below). 8. Leave **Run automations for these contacts** unticked unless you mean it. An import brings people in; it does not greet them. Tick it and every contact the file creates enters your **Contact created** automations, and every contact it adds to a list enters that list's **Joined a list** automations — which is almost never what you want when you are moving an existing list in, since those people were welcomed long ago somewhere else. The summary tells you how many enrolments it started. 9. Click **Import**. SendBeam checks the account's remaining contact allowance before it reads the file, reads only as many subscribed rows as the plan has room for, then creates the contacts in batches. Through the API (no mapping screen) the headers are read as described above and every unrecognised column becomes a custom field; send a `mapping` to get the same control as the screen — see the API reference. 10. The result appears on the same page when the import finishes. Keep the page open until it does. > Any active automation with the **Contact Created** trigger enrols every newly > created contact whose status is Subscribed, so pause a welcome sequence first if you do not > want it to fire for an existing audience — or import with the default status set to > Unsubscribed. A **List Joined** automation fires for contacts added to a > single opt-in list; on a double opt-in list it waits until they confirm. > > **Plan limit.** If the number of *subscribed* rows in the file is more > than the contact slots left on your plan, the whole import is refused with a message telling > you how many slots remain — even if some rows would have been skipped as duplicates. Rows > with an opt-out status never count. Split the file or upgrade under > [Billing & Plans](https://sendbeam.io/docs/admin/billing). > ## Handling duplicates When an email address in your CSV already exists in the workspace, the **Skip duplicate emails** option decides what happens to the contact's *names and source*: - **Ticked (recommended)** — the existing contact's first name, last name and source are left unchanged and the row is counted as a skipped duplicate. - **Unticked** — the existing contact's first name, last name and source are refreshed from the file. Use this when your CSV carries fresher names. Either way, the file's data is **added** to existing contacts: its tags are attached, its custom fields are merged in (keys the file carries take the file's value; other keys are kept), and a currently subscribed contact whose row says `unsubscribed`, `bounced` or `complained` is moved to that status and suppressed — an opt-out in the file is always honoured. The reverse never happens: an import cannot turn an unsubscribed contact back into a subscribed one, and a complaint is never downgraded to a plain unsubscribe. Contacts changed this way are counted as *updated*. Re-importing a file is therefore safe to repeat. ## Migrating from another platform Export your audience from the old platform including the unsubscribed and cleaned members and their signup dates, rename the headers to the names above where they differ (most exports call the address column *Email Address* and the opt-in date *OPTIN_TIME* or *CONFIRM_TIME*), and import the whole file in one go. Keeping the original `subscribed_at` and consent columns matters: they are your evidence that each person opted in, and the opt-out rows keep you from ever emailing someone who left. If you would rather we did the move with you, the [Move-in guide](https://sendbeam.io/docs/getting-started/moving-in) walks through the whole process — export, domain setup, import and the first send. ## Import from Mailchimp, MailerLite, Kit, Brevo or EmailOctopus If your audience is on Mailchimp, MailerLite, Kit, Brevo or EmailOctopus you can skip the export altogether. Go to **Contacts → Import → Import from another platform**, paste an API key from the old platform and click **Connect**. SendBeam checks the key with a single call and shows the account, its audiences / groups / tags / lists with subscriber counts, and exactly what will and will not come across. Pick what to bring over, optionally a tag for the batch and a SendBeam list, and click **Import**. ### Where to create the key - **Mailchimp** — profile icon → *Account & billing* → *Extras* → *API keys* → *Create A Key*. The key ends in a data-centre suffix such as `-us21`; SendBeam reads the data centre from it. - **MailerLite** (the new MailerLite, not Classic) — *Integrations* → *MailerLite API* → *Generate new token*. - **Kit** — *Settings* → *Developer* → *API Keys*; use the v4 key. - **Brevo** — in your account settings, under *SMTP & API* → *API Keys* → *Generate a new API key*. - **EmailOctopus** — under *Account* → *API*, create a new API key. None of the five platforms issues a truly read-only key, so create one just for the import and delete it on the old platform once you are done. SendBeam only ever reads with it: the key is held in your browser tab for the visit, sent with each request, and never stored, logged or displayed again. Nothing on the old platform is changed. ### What is imported - **Contacts** with email, first and last name and their status. Mailchimp `cleaned` → bounced; MailerLite `junk` → complained; Kit `cancelled` → unsubscribed. Archived and transactional Mailchimp members are skipped. - **Unsubscribed, bounced and complained** addresses are recorded on the [suppression list](https://sendbeam.io/docs/contacts/suppressions) *before* any contact is written and are imported as contacts with that status. They never count towards your contact limit. - **Unconfirmed / pending** people (Mailchimp `pending`, MailerLite `unconfirmed`, Kit `inactive`, EmailOctopus `pending`) are counted but never imported — they did not confirm. - **Original opt-in date** as `subscribed_at`, and — where the platform exposes it — the **consent evidence**: IP address, confirmation time and signup source, stored as the custom fields `consent_ip`, `consent_confirmed_at` and `consent_source`. Mailchimp and MailerLite provide these; Kit provides the signup source (form, landing page, API, referrer, with UTM values as custom fields) but no IP or confirmation time; Brevo's consent IP is only known for people who later unsubscribed; EmailOctopus provides neither IP nor confirmation time, only the signup source on a double opt-in list. A Mailchimp confirmation time is only carried for double opt-in audiences; on single opt-in audiences the opt time is kept as `mailchimp_timestamp_opt`. MailerLite's `unsubscribed_at` is carried as the opt-out time. - **Tags**: Mailchimp tags, MailerLite groups, Kit tags, Brevo lists and EmailOctopus tags become SendBeam tags (created when missing). Kit tags arrive with each subscriber, so any number of tags is mapped in one pass. Mailchimp audience group interests become `interest:` tags, and members with more than 50 tags have their full set fetched separately (up to 40 such members per run; the rest carry the custom field `mailchimp_tags_truncated` and the result reports *tags truncated*). - **Custom fields**: merge fields, MailerLite fields, Kit fields, Brevo attributes and EmailOctopus fields become custom fields keyed by their snake_cased name (up to 30 per contact, values cut to 200 characters; address-type fields are dropped). Mailchimp GDPR marketing permissions become `mc_permission_` fields. ### What is not - Campaign history, templates, automations, forms, landing pages, segments and statistics stay on the old platform. - Mailchimp does not expose spam complaints separately (they are folded into `cleaned`); Kit exposes no consent IP or confirmation timestamp; paid Kit newsletters are not carried over; EmailOctopus does not expose bounces or complaints as contact statuses at all (they only appear in campaign reports), so those addresses arrive as subscribed or unsubscribed. - Nothing is deleted or modified on the old platform, and no email is sent to anyone by the import. ### Caps and re-running A run reads the source one page at a time and writes each page as it goes, so a large audience never sits in memory. Each run handles up to **25,000 contacts** (rounded up to the end of the source page in progress), makes at most 250 requests to the old platform and runs for at most about 45 seconds. When a cap is hit — or your plan has no room for the next page of subscribed contacts — the run stops *between* pages, reports everything written so far with `truncated: true`, and offers **Continue import**, which resumes from exactly that page. Re-running is always safe: contacts already in the workspace are skipped as duplicates, tags and custom fields are added, an opt-out in the source always wins over a subscribed contact here, and a stronger suppression reason is never downgraded. The same audience can be imported again later to pick up new signups. The two calls behind the page are available to your own code with an API key that has `contacts:write` (Pro or Business): `POST /api/v1/imports/connect` with `{"source", "credentials": {"api_key"}}`, then `POST /api/v1/imports/run` with the same plus `list_ids` and `options` (`tags`, `list_id`, `max_contacts`, `cursor` from a previous run's `next_hint`). See the [API reference](https://sendbeam.io/docs/api#post-api-v1-imports-run). ## Addresses that cannot receive mail Two kinds of address are skipped before a row becomes a contact. The first needs no lookup: **placeholders** that can never belong to a subscriber — anything on `example.com`, `example.net` or `example.org` (or a subdomain of them), anything on a reserved `.test`, `.invalid`, `.localhost` or `.example` domain, and the `abuse@` and `postmaster@` mailboxes that every domain runs for its mail administrators. Sample rows left in an export and addresses scraped from a contact page are the usual sources. Each one is skipped whatever `check_domains` says, and the summary names them (the first twenty) with the reason, so you can fix the file rather than wonder why a row is missing. A campaign's Review step and its page make the same check on the audience and list any that are already contacts. Every import also checks the domain of each remaining address. A domain that does not exist, that publishes a "null MX" (an explicit *no mail here*), or that has neither mail servers nor an address of its own cannot be delivered to, so those rows are skipped and the summary lists the domains with the most rows lost — typos like `gmial.com` and companies that have closed are the usual culprits. Only a definite DNS answer skips a row; a slow or failing lookup lets the row through. Well-known providers are never looked up, and an import with more than 500 distinct domains checks the first 500. This is a domain check, not a mailbox check: a real domain with a mistyped local part still imports and bounces later, at which point the contact is marked *bounced* and suppressed as usual. Pass `check_domains=false` on the API to skip the check. ## Import results summary Once your import finishes, SendBeam displays a summary so you can verify everything went as expected: - **Contacts imported** — new contacts created. - **Existing contacts updated** — contacts that already existed and received custom fields, a refreshed name or an opt-out status from the file. - **Duplicates skipped** — rows whose email address already existed in the workspace. - **Added to the suppression list** — unsubscribed, bounced and complained addresses recorded so they are never mailed. - **Pending rows not imported** — rows with status `pending`. - **Invalid rows** — rows dropped because the email was missing or malformed, a value exceeded its limit, or the status was unrecognised. - **Tags attached** (and new tags created) — contact–tag links made by this import. - **Custom fields** — the keys the file mapped, and **columns ignored** — headers that could not become custom fields. - **Added to the list** — confirmed memberships, or memberships awaiting confirmation on a double opt-in list. The same import is available to your own code: `POST /api/v1/contacts/import` with a multipart body containing `file` and the optional `default_status`, `source`, `skip_duplicates`, `run_automations` (`false` unless you send `true`), `tags` and `list_id` fields, authenticated with an API key that has `contacts:write` (Pro or Business). The response carries `imported`, `updated`, `skipped_duplicates`, `suppressed`, `skipped_unconfirmed`, `invalid`, `skipped_undeliverable`, `undeliverable_domains`, `skipped_placeholder`, `placeholder_addresses`, `tags_created`, `tags_attached`, `custom_field_keys`, `dropped_columns`, `list_added`, `list_pending_confirmation` and `truncated`; see the [API reference](https://sendbeam.io/docs/api#post-api-v1-contacts-import) for every field. A file over 5 MB is refused with `413`. If none of the rows are usable, the import fails with a message asking for an `email` column header and at least one data row. SendBeam does not keep an import history, so note the summary if you need it for your records. --- # Contacts: Editing Contacts Update contact details, custom fields and subscription status. Every contact in SendBeam has a profile page where you can view all of their information and an edit page where you update it. Whether you need to correct a misspelled name, fill in a custom field, or manually adjust a subscription status, this is where you do it. ## Opening a contact profile You can reach a contact's profile from several places in SendBeam: - From the **Contacts** list: click the contact's name or email to open their profile. - From a **list's page**: contacts listed as members link to their profile. - From the **Dashboard**: the Recent Contacts panel links to each profile. The profile shows the contact's details, tags, lists, custom fields and activity. To edit the contact: 1. Click the **Edit** button at the top of the profile page. The edit page opens with the current values filled in. 2. Make your changes to any of the fields. You can update several fields before saving. 3. Click **Save**. You are returned to the profile with the new values. 4. To discard your changes without saving, use the back arrow instead. > Changes to a contact take effect immediately. Segments are evaluated when they are used, so > a campaign sent after the edit sees the updated data. > ## Editable fields The following fields can be edited from the contact's edit page: - **Email address** — you can correct a typo or update an address. The new address must not already belong to another contact in the workspace. - **First name** — used in `{"{{first_name}}"}` merge tags throughout your campaigns. - **Last name** — used in `{"{{last_name}}"}` merge tags. - **Status** — Subscribed, Unsubscribed, Bounced or Complained. See below. - **Custom fields** — one input per field declared under Settings → Custom fields, matching its type: a date picker for a Date field, Yes/No for a yes/no field, a list for a dropdown, a number box for a number. A value that no longer fits its field's type is shown as it is with a *Does not fit* mark and must be corrected or cleared to save. Each field is available as a `{"{{custom_fields.key}}"}` merge tag and in segment rules — see [Custom fields](https://sendbeam.io/docs/contacts/custom-fields). Tags and list memberships are not edited here. Add or remove tags from the [Contacts list](https://sendbeam.io/docs/tags/tagging-contacts) (select the contact, then Add tag or Remove tag), and manage list membership from the [list's page](https://sendbeam.io/docs/lists/managing-members). The profile shows the contact's current tags and lists, with a link to each list. > The **Source**, **Created**, **Subscribed** and > **Unsubscribed** timestamps shown on the profile are set automatically and > cannot be edited. > ## Changing subscription status A contact's status controls whether they can receive emails from you. You can change it by hand when necessary — for example, to re-subscribe someone who unsubscribed by mistake, or to mark a contact as unsubscribed at their request. To change the status: 1. Open the contact profile and click **Edit**. 2. Choose the new value in the **Status** dropdown. 3. Choosing **Unsubscribed** reveals **Also block this address so it can never be re-added**, ticked by default. Leave it ticked unless you have a reason not to (see below). 4. Click **Save**. The status updates immediately and applies to every list this contact belongs to — only **Subscribed** contacts receive campaigns and automation emails. ### Marked versus blocked **Unsubscribed** on its own is a status: it keeps the contact out of every campaign and automation, but nothing stops the address coming back. A later CSV import that lists it as subscribed, an API call, or the person filling in one of your signup forms would make it a subscriber again. **Blocking** puts the address on the workspace's [suppression list](https://sendbeam.io/docs/contacts/suppressions), which every one of those paths checks: an import brings it in as unsubscribed, the API refuses with `409`, and only the person themselves — opting in again through a form — can lift it. The contact profile shows which of the two you have. A blocked address carries a **Suppressed** badge next to its status, with the reason (`unsubscribed`, `bounced`, `complained` or `deleted`). A contact that is unsubscribed but not blocked says so, with a **Block this address** link that does it in one click. Untick the box only when the unsubscribe is temporary — a pause you expect to undo yourself, say. The same choice exists everywhere a status is set: the bulk **Unsubscribe** action on the Contacts page offers the box for the whole selection, and the API takes `suppress: true` alongside `status: "unsubscribed"` (off unless you send it — see the [API reference](https://sendbeam.io/docs/api#patch-api-v1-contacts-id)). ### Re-subscribing a blocked contact Setting a blocked contact back to **Subscribed** is refused unless the block is an `unsubscribed` one and you tick **They asked to be re-subscribed — lift the block**, which appears under the Status field when you choose Subscribed. Tick it only when the person has told you they want your emails again; saving removes the address from the suppression list and sets the status. A `bounced`, `complained` or `deleted` block cannot be lifted this way — the address is dead, the person reported you, or the record was removed — and the page says which (a workspace admin can lift a `deleted` block from the [Suppressions page](https://sendbeam.io/docs/contacts/suppressions#lift)). Changing a contact's email address to one that is blocked follows the same rule. > Re-subscribing a contact who previously marked your email as spam (**Complained** > status) is strongly discouraged. Sending to complaint contacts significantly increases the > risk of further spam reports, which can damage your deliverability and can get the > workspace's sending paused. > ## Viewing engagement history The **Activity Timeline** on the right of every contact profile lists the campaigns the contact has been sent, newest first. Each entry shows the campaign name and subject, the delivery status, and whether the contact opened or clicked. This gives you a quick picture of how engaged the contact is. The timeline is read-only. For a full per-message log across the workspace, including automation emails, see the **Log** tab under [Reports](https://sendbeam.io/docs/analytics). ## Archive, delete or erase Three ways to take a contact out of your audience, each doing something different to the address. The profile's buttons say which is which before anything happens. - **Archive** — the contact leaves the working list: hidden from the Contacts page (choose the *Archived* status filter to see them), never mailed, not counted against your plan. The address is *not* blocked. **Restore** brings the contact back with the status it had; a signup, an import or `POST /api/v1/contacts` at the same address restores it too. Use this for "out of the way, might come back" — a test contact, a paused customer, a duplicate you are not sure about. - **Delete** — the record and its send history are removed (the email log keeps an anonymous placeholder). The address goes on the suppression list as `deleted`, so a later import or API call cannot quietly add it back as subscribed. A workspace admin can lift that block from the [Suppressions page](https://sendbeam.io/docs/contacts/suppressions#lift) if the deletion was a mistake, and the person can always return through a signup form. - **Erase** — for a request to be forgotten. The same removal, but the suppression row keeps a one-way hash alone (no masked form, no domain) and can never be lifted from the app. Only the person can return, by subscribing again themselves. Through the API, `DELETE /api/v1/contacts/{id}` is a delete, `?mode=erase` an erasure, and `PATCH` with `status: "archived"` an archive; the bulk endpoint takes `archive`, `restore`, `delete` and `erase`. Deleting or erasing frees the contact's slot in your plan's limit; so does archiving, since only subscribed contacts count. --- # Contacts: Custom Fields Declare the fields your contacts carry, give each a type, and use them everywhere by one name. Beyond name and email, a contact can carry any number of custom fields — a plan, a renewal date, a region, whether they want the weekly digest. **Settings → Custom fields** is where those fields are declared: each has a key, a label and a type, and the page shows how many contacts carry each one. ## What a custom field is A field has three parts: - **Key** — the machine name, letters, digits and underscores only, for example `renewal_date`. It is what you type everywhere: `{{custom_fields.renewal_date}}` in an email, `custom_fields.renewal_date` in a segment or automation rule, `renewal_date` as a CSV column header, a form input or an API key. - **Label** — what the app shows: on the contact page, in pickers, on forms. Change it whenever you like. - **Type** — what a value may be. See below. ## Managing fields Open **Settings → Custom fields**. The table lists every declared field with its label, key, type and how many contacts have a value. **Add a field** takes a key, a label (filled in from the key) and a type; **Edit** in a row's **⋯** menu changes its label, type or options; **Delete** removes it. Keys your contacts already carry that no field names are listed under *Keys in use without a field*. **Scan contacts** declares them all at once, inferring a type only where every stored value agrees (all yes/no, all numbers, all dates) and using Text otherwise — a scan never picks a type a stored value would break. ### Types - **Text** — anything up to 200 characters. - **Number** — a number. Segments compare it numerically ("is greater than 3"). - **Yes / No** — true or false. The contact page offers Yes, No or not set; the API takes a boolean or `yes`/`no`/`true`/`false`. - **Date** — a real calendar date, stored as `YYYY-MM-DD`. The contact page shows a date picker; the API and CSV imports also accept an ISO date-time (its calendar date is kept) and `DD/MM/YYYY`. This is the type the *Anniversary of a date* and *On a specific date* automation triggers read, and their date-field picker offers only Date fields. - **Dropdown** — one of a fixed list of options you write, one per line. Values must match an option exactly (case is corrected for you). ### Keys cannot be renamed Once a field exists its key is fixed. The key is embedded in every email you have sent or drafted, every template, every form, every segment and automation rule, every contact's record and every integration that writes to the API — most of which a rename here could not reach, and a half-renamed field would be worse than none. Change the **label** instead: it is what people see. If a key really must change, add a new field and delete the old one. ### Changing a type Changing a field's type never alters stored values. If some contacts hold a value that does not fit the new type — "soon" in what is becoming a Date field — SendBeam tells you how many (with examples) and asks before changing anyway. Those values are kept exactly as they are, shown on the contact page with a *Does not fit* mark, and asked for again the next time that contact is saved. Segments and automations keep comparing the stored text as before. Through the API the same request is refused with `409` and a `violations` count until you send `confirm_violations: true`. ### Deleting a field Deleting a field removes it **and its value from every contact**, and takes it off any signup form that collected it. Segment rules, automation rules and merge tags that name it are left in place and simply stop matching or resolving — the pre-send check will point them out. This cannot be undone. A key that an integration keeps sending comes back as a new Text field the next time a value arrives. ## Where values come from Every way a value can arrive checks it against the field's type and says what is wrong: - **The contact page** — each field has the right input: a date picker, Yes/No, a list, a number box. - **CSV import** — a cell that does not fit is left out of that row (the row itself is still imported) and the import summary says which column, how many rows and an example. - **Signup forms** — the embed renders a date picker, a checkbox, a select or a number box to match, and the visitor is told which field needs fixing. - **The API** — `400` with `custom_fields. must be …`. See the [API reference](https://sendbeam.io/docs/api), *Custom fields*. - **Automations** — a *Set Field* step offers only values of the field's type; a value that does not fit is reported on the automation rather than stored. A key that has not been declared — a new CSV column, a new field on a form, a key an integration starts sending — is registered automatically as a Text field, so nothing breaks and the key becomes visible in Settings, where you can give it a type. ## Using a field - **Emails** — the merge-tag bar in the campaign wizard and the template editor lists your fields; one click inserts `{{custom_fields.key}}`. Add a fallback after a pipe: `{{custom_fields.plan|free}}`. The pre-send check flags a tag that names a field that does not exist and suggests the one you probably meant. - **Segments** — pick the field from the list; the value box matches its type. See [Using segments](https://sendbeam.io/docs/segments/using). - **Automations** — conditions, trigger filters, the *Field changes* trigger, the date triggers and *Set Field* all pick from the same list. Activation warns about a rule that names a field that does not exist. - **Forms** — tick the fields a signup form should collect; they appear as inputs on the embed and the hosted page. --- # Contacts: Bulk Actions Unsubscribe, tag, export, archive or delete many contacts at once. Bulk actions let you perform operations on many contacts at the same time without having to open each profile individually. Whether you need to tag a group of your audience, export a selection, or clean up old contacts, bulk actions make it fast and straightforward. Bulk actions are available from the main **Contacts** list page. They apply to the contacts you have ticked, or — with one more click — to every contact matching the current search, status, tag, list, source and date-added filters. ## Selecting contacts There are three ways to build your selection before applying a bulk action: - **Individual checkboxes** — click the checkbox at the left of any row to add that contact to the selection. Click again to deselect. - **Select all on page** — click the checkbox in the table header row to select every contact on the current page (20 contacts per page). Useful when you want most contacts on the page but not all — select all, then deselect the exceptions. - **Select all matching** — after ticking the header checkbox, a banner appears offering **Select all N matching**. Click it and the bulk action applies to every contact matching your current filters — search, status, tag, list, source and date added — across all pages (up to 50,000 contacts). > Use the **search** box and the **status**, **tag**, > **list**, **source** and **date added** filters to > narrow the list before selecting. For example, filter by status "Bounced", tick the header > checkbox and choose **Select all matching** to delete every bounced contact in > one go; filter by tag `event-2026` to add a second tag to that whole cohort; or > filter by source and a date range to archive everyone an old import brought in. > Once you have a selection, the bulk action toolbar appears above the table showing the number of selected contacts and the available action buttons. A per-page selection is cleared when you move to another page; a "select all matching" selection covers every page. ## Available bulk actions The following actions can be applied to your selection: - **Unsubscribe** — every selected contact who is subscribed becomes Unsubscribed; contacts already opted out are left alone. The menu offers **Also block these addresses so they can never be re-added**, ticked by default: with it, every selected address goes on the [suppression list](https://sendbeam.io/docs/contacts/suppressions) too, so no later import, API call or signup form brings them back. Without it the unsubscribe is a status only. The confirmation says which you chose, and the result says how many were unsubscribed, how many were already opted out, and how many addresses were blocked. - **Add tag** — pick a tag from the dropdown to apply it to every selected contact. Contacts that already have the tag are unaffected (no duplicates are created). Adding a tag starts any active automation with the **Tag Added** trigger for that tag, for the selected contacts who are subscribed. - **Remove tag** — pick a tag to remove from every selected contact. Contacts that do not have the tag are silently skipped. - **Add to list** — pick a list to put every selected contact on. Only subscribed contacts join; anyone unsubscribed, bounced or complained is skipped and the result tells you how many. On a double opt-in list the membership is held as pending — no confirmation email is sent by a bulk add. Joining starts any active automation with the **List Joined** trigger for that list. - **Remove from list** — pick a list to take every selected contact off. Contacts that are not on the list are silently skipped. The contacts themselves are kept. - **Export** — download the selected contacts as a CSV file. See the [Exporting](https://sendbeam.io/docs/contacts/exporting) page for full details on the export format. - **Archive** — take the selected contacts out of the working list without blocking their addresses: hidden from this page, never mailed, not counted against your plan. Choose the *Archived* status filter to see them; the button becomes **Restore** there and puts them back with the status they had. See [Archive, delete or erase](https://sendbeam.io/docs/contacts/editing#archive-delete-erase). - **Delete** — permanently remove all selected contacts from the workspace and block their addresses. This action cannot be undone and requires confirmation (see below). A single contact can also be added to a list by email from the list's own page — see [Managing members](https://sendbeam.io/docs/lists/managing-members). From your own code, `POST /api/v1/contacts/bulk` takes either a `contact_ids` array or a `filter` object (`q`, `status`, `tag`, `list`, `source`, `from`, `to`) to act on everything that matches. > Bulk actions run immediately and the page reloads when they finish. > ## Confirmation for destructive actions Actions that cannot be reversed require an explicit confirmation before they execute. This prevents accidental data loss when working with large selections. Currently, **Delete** is the only destructive bulk action. When you click Delete: 1. A confirmation dialog appears stating how many contacts you are about to delete and that it cannot be undone. With "select all matching" this is everyone matching the filter, so read the number carefully. 2. Click **Delete and block** to proceed. The contacts are removed from the workspace permanently, along with their tags, list memberships and automation enrolments. Each address is added to the workspace's suppression list (reason `deleted`) and replaced in the email log with an anonymous placeholder, so it cannot be re-added by an import or the API. A workspace admin can lift that block from the [Suppressions page](https://sendbeam.io/docs/contacts/suppressions#lift); the person can always return through a signup form. An *erasure* (a request to be forgotten) is done per person from the contact's own page. > Deleted contacts cannot be recovered. If you think you might need the data later, > **Archive** instead, or [export](https://sendbeam.io/docs/contacts/exporting) before > deleting. Deleting or archiving frees the contact's slot in your plan's contact limit. > --- # Contacts: Exporting Contacts Download your contacts as a CSV file. SendBeam lets you export contacts to a CSV file at any time. Exports are generated on demand and downloaded directly to your browser. Exports contain every contact field plus tags, lists and custom fields, making them useful for backups, migrations to other platforms, offline analysis, or sharing a list with a third-party tool. ## Exporting from the Contacts page To export every contact, or every contact matching a search or filter: 1. Navigate to **Contacts** in the left sidebar to open the contacts list. 2. Optionally narrow the list with the search box and the status, tag, list, source and date-added filters. Leave them all clear to export everyone. 3. Choose **Export CSV** from the **⋯** menu at the top of the page. A CSV of *all* contacts matching the current search and filters downloads — not just the page you are looking at. > From your own code, `GET /api/v1/contacts/export?q=&status=&tag=&list=&source=&from=&to=` > returns the same CSV (an API key with `contacts:export` — reading contacts and downloading > them as a file are separate permissions, so a key with only `contacts:read` can look > people up and page through them but cannot use this endpoint); `tag` and `list` take > IDs, `from` and `to` are dates (YYYY-MM-DD). The same seven parameters filter > `GET /api/v1/contacts`. For a complete copy of the whole workspace — > lists, templates, campaigns and so on as well as contacts — an admin can download the JSON > export from [Workspace settings → General → Your data](https://sendbeam.io/docs/admin/settings#your-data). > ## Exporting selected contacts Use the checkboxes to select specific contacts on the page (see [Bulk Actions](https://sendbeam.io/docs/contacts/bulk-actions) for details on selecting contacts), then click **Export** in the bulk action toolbar that appears. Only the ticked contacts are exported, with their tags and custom fields (list memberships are only in the full export). If you choose **Select all matching** instead, the export uses the full export with every column. > Exports are generated fresh each time you request one. If you export now and export again in > an hour after adding new contacts, the second file will include those additions. > ## CSV format and columns Exported CSV files use standard comma-separated formatting with every value quoted, in UTF-8, so they open in Excel, Numbers and Google Sheets. A value that begins with `=`, `+`, `-` or `@` is prefixed with a single quote so the spreadsheet shows it as text rather than running it as a formula. The first row is a header row. The columns of the full export are: - `email` — the contact's email address. - `first_name` — first name, or empty if not set. - `last_name` — last name, or empty if not set. - `status` — subscription status: `subscribed`, `unsubscribed`, `bounced`, or `complained`. - `source` — how the contact was added (e.g. `manual`, `import`, `form`, `api`). - `tags` — the contact's tag names, separated by semicolons. - `lists` — the names of the lists the contact belongs to, separated by semicolons. - `created_at`, `subscribed_at`, `unsubscribed_at` — ISO 8601 timestamps. - `custom.` — one column per custom field key found on any exported contact, for example `custom.company`. The selected-rows export has the same shape without the `lists`, `subscribed_at` and `unsubscribed_at` columns. ## When to use exports Exports are useful in many common scenarios: - **Backup** — take a copy of your contacts as a safety net before major changes such as bulk deletes or large imports. - **Platform migration** — if you are moving to or from another email marketing tool, an export gives you a portable copy of your subscriber data. - **Offline analysis** — import the CSV into a spreadsheet or data tool to run custom reports on your audience data. - **Compliance requests** — if a subscriber requests a copy of their data under GDPR or similar regulations, search for them, select the row and export it to share with them. > Exported CSV files contain personal data including email addresses. Store them securely and > in accordance with your privacy policy. Do not share export files publicly or via unencrypted > channels. Delete them once they are no longer needed. > --- # Contacts: Suppression list Addresses that are never emailed, and how to bring your old platform's unsubscribes, bounces and complaints across before you send. Suppressed addresses are never emailed, whatever list or segment they are in. The suppression list is where SendBeam remembers everyone who unsubscribed, bounced, complained or was deleted, so that a later import or API call cannot quietly bring them back as a subscriber. ## What the suppression list is Every workspace has one suppression list. An address lands on it when: - a subscriber clicks **unsubscribe** in one of your emails (`unsubscribed`); - you set a contact to Unsubscribed with **Also block this address** ticked — on the edit page, in the bulk **Unsubscribe** action, or with `suppress: true` on the API (`unsubscribed`). Without that, an owner-set unsubscribe is a status only and does not touch this list — see [marked versus blocked](https://sendbeam.io/docs/contacts/editing#marked-vs-blocked); - a message to them hard-bounces (`bounced`); - they report a message as spam (`complained`); - you delete the contact (`deleted`), so the address does not come back on the next import; - you import it, from the dashboard or the API, as described on this page. The list is enforced wherever an address could otherwise become a subscriber again: a [CSV contact import](https://sendbeam.io/docs/contacts/importing) brings a suppressed address in as `unsubscribed` whatever the file says, `POST /api/v1/contacts` refuses it with `409`, and editing a contact's email to a suppressed address or setting a suppressed contact's status back to `subscribed` is refused the same way. Campaigns and automations only ever go to contacts whose status is `subscribed`. The one way off the list is a fresh opt-in from the person themselves, and only for an unsubscribe — see [When someone subscribes again](#resubscribe). Reasons have a strength order — `complained` beats `bounced`, which beats `unsubscribed`, which beats `deleted`. A stronger reason replaces a weaker one; a weaker one never downgrades a stronger one. Importing the same file twice is therefore harmless. ## Import it before your first send When you move to SendBeam from another platform, load its unsubscribes, bounces and complaints *before* you import contacts and long before you send. Your previous platform kept those people out of your sends for you; a fresh export of "all contacts" often includes them with no warning, and one campaign to a few hundred old complainers is enough to damage a sending domain's reputation. 1. Export the unsubscribed, cleaned/bounced and complained (junk/abuse) lists from your old platform. Most tools offer these as separate CSV downloads or as a status column in the main export. 2. Import them here — **Contacts → Suppressions** in the dashboard, or `POST /api/v1/suppressions`. 3. Then [import your contacts](https://sendbeam.io/docs/contacts/importing). Anyone on the suppression list arrives as `unsubscribed` automatically. > Already imported contacts? Importing suppressions afterwards still works: any matching contact > who is still `subscribed` is switched to the imported status at the same time. No > contact is ever created by a suppression import. > ## Importing from the dashboard 1. Open **Contacts**, then **Suppressions** in the **⋯** menu at the top of the page. The summary shows how many addresses are on the list, by reason. 2. Drop your CSV on the upload area (up to 5 MB per file — split larger exports). 3. Choose the reason to apply to rows that do not carry one. It defaults to **Unsubscribed**; pick **Bounced** or **Complained** when you are uploading a dedicated bounce or complaint export. 4. Click **Import suppressions**. The result tells you how many addresses were added, upgraded to a stronger reason, already on the list, or skipped as invalid, and how many existing contacts were switched from subscribed. ## CSV format A UTF-8 CSV with a header row. The `email` column is required (`email_address` is also accepted); `reason` (or `status`) is optional. Headers are case-insensitive. A file that is just one address per line, with no header, is accepted too. ``` email,reason alice@example.com,unsubscribed bob@example.com,cleaned carol@example.com,spam dave@example.com, ``` Reason cells are matched case-insensitively and the words other platforms use are understood: - `unsubscribed` — also `unsubscribe`, `cancelled`, `canceled`, `opted out`, `optout`. - `bounced` — also `bounce`, `cleaned`, `hard bounce`, `invalid`. - `complained` — also `complaint`, `junk`, `spam`, `abuse`. A blank reason takes the default you chose (the `reason` form field for the API; `unsubscribed` when none is given). A row whose reason is anything else, or whose address is missing, malformed or over 254 characters, is skipped and counted as `invalid`. `deleted` cannot be imported; it is reserved for contacts you delete in SendBeam. Repeated addresses in one file are folded together, keeping the stronger reason. ## Importing with the API `POST /api/v1/suppressions` takes either JSON or the same CSV as a multipart upload. It needs an API key with `contacts:write` (available on every plan; writes are metered per hour, like every other write). Reasons and aliases are the same as for the CSV. **JSON** — up to 5,000 entries per request; send several requests for more. `reason` on an entry is optional, and a top-level `reason` sets the default for entries without one: ``` curl -X POST https://sendbeam.io/api/v1/suppressions \ -H "x-api-key: sb_live_..." \ -H "Content-Type: application/json" \ -d '{ "reason": "unsubscribed", "entries": [ { "email": "alice@example.com" }, { "email": "bob@example.com", "reason": "bounced" }, { "email": "carol@example.com", "reason": "complained" } ] }' ``` **CSV** — the file goes in a field named `file` (5 MB max); an optional `reason` field is the default for blank cells: ``` curl -X POST https://sendbeam.io/api/v1/suppressions \ -H "x-api-key: sb_live_..." \ -F "file=@bounces.csv" \ -F "reason=bounced" ``` Both return the same summary: ``` { "received": 3, "added": 2, "upgraded": 1, "unchanged": 0, "invalid": 0, "contacts_updated": 1, "by_reason": { "unsubscribed": 1, "bounced": 1, "complained": 1 } } ``` - `received` — rows or entries in the request. Always equals `added + upgraded + unchanged + invalid`. - `added` — addresses that were not on the list before. - `upgraded` — addresses already on the list whose reason became stronger. - `unchanged` — already on the list with the same or a stronger reason (repeats within the request count here too). - `invalid` — rows skipped for a bad address or an unknown reason. - `contacts_updated` — existing contacts switched from `subscribed` to the imported status (`unsubscribed_at` is set to now). - `by_reason` — the valid, de-duplicated addresses split by the reason that was applied. Full request and response schemas are in the [API reference](https://sendbeam.io/docs/api#post-api-v1-suppressions). ## Checking an address and counts `GET /api/v1/suppressions` (key with `contacts:read`) returns the size of the list by reason — the same figures the dashboard shows: ``` curl https://sendbeam.io/api/v1/suppressions -H "x-api-key: sb_live_..." { "count": 1204, "by_reason": { "unsubscribed": 1130, "bounced": 61, "complained": 9, "deleted": 4 } } ``` Add `?email=` to ask about one address: ``` curl "https://sendbeam.io/api/v1/suppressions?email=bob@example.com" -H "x-api-key: sb_live_..." { "email": "bob@example.com", "suppressed": true, "reason": "bounced", "created_at": "2026-09-03T09:12:41.000Z" } ``` An address that is not on the list returns `{ "email": "...", "suppressed": false }`. The dashboard's **Check an address** box calls the same endpoint. ## Browsing and exporting the list The **Browse the list** table on the Suppressions page shows every row, newest first: a masked address (`j***@example.com`), its domain, the reason, where the suppression came from, and when it was added. Filter by reason or by domain, and click **Export CSV** to download the rows shown. Rows recorded before 14 September 2026 show "—" for the address and domain: only their hash was ever kept, and SendBeam does not guess at the rest. Where a contact record still carries the address (an unsubscribed or bounced contact you have not deleted, say) the row links to it, and the export fills in the full address for that row. The **source** column says how the address got there: *Unsubscribe link* (the person clicked it or used the preference page), *Delivery report* (a bounce or spam report from the mailbox provider), *Import*, *Blocked in app* (you set Unsubscribed with the block box ticked), *Contact deleted*, or *Erased on request*. From a script: `GET /api/v1/suppressions/list` pages the rows (`page`, `limit`, `reason`, `domain`) and `GET /api/v1/suppressions/export` returns the CSV — columns `email` (only where a contact record still exists), `email_masked`, `domain`, `reason`, `source`, `added_at`, `contact_id`. The list needs `contacts:read`; the export needs `contacts:export`, the same permission as the [contacts export](https://sendbeam.io/docs/contacts/exporting). ## Lifting a block A suppression is a record of what a person or a mailbox told you, so most rows are never removed by anyone in the workspace. The one exception is the block a **deleted contact** leaves behind: deleting a test contact, a duplicate or a colleague's old address should not mean the address is lost for good. A workspace admin can click **Lift block** on such a row (or send `POST /api/v1/suppressions/lift` with the row's `email` or `email_hash`), after which an import, the API or a signup form can add the address again. The deleted contact itself is not restored. - `bounced` and `complained` rows are never lifted: the address is dead, or the person reported you. - An `unsubscribed` row is the person's decision: it is lifted only by their own new opt-in, or by you asserting their new consent through [re-subscribe](https://sendbeam.io/docs/contacts/editing#re-subscribing). - An address **erased** at the person's request (the *Erase* action on a contact) is never lifted from the app; only the person can return, through a form. If you do not want the address blocked at all, [archive](https://sendbeam.io/docs/contacts/editing#archive-delete-erase) the contact instead of deleting it. ## How addresses are stored Every row stores a one-way hash of the lower-cased address, never the address itself. That is why the list can outlive the contact record — it keeps working after a deletion without retaining the person's data, which is what our [data processing agreement](https://sendbeam.io/legal/dpa) promises. Rows written since 14 September 2026 also carry a masked display form (the first character of the local part, then `***`, then the domain; a one- or two-character local part is masked entirely, and a `+tag` is dropped) and the bare domain, so that you can review the list without SendBeam holding the address. Older rows are never back-filled. An address erased at a person's request keeps the hash alone whatever else was known. ## When someone subscribes again Being on the list does not stop a person from choosing to come back — but only an `unsubscribed` entry is theirs to undo. A bounce says the address is dead, a complaint says they reported you, and an erasure was their own request: none of those is cleared by a new signup. The block a plain deletion leaves can also be lifted by a workspace admin — see [Lifting a block](#lift). - **Through one of your signup forms**: the submission creates or updates the contact as `subscribed` (`source: form`) and, if the address was `unsubscribed`, removes it from the suppression list at the moment the opt-in is complete. For a form whose list does not use double opt-in (or no list at all) that is the submission itself. If the list uses [double opt-in](https://sendbeam.io/docs/lists/double-optin) (or the plan requires it), the entry stays until they click the confirmation link — confirming removes it, marks the list membership confirmed and fires *list joined* automations. An address that `bounced` or `complained` is not resurrected: the visitor still sees the form's thank-you message (a public form must not reveal whether an address is known), the submission is recorded under the form, but the contact is left as it is, no list is joined and no confirmation email is sent. A `deleted` address is created afresh as `subscribed` but its entry stays on the list. - **Through the dashboard or API**: a CSV contact import brings a suppressed address in as `unsubscribed`, because a spreadsheet is not consent. `POST /api/v1/contacts`, changing a contact's email to a suppressed address, and setting a suppressed contact's status back to `subscribed` all return `409`. If the person has given you new consent after *unsubscribing* — they asked you in person, say — send `{ "resubscribe": true }` with `POST /api/v1/contacts` or `PATCH /api/v1/contacts/{id}`: the contact becomes `subscribed` with `subscribed_at` set to now and `source` unchanged, and the `unsubscribed` entry is removed once the write succeeds. `resubscribe` has no effect on a bounced, complained or deleted address: those remain `409`. Once an entry has been cleared this way, `GET /api/v1/suppressions?email=` reports the address as not suppressed and a later contact or suppression import treats it like any other address — unless the import itself carries an opt-out status for it, which is applied as usual. > Migrating in stages? Import your old platform's suppressions *before* your forms go live > on SendBeam. Anyone who re-subscribes through a form after the import is taken off the list > when they do, so the order only matters for people who re-subscribed on the old platform in > between. > --- # Lists: Lists Organise contacts into targeted mailing lists. ## What are lists? Lists are named collections of contacts that you want to send email campaigns to. A campaign in SendBeam is sent to all subscribed contacts, to one list, or to one segment, and lists are the primary way to organise your audience for broadcasting messages. Think of a list as a mailing list in the traditional sense — a group of people who have opted in to receive a specific type of communication from you. Lists are explicit: a contact is either a member or they are not. > Membership is simple: a contact is on a list or not. Whether they actually receive a campaign sent to the list depends on their **contact status** (only **Subscribed** contacts are mailed) and, on a double opt-in list, on whether they have **confirmed** by clicking the confirmation email. > ## Lists vs tags vs segments SendBeam gives you three ways to group contacts. Each serves a different purpose, and understanding the difference helps you keep your audience organised effectively. | Feature | Lists | Tags | Segments | | --- | --- | --- | --- | | **Purpose** | Subscription-based opt-in groups | Lightweight labels for filtering | Dynamic groups based on rules | | **Membership** | Explicit — added by hand, by form or via the API | Explicit — by hand, in bulk, by automation or via the API | Automatic — evaluated each time it is used | | **Tracks opt-in?** | Yes — double opt-in confirmation per list | No | No | | **Used in campaigns** | Yes — as the campaign audience | No — use automation triggers and conditions | Yes — as the campaign audience | | **Double opt-in** | Supported per list | Not applicable | Not applicable | In practice, lists represent *what someone signed up for*, tags represent *what you know about them*, and segments represent *a calculated audience* at any point in time. ## When to use lists Lists work best when contacts are actively choosing to receive a particular type of content. Common use cases include: - **Newsletter subscribers** — a weekly or monthly digest that readers opt in to from your website or sign-up form. - **Product updates** — customers who want to hear about new features, releases, or changelogs. - **Event attendees** — people registered for a webinar, conference, or virtual event who should receive reminders and follow-ups. - **Promotional emails** — contacts who have specifically opted in to receive offers and discounts. - **Onboarding sequences** — new users enrolled in a welcome email flow through the *List Joined* automation trigger. > Keep your list names descriptive and audience-focused. A name like **"Weekly Newsletter"** or **"Product Announcements"** is far more useful than "List 1" — both for you and for subscribers who manage their own preferences. > ## Contacts and multiple lists A single contact can belong to any number of lists simultaneously. For example, a contact might be subscribed to your *Weekly Newsletter*, your *Product Updates* list, and your *Beta Testers* list all at once. Each list membership is tracked independently, which means: - Removing a contact from one list does not affect their membership in other lists. - Double opt-in confirmation is stored separately for each list, so a contact may be confirmed on one list and still awaiting confirmation on another. - Clicking the unsubscribe link in any email sets the contact's status to Unsubscribed, which stops all campaigns and automation emails to them, whichever list they came from. - You can view all lists a contact belongs to from their contact profile page. > Deleting a list is permanent. All membership records for that list are removed. The underlying contacts themselves are **not** deleted — they remain in your workspace and on any other lists they belong to. > --- # Lists: Creating Lists Create and manage your mailing lists. ## Creating a list Lists are created from the **Lists** section of the SendBeam dashboard. You can create as many lists as you need — there is no cap on the number of lists in a workspace. 1. Navigate to **Lists** in the main sidebar. You will see an overview of all existing lists along with their contact counts, double opt-in setting and creation dates. 2. Use the **Create New List** form at the top of the page. 3. Enter a **Name**. This is the name you and your team will use to identify the list. It also appears in the subject of double opt-in confirmation emails ("Please confirm your subscription to …"), so make it recognisable. 4. Optionally add a **Description** to explain the purpose of the list. It is shown on the Lists page and at the top of the list's own page. 5. Tick **Require double opt-in** if new members should confirm by email before they are mailed. See [Double Opt-In](https://sendbeam.io/docs/lists/double-optin) for full details. 6. Click the create button. The new list appears in the table; click its name to open it and start adding contacts. > Create your lists before you build signup forms or automations: a form adds its subscribers to one list, and the *List Joined* trigger fires when a contact is added to a specific list. > ## Naming conventions Clear, consistent list names make it much easier to select the right audience when building campaigns — especially as your workspace grows. Here are some conventions that work well: - **Audience + content type** — "Customers – Product Updates", "Leads – Nurture Sequence" - **Channel or source** — "Website Sign-Ups", "Trade Show 2026", "Checkout Opt-In" - **Frequency or cadence** — "Weekly Newsletter", "Monthly Digest" - Avoid vague names like "List 1", "Main List", or "Test". These become confusing quickly. - Use consistent capitalisation across all list names to keep the sidebar tidy. > Subscribers see the list name in the double opt-in confirmation email. A clear name reassures them that the confirmation is genuine. > ## List settings Each list has three settings, chosen when you create it: - **Name** — the name shown in the dashboard and in confirmation emails. - **Description** — a short summary of the list's purpose. - **Double opt-in** — when on, new members must click a confirmation link before campaigns sent to the list reach them. On the Free plan every list behaves as double opt-in regardless of this setting. All three can be changed later from the **List details** panel at the top of the list's page. Sender name and address are set per workspace under **Settings → Email & Domains** and can be overridden on each campaign; they are not set per list. ## Editing and deleting lists To edit a list, open it and expand **List details** at the top of the page. Change the name, description or double opt-in setting and save. Changing double opt-in affects people who join from then on; existing members are unaffected. The name and description can also be changed through the [API](https://sendbeam.io/docs/api) (`PATCH /api/v1/lists/{id}`). To delete a list: 1. Go to **Lists** and find the list in the table. 2. Open the **⋯** menu at the end of its row and choose **Delete**. 3. Confirm when prompted. > Deleting a list is **permanent and cannot be undone**. All membership records for that list are erased. The contacts themselves are **not** deleted — they remain in your workspace. Forms that added subscribers to the list, and automations triggered by joining it, will need a new list. > --- # Lists: Double Opt-In Verify subscriber intent with confirmation emails. ## What is double opt-in? Double opt-in (DOI) is a two-step subscription process. When a contact is added to a list that has double opt-in enabled, they are not immediately treated as a confirmed member. Instead, SendBeam sends them an automated confirmation email. Only after they click the confirmation link do campaigns sent to that list reach them. This is in contrast to *single opt-in*, where a contact is a full member of the list the moment their address is collected — for example, when they submit a sign-up form. > Double opt-in is configured **per list**. You can have some lists with it enabled and others without, depending on how you are collecting subscribers for each list. On the **Free plan** every list behaves as double opt-in, whatever the setting, and campaigns to *All Contacts* or a segment also reach only contacts who have confirmed a subscription. > ## Why it matters Enabling double opt-in delivers measurable improvements across three key areas: - **List quality** — only people who genuinely want your emails make it through. Typos, fake addresses, and disinterested sign-ups are filtered out before they reach your confirmed audience. - **Deliverability** — engaged subscribers who confirmed their intent are far less likely to mark your emails as spam. A lower complaint rate protects your sender reputation and keeps your emails landing in the inbox rather than junk folders. - **Compliance** — in regions covered by GDPR, CASL, and similar regulations, double opt-in provides a clear record of consent: the confirmation happens from the subscriber's own inbox. - **Engagement rates** — open and click rates are consistently higher on double opt-in lists because every subscriber on the list actively chose to be there. > If you are building a list from a public sign-up form on your website, double opt-in is strongly recommended. It is the single most effective step you can take to protect your sender reputation when growing an audience from cold traffic. > ## How the confirmation flow works Here is the complete journey a subscriber takes when double opt-in is enabled: 1. **The contact is added to the list** — through a SendBeam sign-up form, from the list's page in the dashboard, or via the API. 2. **Unconfirmed membership** — SendBeam creates the contact record (if it does not already exist) and adds them to the list as **unconfirmed**. Campaigns sent to the list skip them while they are in this state. 3. **Confirmation email sent** — SendBeam immediately sends a confirmation email from your workspace's sender address with the subject "Please confirm your subscription to *list name*" and a single confirmation button. At most one confirmation goes to an address every 10 minutes, and each one counts towards the account's monthly email allowance. 4. **Contact clicks the link** — SendBeam verifies the token and marks their membership of the list as **confirmed**. From then on, campaigns sent to the list include them, and any automation with the *List Joined* trigger for this list starts for them now — not when they were first added. 5. **Confirmation page shown** — the subscriber sees a SendBeam-hosted "Subscription confirmed" page. A link that has already been used shows an "expired or invalid" page instead. If the contact never clicks the confirmation link, they remain unconfirmed on that list and campaigns sent to it will not reach them. Their contact status stays **Subscribed**, so on paid plans campaigns sent to *All Contacts* or to a segment can still include them — keep form-fed lists as the audience for those contacts if you want the confirmation to gate every send. On the Free plan the confirmation gates every send: unconfirmed contacts, including any imported from CSV or created through the API, are not mailed by any campaign. > Adding a contact to a double opt-in list from the list's page or via the API also sends the confirmation email (and holds the *List Joined* trigger until they confirm), so avoid bulk-adding an existing audience to such a list unless you want them all to re-confirm. The API response reports this as `"membership": "pending_confirmation"`, and posting the same contact again while they are unconfirmed re-sends the link. Contacts brought in by CSV import are not added to any list. > ## Enabling double opt-in on a list 1. Go to **Lists** in the sidebar and open the list (or tick **Require double opt-in** in the **Create New List** form for a new one). 2. Expand **List details** at the top of the list page. 3. Tick or untick **double opt-in** and save. The change applies to people who join from then on. Existing members are not asked to re-confirm and keep whatever confirmation state they had. Unconfirmed members are counted at the top of the list page ("N awaiting confirmation") and badged in the members table. ## Sending confirmations to pending members A bulk add from the Contacts page and a CSV import with a list chosen put people on a double opt-in list as *unconfirmed* and send nothing — importing a thousand rows must not fire a thousand emails by accident. When you do want those people asked, click **Send confirmation emails** next to "N awaiting confirmation" at the top of the list page. It mails only members who have not confirmed (someone who clicks the link between your selection and the send is never mailed again), skips anyone who was sent any transactional email in the last 24 hours, and sends at most 500 per run — run it again later for the rest. Each email counts against your monthly allowance, and five provider refusals in a row (plan quota, a paused workspace, a sending-domain problem) stop the run. The result says how many were sent, skipped and still pending. From a script: `POST /api/v1/lists/{id}/confirmations` (key with `lists:write`), optionally with `contact_ids` to limit the run to some members. A single opt-in list answers `422`. No other platform ships this: most cannot re-send a confirmation at all. --- # Lists: Managing Members Add and remove contacts from your lists. ## Adding contacts to a list There are three ways to add contacts to a list in SendBeam. Use whichever method fits your workflow — the result is the same. ### From the list page 1. Navigate to **Lists** and open the target list. 2. In the **Add Contact to List** box at the top, enter the contact's email address. The contact must already exist in the workspace — add them under [Contacts](https://sendbeam.io/docs/contacts/adding) first if not. 3. Click the add button. The contact joins the list immediately; if the list uses double opt-in, they also receive the confirmation email. ### From a signup form A [signup form](https://sendbeam.io/docs/forms) adds each visitor who submits it to the list you chose when creating the form, creating the contact if they are new. This is how most lists grow. ### Via the API `POST /api/v1/lists/{id}/contacts` adds an existing contact by ID. It is the right tool for syncing memberships from your own systems or for bulk operations. It follows the list's double opt-in setting exactly as the list page and forms do: on a double opt-in list (or any list on the Free plan) the response says `"membership": "pending_confirmation"`, the contact is emailed the confirmation link and campaigns skip them until they click it; on a single opt-in list it says `"membership": "confirmed"`. Posting an unconfirmed member again re-sends the confirmation (at most one per address every 10 minutes); a confirmed member is a `409`. Only **subscribed** contacts can be added — an unsubscribed, bounced or complained contact is refused with `422`. See the [API reference](https://sendbeam.io/docs/api). > Whichever way a contact joins a list — list page, form or API — any active automation with the **List Joined** trigger for that list starts for them (on a double opt-in list, once they confirm). This is the easiest way to run a welcome sequence. > > CSV import creates contacts but does not put them on a list. After an import, add the contacts to the list through the API, or ask them to subscribe through a form so that consent is recorded. > ## Removing contacts from a list Removing a contact from a list takes them off that list only. Their contact record, status, and membership in any other lists are not affected. - **From the list page** — find the contact in the members table and click the **Remove from list** icon on their row, then confirm. - **Via the API** — `DELETE /api/v1/lists/{id}/contacts` with the contact's ID in the request body. > Removing someone from a list is not the same as unsubscribing them. If a contact has asked to stop receiving email, set their status to **Unsubscribed** on their profile instead — that stops every campaign and automation email, and a form cannot silently re-add them. > ## Subscribers can leave one list Every email carries an unsubscribe link. When the person who clicks it is on one or more of your lists, the page they land on is a preference page: each list they are on, ticked, with the list this email was sent to marked *"this email"*. Unticking a list and clicking **Save preferences** removes them from just that list — they stay subscribed and keep receiving your other lists. **Unsubscribe from everything** (and unticking every list) does what it says: the contact becomes unsubscribed and goes on the [suppression list](https://sendbeam.io/docs/contacts/suppressions). Leaving a list fires the `contact.list_left` webhook event, and if the list was the one this campaign went to, it counts as an unsubscribe in that campaign's statistics. The one-click unsubscribe that Gmail and Yahoo trigger from the mailbox header is always the full unsubscribe, as those providers require. ## Offering a list to your other workspaces If you run several sites on one account, a list in one workspace can be *offered* to the people who subscribed on another. It is an offer, not a transfer: the person ticks it themselves, and a contact is created in the list's own workspace with its own consent record and its own unsubscribe. No workspace ever sees another's contacts. ### Marking a list offerable 1. Open the list under **Lists** and expand **List details**. 2. Tick **Offer this list on your other workspaces** and click **Save list**. Give the list a description while you are there — it is shown with the offer, and it is what persuades someone to tick. The switch is shown only to a workspace admin, and only when the workspace belongs to an account — a workspace with no account has no other workspaces to offer to. Through the API, `PATCH /api/v1/lists/{id}` with `"offerable_across_account": true` does the same, and answers `403` for anyone else, an API key included: a key is bound to one workspace, and this decision reaches beyond it. ### Where the offer appears In one place only: the **More from us** tab of the [preference page](#leaving-one-list) that your other workspaces' subscribers reach from the unsubscribe link in their emails. It never appears on a signup form or in a campaign link, and it is never the tab the page opens on — someone who came to leave a list is not made to walk past it. Each offer shows the list's name, which of your workspaces it belongs to and, if you gave it one, its description — unticked. Nothing happens unless the person ticks it and clicks **Sign me up**. ### What happens when someone ticks it Everything after the tick is the list's own workspace's ordinary signup path, not a shortcut through it: - **Its suppression list wins.** Someone who unsubscribed from that workspace is not re-added by ticking a box on another workspace's page. They are told only that nothing changed. - **Its plan's contact cap applies.** A workspace that has used its allowance cannot gain a contact this way. - **Its double opt-in is honoured.** On a double opt-in list — or any list in a Free-plan workspace — the membership is created unconfirmed and the confirmation email goes out, exactly as that workspace's own form would send it. Someone who never clicks the link is never mailed. > The new contact's **source** reads *opted in via* followed by the name of the workspace whose page they ticked it on, so you can always answer where an address came from. > ## Viewing list members Opening a list shows a table of everyone on it. Each row shows the contact's name and email, their contact status, and the date they were added; on a double opt-in list, members who have not yet confirmed carry an *awaiting confirmation* badge, and the total waiting is shown at the top of the page. Twenty members are shown per page. The **List details** panel above the table is where you rename the list or change its double opt-in setting. From the members table you can: - Click a contact's name or email to go directly to their full contact profile. - Remove a contact from the list. > The count shown on the Lists overview page and at the top of a list counts **every** member, including those who have not yet confirmed a double opt-in and those whose contact status is Unsubscribed or Bounced. The number actually mailed by a campaign can therefore be lower. > ## Member status The status column in the members table is the contact's workspace-wide status, not a per-list one: - **Subscribed** — will receive campaigns sent to this list (on a double opt-in list, once they have confirmed). - **Unsubscribed** — has opted out, by clicking an unsubscribe link or by being changed by hand. Never sent campaigns, even while still listed as a member. - **Bounced** / **Complained** — suppressed automatically after a hard bounce or a spam complaint. On a double opt-in list, members who are still unconfirmed are badged in the table and counted at the top of the page. A campaign sent to the list skips them until they confirm. --- # Tags: Tags Label contacts with flexible, colour-coded tags for easy filtering. ## What are tags? Tags are lightweight, freeform labels you attach to contacts to capture information that doesn't belong in a dedicated field. Unlike custom fields, tags carry no value — a contact either has a tag or they don't. That simplicity makes them fast to apply, easy to read at a glance, and flexible enough to evolve with your business. Every tag in SendBeam belongs to the workspace and can be applied to any number of contacts. Tags are visible on the contact profile and on the Tags page, filter the Contacts list (and with it bulk actions and exports), and drive automations through the *Tag Added* trigger and the tag condition. > Tags are workspace-wide. Every team member with access to the workspace can see and > apply all tags. > ## Tags vs lists SendBeam has two ways to group contacts: **lists** and **tags**. They serve different purposes and work best together. | Feature | Lists | Tags | | --- | --- | --- | | Primary purpose | Sending campaigns to a defined audience | Labelling contacts for filtering, bulk actions and automations | | Opt-in / opt-out tracking | Yes — per list | No | | Double opt-in | Yes — configurable per list | No | | Used for campaign targeting | Yes — as the campaign audience | No — tags drive automations instead | | Structure | Formal; contacts join and leave | Informal; applied and removed freely | A good rule of thumb: use lists to manage *who receives your emails* and tags to describe *who your contacts are* and *what they have done*. For example, you might send your newsletter to the "Newsletter" list, and tag the contacts who bought something as `customer` so an automation can send them a follow-up sequence. > Tags pair especially well with [Automations](https://sendbeam.io/docs/automations). The > **Tag Added** trigger starts a sequence the moment a tag is applied, and the > **Add Tag** and **Remove Tag** steps let one automation hand off to > another. Segments can filter on tags too: add a **Tag** rule with > *has tag* or *does not have tag*, alongside any field or custom-field rules. > ## Common use cases Here are some popular ways SendBeam customers use tags: - **Customer tier** — `vip`, `customer`, `churned` - **Lead source** — `webinar`, `referral`, `paid-search`, `organic` - **Product interest** — `interested-pro`, `demo-requested`, `pricing-page` - **Event attendance** — `summit-2025`, `launch-event`, `workshop-q1` - **Engagement level** — `highly-engaged`, `at-risk`, `re-engaged` - **Content preference** — `prefers-digest`, `blog-subscriber`, `video-only` - **Sales stage** — `sql`, `mql`, `closed-won` > Keep tag names lowercase and hyphenated for consistency. Tags like `vip` and > `VIP` are treated as different tags in SendBeam, so agreeing on a naming convention > early saves cleanup work later. > ## Tag colours Each tag has a colour, chosen from the preset swatches or picked freely. Colours are purely visual — they have no effect on automations or deliverability. Use them to make tags scannable on contact profiles or to signal category at a glance. Some teams organise colours by category: - **Green** — positive signals (e.g. `vip`, `closed-won`) - **Red** — risk signals (e.g. `churned`, `at-risk`, `unsubscribed-once`) - **Blue** — lead source tags (e.g. `webinar`, `referral`) - **Yellow** — event-based tags (e.g. `summit-2025`) - **Purple** — internal / sales tags (e.g. `sql`, `mql`) You can change a tag's colour at any time from the Tags page without affecting any contacts it is applied to. See [Managing Tags](https://sendbeam.io/docs/tags/managing) for step-by-step instructions. > Colour assignments are cosmetic only. An automation triggered by a tag fires for every > contact given that tag regardless of the colour it has been given. > --- # Tags: Managing Tags Create, edit, delete, and colour-code your tags. All tag management in SendBeam happens from the **Tags** page, which you can reach from the main navigation. From here you can create new tags, update their names and colours, and delete tags you no longer need. ## Creating a new tag Follow these steps to create a tag: 1. Navigate to **Tags** in the left sidebar. 2. In the **Create New Tag** form, enter a name for the tag. Tag names must be unique within the workspace. See [Naming tips](#naming-tips) below for best practices. 3. Choose a colour from the preset swatches, or pick a custom colour. 4. Click the create button. The tag is created immediately and is available to apply to contacts. > Tags can also be created from your own code with `POST /api/v1/tags`, and an > automation's **Add Tag** step can apply any existing tag. Create the tag first, > then reference it. > ## Naming tips Good tag names are short, consistent, and self-explanatory. Keep these guidelines in mind: - Use lowercase letters and hyphens instead of spaces — e.g. `closed-won` rather than `Closed Won`. - Avoid abbreviations that are only meaningful to one team member. `hs-sync` may not be obvious to a new colleague; `hubspot-synced` is clearer. - Prefix related tags to group them visually — e.g. `event-summit-2026`, `event-webinar-march`. - Keep names short enough to display cleanly in the contacts table (under 30 characters works well). - Agree on a naming convention with your team before you accumulate dozens of tags — there is no bulk merge, so retagging later is manual work. > Tag names are case-sensitive. `VIP` and `vip` are two separate tags. > If you find duplicate tags with different capitalisation, you will need to retag affected > contacts before deleting the unwanted tag. > ## Editing a tag You can rename a tag or change its colour at any time without affecting the contacts it is applied to. 1. Go to **Tags** in the left sidebar. 2. Find the tag you want to edit, open the **⋯** menu at the end of its row and choose **Rename**. The name and colour become editable in place. 3. Update the name, colour, or both, then click the **Save** (tick) icon. Click the cross to cancel. Renaming a tag updates every contact that has it — the old name disappears and the new name appears in its place immediately. There is no need to re-apply the tag. > Automations reference tags by ID, so a **Tag Added** trigger or an > **Add Tag** step keeps working after you rename the tag. > ## Deleting a tag Deleting a tag removes the label from every contact it has been applied to. The contacts themselves are **not** deleted — only the tag association is removed. 1. Go to **Tags** in the left sidebar. 2. Open the **⋯** menu at the end of the tag's row and choose **Delete**. 3. Confirm in the dialog. The contact count on the row tells you how many contacts will lose the tag. > Tag deletion is permanent and cannot be undone. Check any automations that use the tag as a > **Tag Added** trigger, in a **Condition**, or in an > **Add Tag** / **Remove Tag** step, and update them. > ## Viewing contacts by tag The Tags page shows the number of contacts carrying each tag. Each contact's profile lists the tags applied to them. To see everyone with a tag, go to **Contacts** and pick the tag in the **Any tag** dropdown next to the status filter. The tag filter combines with the search box and the status filter, and whatever it shows is exactly what [bulk actions](https://sendbeam.io/docs/contacts/bulk-actions) and the [CSV export](https://sendbeam.io/docs/contacts/exporting) act on. From your own code, pass `tag=` to `GET /api/v1/contacts` or `GET /api/v1/contacts/export`. --- # Tags: Tagging Contacts Apply and remove tags from individual or multiple contacts. Tags can be applied to contacts in several ways: from the contacts list (one contact or many at once), automatically by an automation step, or from your own code through the API. This page walks through each approach and explains how to remove tags and use them to drive automations. ## Tagging from the contacts list The Contacts page is where you tag contacts by hand. It works the same whether you tick one contact or a whole page of them, and it is the fastest way to tag a group after an import. 1. Navigate to **Contacts**. 2. Use the search box, the status filter and the **Any tag** dropdown to find the contacts you want to tag. 3. Tick individual checkboxes, or tick the checkbox in the header row to select the whole page and then click **Select all N matching** in the banner to cover every contact matching your filters. 4. In the toolbar that appears, click **Add tag** and pick the tag from the dropdown. SendBeam applies it to all selected contacts, starts any **Tag Added** automation for that tag for the subscribed ones, and reloads the page. > "Select all matching" covers up to 50,000 contacts in one action, so tagging a whole > filtered cohort — everyone with status Subscribed, or everyone who already carries another tag — > is a single click. > ## Tagging from the Tags page The other way round works too. On **Tags**, the **Apply a tag** panel (or **Add contacts** in a tag row's **⋯** menu, which preselects it) lets you pick the tag and then either search for the contacts it should go on — type a name or email, click each match to add it — or describe them with the same filter the Contacts page uses (name or email contains, status). **Add tag** and **Remove tag** apply to the chosen contacts or to everyone matching the filter, through the same bulk action, so *Tag Added* automations fire exactly as they would from the Contacts page. ## Tagging automatically An automation can add or remove tags as contacts move through it, using the **Add Tag** and **Remove Tag** steps. Typical patterns: - Tag everyone who completes a welcome sequence with `onboarded`. - When a contact submits a particular form, tag them with the campaign or event it belongs to. - Use a **Condition** step on the contact's existing tags to branch a sequence. See [Action steps](https://sendbeam.io/docs/automations/steps) for details. ## Tagging via the API From your own systems, tag a contact with `POST /api/v1/contacts/{id}/tags` and remove a tag with `DELETE /api/v1/contacts/{id}/tags/{tagId}`; to tag many at once, `POST /api/v1/contacts/bulk` with `action: "add_tag"` and either `contact_ids` or a `filter`. These need an API key with the matching write permission on a Pro or Business plan. This is the way to record things SendBeam cannot see itself, such as a purchase or a plan change. > Use a source tag like `import-april-2026` when you bring in an external list: > tag the imported contacts straight after the import so you can find the batch later. > ## Removing tags You can remove a tag from a contact in three ways: - **From the contacts list** — select the contacts, click **Remove tag** in the toolbar and pick the tag. - **Automatically** — with a **Remove Tag** step in an automation, or through the API. - **By deleting the tag entirely** — deleting a tag from the Tags page removes it from every contact. See [Deleting a tag](https://sendbeam.io/docs/tags/managing#deleting-a-tag) for details. Removing a tag from a contact does not affect the tag itself — it continues to exist and remains applied to any other contacts that have it. ## Tags in automations Tags are the main way to start an automation for a specific contact at a moment you choose. The **Tag Added** trigger enrols a contact the moment the chosen tag is applied — by hand, by another automation, or via the API. 1. Navigate to **Automations → New Automation**. 2. Choose the **Tag Added** trigger and select the tag. 3. Add your steps — for example a **Send Email**, a **Wait**, and a **Condition** that checks the contact's tags before a second email. 4. Activate the automation. From now on, tagging a contact starts the sequence for them. > To send a campaign to everyone with a tag, build a segment with a **Tag** rule > (*has tag* / *does not have tag*) and pick that segment as the audience. Tag rules > combine with the other segment rules, so "has tag *customer* and country is UK" is one > segment. See [Fields and operators](https://sendbeam.io/docs/segments/operators). > --- # Segments: Segments Build dynamic contact groups based on contact data. Segments are one of the most useful targeting tools in SendBeam. Rather than manually curating a list of recipients, you define a set of rules and SendBeam works out which contacts qualify each time the segment is used — so the audience follows your data as it changes. ## What are segments? A segment is a saved filter that produces a dynamic set of contacts. Every segment is defined by one or more rules — conditions that a contact must meet to be included. Rules test the contact's fields: email address, first and last name, status, source, the date they were created, and any custom field stored on the contact. For example, you could create a segment for: - Contacts whose `custom_fields.country` equals `United Kingdom` - Contacts whose email address ends with `@acme.com` - Contacts created after `2026-01-01` whose source is `form` - Contacts with no first name, so you can avoid a personalised greeting for them > Segments never store contacts themselves — they store the rules. The matching contacts are resolved each time you preview the segment or send a campaign to it, so the audience is always fresh. > ## Segments vs lists Both segments and lists let you target a subset of your contacts, but they work in fundamentally different ways. | Feature | Lists | Segments | | --- | --- | --- | | Membership managed by | You (by hand, via forms or the API) | SendBeam (rule evaluation) | | Updates automatically | No | Yes | | Supports double opt-in | Yes | No | | Good for sign-up flows | Yes | No | | Good for attribute-based targeting | No | Yes | Use lists when contacts explicitly opt in to a specific topic or newsletter. Use segments when you want to target contacts based on who they are, without requiring manual curation. > Segment rules **can** test tags — the `tag` field takes **has tag** and **does not have tag**. What they cannot test is list membership. So "everyone tagged *vip* from the UK" is a segment; for "everyone on the Newsletter list from the UK", send to the list and narrow with a tag, or store the country as a custom field. Either way the campaign only reaches subscribed contacts. > ## When to use segments Segments are the right choice in most of these situations: - **Field-value filtering** — send region-specific campaigns by filtering on a `country` or `city` custom field without duplicating contacts across multiple lists. - **Lifecycle stages** — record a `plan` or `stage` custom field via the API or a form, then target those stages directly in campaigns. - **Source-based targeting** — reach only contacts who came in through a form, or only those you added by hand or via the API. - **Data hygiene** — find contacts with an empty first name or an unexpected email domain and tidy them up before a big send. - **Company or domain targeting** — send to everyone at a particular company by matching the end of their email address. ## Evaluated when used Segment membership is evaluated whenever the segment is used. Whenever a contact's data changes — a custom field is updated via the API, a name is corrected, a status changes — they qualify or disqualify from any segments whose rules are affected the next time those segments are resolved. The count shown while building a segment reflects the current state of your audience. When a campaign starts sending, SendBeam resolves the recipient list at that moment and keeps only contacts whose status is **Subscribed**. > Because segments are evaluated at send time, the contact count can change between when you preview a segment and when a scheduled campaign actually sends. Check the segment's count again before sending to a large audience. > --- # Segments: Creating Segments Define rules to automatically group contacts. Creating a segment in SendBeam takes only a few minutes. You give the segment a name, add one or more rules, preview who matches, and save. ## Creating a new segment Follow these steps to create your first segment: 1. Navigate to **Segments** in the left-hand sidebar. You will see a list of any existing segments with a summary of their rules. Click **New segment**, which opens the builder on a page of its own. 2. Enter a descriptive name in the name field. Good names make it clear who qualifies — for example, *Active UK subscribers* or *Acme staff*. 3. The form starts with one rule. Choose a field from the dropdown — a built-in field or **Custom field…**, which asks for the key — select an operator, and enter a value where required (*is empty* and *is not empty* take none). See [Operators & Fields](https://sendbeam.io/docs/segments/operators) for the full list. 4. Add additional rules as needed with **Add rule**, under the last rule. Remove a rule with the remove link beside it. 5. Watch the figure at the foot of the page. It keeps itself up to date as you change the rules — *Reachable now* is how many subscribed contacts the segment would reach today, and the number beside it is how many match in total. **Sample of who matches** opens a handful of them. 6. Save the segment. It is now available as an audience when creating a campaign. > Use descriptive, consistent naming conventions across your segments. Prefixing names with a category — such as `geo:` or `lifecycle:` — makes them easier to scan when selecting an audience during campaign setup. > ## Combining rules When a segment has more than one rule, **all rules must match** (AND). Each extra rule narrows the audience. For example: status equals `subscribed` AND email ends with `@acme.com`. There is no OR option. If you need "A or B", create two segments and send the campaign twice, once to each — or, where the two conditions can be expressed as one value, store that value in a custom field and match on it. > A rule with an empty value is ignored in previews, but the segment cannot be saved until every rule has a value (except *is empty* and *is not empty*, which take none). > ## Previewing matching contacts As you build your segment, click the preview button to run the rules against your contacts. The preview shows: - The total number of matching contacts - A sample of matching contacts with their names and email addresses Preview and send use exactly the same rule evaluation, so the count you see is the audience a campaign would resolve at that moment (before the status check that drops anyone who is not Subscribed). The same count is available from your own code through `POST /api/v1/segments/preview`, which takes a set of rules without saving a segment — handy for testing rules before creating one. ## Editing and deleting segments To edit a segment: 1. Go to **Segments** in the sidebar. 2. Click the segment's name, or choose **Edit** from the menu at the end of its row. The builder opens pre-filled with its name and rules, and the count at the foot shows who it reaches today. 3. Change the name or rules — the count follows them — and save. The same is possible from your own code with `PATCH /api/v1/segments/{id}`. To delete a segment: 1. Open the segment, or choose **Delete** from the menu at the end of its row. 2. On the segment's own page, open **Delete this segment** at the foot and click **Delete segment**. 3. Confirm. Draft campaigns that used the segment as their audience will need a new audience before they can send. Changes made through the API take effect immediately — the next time the segment is resolved, at campaign send time, the updated rules are used. Campaigns reference the segment by its ID, so renaming it does not affect them. --- # Segments: Operators & Fields Available fields and comparison operators for segment rules. Each segment rule is made up of three parts: a **field** (the piece of contact data to test), an **operator** (how to compare it), and a **value** (what to compare it against). This reference lists every field and operator available in SendBeam. The same fields and operators are accepted by the segments [API](https://sendbeam.io/docs/api), and previews and sends evaluate them identically. ## Available fields SendBeam exposes the following fields for use in segment rules. | Field | Type | Description | | --- | --- | --- | | `email` | Text | The contact's email address | | `first_name` | Text | The contact's first name | | `last_name` | Text | The contact's last name | | `status` | Text | Contact status: `subscribed`, `unsubscribed`, `bounced` or `complained` | | `source` | Text | How the contact was added: `manual`, `import`, `form` or `api` | | `language` | Text | The language the contact reads in, as a two-letter code (`fr`). *is empty* finds contacts with no language | | `created_at` | Date | The date and time the contact was added to the workspace | | `tag` | Whether the contact carries a tag. Use with *has tag* / *does not have tag* and pick the tag (the API takes the tag id as the value). | | | `campaign` | Engagement | What the contact did with one sent campaign. Use with *opened*, *received but did not open*, *clicked a link in* or *received but did not click* and pick the campaign (the API takes the campaign id as the value). The "did not" forms only match people the campaign actually reached — never people it was not sent to. | | `activity` | Engagement | Any open or click on any email from this workspace — campaigns, automations and transactional — in the last *N* days (1–365). *Opened nothing in the last 90 days* is the usual re-engagement or list-pruning segment. | | `custom_fields.` | Text | Any custom field stored on the contact, for example `custom_fields.country` | > In the segment form, choose **Custom field…** and type the key (for example `country`); through the API, pass the field as `custom_fields.country`. A custom field registered as a **Number** is compared numerically, so "greater than 9" correctly excludes 10. A field with no registered type is compared as text, where "greater than" is alphabetical — register the field as a Number if you want it to sort like one. > ## Available operators Most operators work with most fields, but not every pairing is valid: the `tag` field takes only **has tag** and **does not have tag**, and `campaign` takes only **opened** and **clicked**. A rule that pairs them wrongly is refused with a `400` rather than quietly ignored. The table below lists them with the name shown in the segment form and the value used in the API. | Operator | API value | Description | | --- | --- | --- | | **equals** | `equals` (or `is`) | Field value matches the given value exactly, ignoring case | | **does not equal** | `not_equals` (or `is_not`) | Field value does not match the given value | | **contains** | `contains` | Field value includes the given text anywhere within it (case-insensitive) | | **does not contain** | `not_contains` | Field value does not include the given text (case-insensitive) | | **starts with** | `starts_with` | Field value begins with the given text (case-insensitive) | | **ends with** | `ends_with` | Field value ends with the given text (case-insensitive) | | **is empty** | `is_empty` | The field is empty or has never been given a value for this contact (no value needed) | | **is not empty** | `is_not_empty` | The field has a non-empty value for this contact (no value needed) | | **is after / greater than** | `greater_than` | The field is later than the given date, or sorts after the given value | | **is before / less than** | `less_than` | The field is earlier than the given date, or sorts before the given value | | **has tag** | `has_tag` | The contact carries the chosen tag (field `tag` only) | | **does not have tag** | `not_has_tag` | The contact does not carry the chosen tag (field `tag` only) | | **opened** / **clicked a link in** | `opened` / `clicked` | The contact opened, or clicked a link in, the chosen campaign (field `campaign` only). A click counts as an open. | | `not_opened` / `not_clicked` | received but did not open / click | The campaign reached the contact and they have not opened it, or not clicked in it (field `campaign` only). Bounced and complained recipients are excluded. | | `opened_within` / `clicked_within` | opened / clicked any email in the last N days | At least one open, or one click, on any email from this workspace in the window (field `activity` only) | | `not_opened_within` / `not_clicked_within` | opened / clicked nothing in the last N days | Every contact without an open, or a click, in the window — including contacts who were sent nothing (field `activity` only) | > The **is empty** / **is not empty** operators are especially useful for finding contacts with incomplete profile data, such as those missing a first name or a custom field value. Use them to run data-cleaning campaigns before major sends. > ## Example rules The table below shows practical examples of rules you might use when building segments in SendBeam. | Goal | Field | Operator | Value | | --- | --- | --- | --- | | All subscribed contacts | `status` | equals | `subscribed` | | Contacts from the UK | `custom_fields.country` | equals | `United Kingdom` | | Signed up this year | `created_at` | is after / greater than | `2026-01-01` | | Came in through a form | `source` | equals | `form` | | Everyone at one company | `email` | ends with | `@acme.com` | | Missing first name | `first_name` | is empty | — | | On the Pro plan | `custom_fields.plan` | equals | `pro` | > **equals** and **does not equal** ignore case, like **contains**, **starts with** and **ends with** — so *Gold* and *gold* are the same value. Leading and trailing whitespace is still significant, so if a rule is not matching contacts you expect, check the stored value's spacing. > --- # Segments: Using Segments Target segments when sending campaigns. Once you have created a segment, you can put it to work immediately as the audience of a campaign. This page covers how that works and how segments fit alongside lists and automations. ## Selecting a segment as a campaign audience When creating a campaign, the **Audience** step lets you choose who will receive the email: all contacts, a specific list, or a segment. 1. Create a new campaign via **Campaigns > New Campaign** and fill in the details step. 2. On the **Audience** step, choose **Segment**. 3. Select the segment from the dropdown. If you have no segments yet, there is a link to create one. 4. Continue to the email and review steps as usual, then send or schedule. > The audience is resolved when the campaign actually sends — immediately for **Send Now**, or at the scheduled time. Any contact who matches the rules at that moment is included. > Only contacts with a status of **Subscribed** are ever included in a campaign send, regardless of segment rules. Contacts whose status is Unsubscribed, Bounced or Complained are excluded at send time even if they technically qualify for the segment. The campaign page shows the number of recipients actually queued. ## Segments and automations Automations are started by events — a contact being created, a tag being added, a contact joining a list, or a form being submitted — rather than by segment membership, so a segment cannot be used as an automation trigger. To branch an automation on contact data, use a **Condition** step, which tests the contact's status, tags, name, email or source. If you want to reach everyone in a segment with an automated-style message, send a campaign to the segment: it is a one-off send, but it uses the same templates and merge tags. ## Combining segments with other targeting A campaign has exactly one audience: all contacts, one list, or one segment. There is no way to add several audiences to one campaign. (There IS a suppression list — see [Suppressions](https://sendbeam.io/docs/contacts/suppressions) — it is applied to every send automatically rather than chosen per campaign.) Some patterns that work within that: - **Two audiences** — duplicate the campaign and send the copy to the second list or segment. A contact who is in both will receive the email twice, so make sure the audiences do not overlap. - **List members with an attribute** — store the attribute as a custom field when the contact signs up (declare it on the form), then build a segment on the custom field. The segment covers the whole workspace, so add a rule on `source` or another field if you need to narrow it further. - **Exclusions** — express the exclusion as a rule, for example `custom_fields.plan` does not equal `customer`, rather than as a separate suppression segment. ## Keeping an eye on segment size The Segments page lists your segments with a summary of their rules, how many rules each has and how many contacts it can reach today. Open a segment to watch that figure follow the rules as you change them, or call `POST /api/v1/segments/{id}/preview` from the [API](https://sendbeam.io/docs/api), which returns the current count and a sample of matching contacts. SendBeam does not keep a history of segment sizes. If you want to track a segment over time, call the preview endpoint on a schedule and record the count yourself. --- # Campaigns: Campaigns Create and send targeted email campaigns to your audience. Campaigns are the core of SendBeam. A campaign is a single email broadcast sent to one or more recipients — whether that's your entire audience, a specific list, or a filtered segment. You compose the email once and SendBeam delivers it reliably through its managed delivery, tracking every open, click, bounce, and unsubscribe automatically. ## What is a Campaign? A campaign bundles together everything needed to send an email blast: a subject line, a from address, the email content, and the chosen audience. Once you hit send (or schedule it), SendBeam queues the messages and updates the campaign status in real time as delivery progresses. Each campaign stores its own independent statistics, so you can compare performance across broadcasts without any data bleeding between sends. > Campaigns are one-time broadcasts. If you need to send a series of automated emails triggered > by subscriber behaviour, see [Automations](https://sendbeam.io/docs/automations) instead. > ## Campaign Statuses Every campaign moves through a set of statuses that reflect where it is in its lifecycle: - **Draft** — The campaign has been created but not yet sent or scheduled. You can freely edit all settings, swap out content, and change the recipient list. - **Scheduled** — A future send date and time has been set. You can still edit the campaign or cancel the send before that time. - **Sending** — Messages are actively being queued and dispatched. This status typically lasts seconds to a few minutes depending on audience size. - **Sent** — All messages have been handed off for delivery. Statistics will continue to update as recipients open emails and click links, but the campaign itself cannot be resent or modified. - **Cancelled** — A scheduled campaign that was cancelled before it sent, or that could not send when its time came (for example because the audience was empty, the monthly email allowance was used up, or sending was paused). In that case the reason is shown on the campaign page and admins are emailed. Duplicate it to try again. > There is no undo for a sent campaign. Once the status changes to **Sent**, the > emails are already in transit. Always send yourself a test email and preview the campaign > before confirming the send. > ## Campaign Types When setting up a campaign you choose who receives it. SendBeam supports three recipient modes: - **All contacts** — Every subscribed contact in the workspace receives the email. Useful for workspace-wide announcements or newsletters. - **Specific list** — Only contacts who are members of a chosen [list](https://sendbeam.io/docs/lists) receive the campaign. Ideal for targeting subscribers who opted in through a particular form or import. - **Segment** — Only contacts who match a saved [segment](https://sendbeam.io/docs/segments) receive the campaign. Use this for behaviour-based or attribute-based targeting, such as contacts who haven't opened in 90 days, or contacts in a specific country. > Segments are evaluated at send time, not when you create the campaign. This means a segment > can grow or shrink between the moment you schedule a campaign and when it actually sends — > which is usually the behaviour you want. > ## Deleting a Campaign A draft or cancelled campaign has a **Delete** button on its page (and `DELETE /api/v1/campaigns/{id}` in the API). Sent campaigns cannot be deleted: their statistics, their web version and the links in recipients' inboxes are a record you keep. Cancel a scheduled campaign first if you want it gone. A campaign's own **from name** and **from email** are used when the address is one this workspace may send as — a verified sending domain or the workspace's shared address. Otherwise the workspace's sender details are used, so an old draft with a stale address still goes out. ## The Campaign Workflow A typical campaign follows these stages from idea to inbox: 1. **Create** — Give the campaign a name, subject line, and from address. Choose your audience (all contacts, list, or segment). 2. **Design** — Build the email body in the Visual builder, the rich-text editor, or the HTML view for full control. 3. **Personalise** — Insert merge tags such as `{'{{first_name}}'}` to address each subscriber individually. 4. **Preview & test** — Use the mobile/desktop preview, then send a test email to yourself from the campaign page before committing. 5. **Send or schedule** — Click **Send Now** to dispatch immediately, or pick a date and time to schedule the send for later. 6. **Review statistics** — Once sent, track opens, clicks, bounces, and unsubscribes from the campaign's stats dashboard. --- # Campaigns: Creating a Campaign Set up a new email campaign step by step. Creating a campaign in SendBeam is a straightforward process. The builder walks you through four steps — **Details**, **Audience**, **Email** and **Review** — and the campaign is saved as a draft as you go, from the moment it has a name. ## Starting a New Campaign To create a campaign, navigate to the **Campaigns** section in the main navigation and click the **New Campaign** button in the top-right corner of the page. 1. Go to **Campaigns** in the sidebar. 2. Click **New Campaign**. 3. The **Campaign Details** step opens. Fill in the fields as described below. 4. Click **Next** to move through Audience, Email and Review. You can go **Back** at any point to change something. > If you already have a campaign with similar settings, use the [Duplicate](https://sendbeam.io/docs/campaigns/duplicating) > feature instead. It copies the subject line, from address, and email body so you only need > to tweak the differences. > ## Campaign Settings The campaign form collects the following information: - **Campaign name** — An internal label used to identify the campaign in your dashboard. Subscribers never see this name. Keep it descriptive, for example *May Newsletter 2026* or *Black Friday Promo — Segment: High Spenders*. - **Subject line** — The email subject that appears in the recipient's inbox. This is one of the most important factors in open rates. You can use [merge tags](https://sendbeam.io/docs/campaigns/merge-tags) here, such as `{'Hey {{first_name}}, your exclusive offer is here'}`. - **From name** — The display name shown as the sender, for example *Sarah from Acme* or just your brand name. - **From email** — The sending address. This must be on a domain *this workspace* has verified under [Settings → Email & Domains](https://sendbeam.io/docs/getting-started/sending-domain), or the workspace's own shared platform address. Both fields are pre-filled from your workspace's sender details, and every send uses the sender details saved there, re-checked at send time. Replies go to the from address. The email body may hold up to 500,000 characters of HTML and 200,000 characters of plain text; larger content is refused when the campaign is saved. > Your **from email** domain must be verified in SendBeam before you can send from it. > If you see a domain error, visit **Settings → Email & Domains** and > complete the DNS verification steps. > ## Subject-Line Suggestions Under the subject field, **Suggest subject lines** writes five alternatives from your current subject and the email you have drafted so far — one direct, one curiosity-led, one benefit-led, one that names the reader's situation and one short one. Click **Use** to take one (or **Use as B** when a subject-line A/B test is on). Suggestions are written by an AI model from the email's own text; nothing about your contacts is sent, and nothing is stored. There is a daily allowance per workspace. ## A/B Tests Tick **Run an A/B test** under the subject to try up to five versions of one thing on the same audience: the **subject line**, the **from name**, the **email content** itself, or the **send time**. Version A is always the campaign as written; add B (and C, D, E) beside it — for a content test, on the Email step with **Add a version**, where each version starts as a copy of the one on screen and is edited in the builder like any other email; for a send-time test, each later version is the same email sent one hour to a day after A. Choose how much of the audience to test on (10–50%), how long to wait before picking a winner (30 minutes to 24 hours) and whether opens, clicks or — where your store sends SendBeam its orders — [revenue per recipient](https://sendbeam.io/docs/ecommerce/revenue) decides. On send, the test sample is split evenly between the versions and goes out at once; everyone else waits. When the wait is up, the version with the better rate goes to the remaining recipients — automatically, or earlier if you press **Pick the winner now** (or choose a version yourself) on the campaign page. A tie goes to the earlier letter, so to A. - Only the tested thing differs; everything else, including the audience, is the same for every version. - Every version needs at least two test recipients, so a test needs an audience of at least twice the number of versions (four for two, ten for five). Smaller audiences are sent with version A and the campaign page says so. - The campaign stays *Sending* until the winner has gone to everyone, and its statistics cover all recipients. - Test sends and the contact preview always render version A. - A send-time test waits until the last time has gone out, then compares opens and clicks over the same length of time after each version's release — so the earliest time does not win just for having been out longest. The remaining recipients go out at the winning time of day the next time it comes round, so the campaign can stay *Sending* into the next day. Pick a version yourself to release them sooner. - From the API, pass `ab_test` with `test_on` and `variants` when creating or updating a draft (a two-version subject test can still be set with `subject_b` alone), and use `POST /api/v1/campaigns/{id}/ab-test` to end a test early. ## Draft from a brief Where the assistants are switched on for your workspace, the Details step offers **Draft from a brief**: describe the campaign and it fills in the name, the subject line and the email as ordinary blocks for you to edit, and nothing is sent until you send it. Each plan includes a daily allowance of drafts. ## Language versions Tick **Send in more than one language** under the subject to carry translations with the campaign. Say which language the email is written in, then add a language: each one gets its own subject line here and its own body on the Email step, where a language switcher above the editor moves between versions (a new language starts as a copy of the original, so a translator edits in place). On send, a contact whose [language](https://sendbeam.io/docs/contacts/adding#required-optional-fields) matches a version receives it; everyone else — including contacts with no language — receives the campaign's own version. The web version and test sends follow the same rule. - Up to five languages besides the original. Every version needs a subject and a body. - A campaign carries either language versions or an A/B test, not both. - From the API, pass `language` (the original's code) and `languages`, an object keyed by two-letter code with `subject`, `html_content` and optional `text_content` and `blocks`. Send a test in one language with `POST /api/v1/campaigns/{id}/test` and `language`. - Once the campaign has gone out, a **Languages** panel on its page shows sent, opened and clicked for each version that went out, and `GET /api/v1/campaigns/{id}` carries the same under `stats.by_language`. The web version shows a reader the language they were actually sent. ## Choosing Recipients The **Choose Audience** step controls which contacts receive the campaign. Pick the option that best matches your goal: - **All Contacts** — Sends to every subscribed contact in the workspace. Unsubscribed, bounced and complained contacts are always excluded automatically. - **Specific list** — Restricts the send to members of one of your [lists](https://sendbeam.io/docs/lists). A dropdown lets you choose which list to use. Only subscribed members of that list receive the email. - **Segment** — Sends to contacts matching a saved [segment](https://sendbeam.io/docs/segments). The segment is re-evaluated at send time, so the exact recipient count may differ from what you see during setup. The builder's Review step shows the recipient count; the campaign's own page shows **N recipients right now** under Audience: the subscribed contacts the campaign would reach if sent at that moment, worked out by the same rules the send uses (subscribed only, confirmed members only on a double opt-in list, confirmed contacts only on the Free plan). The Send Now confirmation repeats that number. Through the API, `GET /api/v1/campaigns/{id}/audience` returns the same count together with whether a send would be accepted. The list's contact count on the Lists page and the segment's preview count are broader — they include unconfirmed and unsubscribed contacts — so they can be higher than what is actually mailed. > **Free plan: confirmed contacts only.** On the Free plan every list is > double opt-in and a campaign — to All Contacts, a list or a segment — reaches only contacts who > have confirmed a subscription. Contacts imported from CSV or created through the API who never > confirmed are not mailed. See [Double opt-in](https://sendbeam.io/docs/lists/double-optin). > ## Saving as a Draft All campaigns start in **Draft** status, and the builder saves as you go. Nothing is created for an untouched builder; as soon as you type a name (or a subject), a draft exists, and every change after that is saved a moment after you stop typing. The bar under the step numbers says where things stand — *Unsaved changes*, *Saving…*, *Draft saved* — and links to the draft's own page. The address bar changes to the draft's edit link, so a reload reopens the same draft rather than starting another. The email design is saved with the draft too: a campaign built in the visual builder reopens in the visual builder. If the same draft is open in two tabs, or is changed through the API while you are editing, the tab that falls behind stops saving and asks you to reload rather than overwriting the other copy's work. Nothing typed in the stale tab is saved over it. A draft costs nothing: it does not count towards any plan limit, and the scheduler never sends one. Drafts are not deleted automatically — delete one from its page when you no longer want it. To resume a draft campaign: 1. Go to **Campaigns** in the sidebar. 2. Find the draft campaign in the list — it will show a **Draft** status badge. 3. Click the campaign name to open it, then click **Edit**. 4. The builder reopens with your details, audience and email. Continue where you left off. > Draft campaigns do not expire. You can leave a draft for days or weeks and return to it > whenever you're ready to send — and you can send a test copy of it at any point (to yourself or > up to 5 addresses on Free, 20 on paid plans) from the Email > or Review step, without sending anything for real. See [Before You Send](https://sendbeam.io/docs/campaigns/sending#before-you-send). > --- # Campaigns: Email Builder Design emails with the visual block editor, a rich-text editor, or raw HTML. The **Email** step of the campaign builder lets you design your email in whichever way suits you: a visual block editor for building layouts without code, a rich-text editor for simple formatted messages, or a raw HTML view for full control. The same editor is used for [templates](https://sendbeam.io/docs/templates). ## Builder Overview The Email step has four views, switched with the tabs above the editor: - **Visual** — the block editor. A palette of blocks on the left, the canvas in the middle, and a properties panel for the selected block. New campaigns open here, with a choice of starter layouts or a blank canvas. - **Edit** — a rich-text editor with a toolbar for bold, italic, underline, paragraphs, lists, alignment, links and images. - **Preview** — the rendered email, with a desktop and mobile toggle. - **HTML** — the raw source. Paste your own markup here. Below the editor, the **Merge tags** buttons insert personalisation tags, and an optional **plain text** box holds the text-only version of the email (generated automatically if left empty). > Start from one of the built-in starter layouts (Welcome Email, Newsletter, Product Announcement, > Sale / Promotion, Event Invitation or Simple Text) rather than a blank canvas if you want a > sensible structure to customise. > ## Available Blocks The following block types are available in the Visual view: - **Header** — a heading line for the top of the email or a section. - **Text** — a paragraph block for body copy, with an optional heading rendered as its own line above it. The most common block. - **Image** — a picture with alt text and an optional link. **Upload** a PNG, JPEG, GIF or WebP from your computer (up to 2 MB; anything wider than 1,600 pixels is shrunk in your browser first), pick one you uploaded before from the **Library**, or paste the URL of an image you already host. The Feature block's picture works the same way. Uploaded images are stored per workspace with a cap that depends on the plan, and removing one from the library breaks it in any email that still uses it. - **Button** — a styled call-to-action button with a label and URL. - **Divider** — a horizontal rule used to visually separate sections; its settings hold the line colour and the padding around it. - **Spacer** — an invisible block that adds vertical whitespace between other blocks. - **Columns** — a multi-column layout for side-by-side content. - **Social Links** — a row of social icons with the URLs you set. - **Footer** — small print with an unsubscribe link and a "View in browser" link (`{'{{web_version_url}}'}`) built in. The unsubscribe link is added on send if you leave it out; the "View in browser" link is not — it exists only where the tag is. Remove the block that holds it and the builder says so, with Undo; the Review step repeats the notice. Seven more blocks cover the layouts people otherwise hand-code: - **Feature** — an image (uploaded or by URL) beside a heading, a paragraph and a button; image left or right. Stack two or three for a product update. - **List** — bullet points, one per line, with an optional heading. - **Quote** — a pulled quote with an attribution, for testimonials and reviews. - **Poll** — one question and two to five answer buttons, each with an optional tag for whoever chooses it; each recipient's click is their answer, and the campaign's report shows the split. See [In-email polls](https://sendbeam.io/docs/campaigns/polls). - **Video** — email clients cannot play video, so this is a thumbnail that links to it, with a caption. - **Latest posts** — filled with a feed's new posts by [RSS to email](https://sendbeam.io/docs/rss); empty in ordinary campaigns. - **HTML** — your own markup, rendered as written, for the one thing the blocks don't do. Click a block in the palette to append it, or drag it onto the canvas. On the canvas, drag the handle to reorder blocks and use the cross to delete one. Undo and redo are in the toolbar (Ctrl+Z / Ctrl+Shift+Z). ## Draft from a brief Where the assistants are switched on for your workspace, the starter picker offers **Draft from a brief**: describe the email — who it is for, what it says, what the reader should do — and a draft lands on the canvas as ordinary blocks for you to edit. It is written from your brief alone, uses only merge tags your workspace has, always ends with the footer that carries the unsubscribe link, and nothing is sent or saved until you do it. Each plan includes a daily allowance of drafts per workspace. ## Styling Your Email Select a block to edit it in the properties panel. Depending on the block you can set its text, URL, image source, alignment (left, centre, right), and colours. The starter layouts come with coordinated colours you can adjust block by block. For anything the panel does not offer — custom fonts, background images, bespoke spacing — switch to the **HTML** view and edit the markup directly. ## Preview, Rich Text and HTML Views Use the **Preview** view to see the rendered email. The desktop and mobile toggle narrows the frame so you can catch layout issues before sending. The **Edit** view is a rich-text editor over the same HTML: handy for quick copy changes to an email you built visually, or for writing a simple message without blocks at all. If you need full control over the HTML — for example, to paste a template from another tool or add custom CSS — use the **HTML** view: 1. Switch to **HTML**. 2. Paste or edit the markup. 3. Switch to **Preview** to check the rendered result. 4. Continue to **Review** when you are happy. > The views share one HTML document. Changes made in Visual overwrite the HTML, so once you have > edited the HTML or rich text by hand, avoid going back to Visual for that campaign — the block > editor cannot read arbitrary markup. When you reopen a draft to edit it, the builder starts in > the HTML view for the same reason. > --- # Campaigns: Merge Tags Personalise emails with dynamic subscriber data. Merge tags let you insert personalised data into your emails at send time. Instead of a generic greeting like "Hello there", you can write "Hello {{first_name}}" and SendBeam will replace the tag with each subscriber's actual first name when the email is delivered. The result feels personal, even when you're sending to thousands of people at once. ## What Are Merge Tags? A merge tag is a placeholder wrapped in double curly braces: `{{tag_name}}`. When SendBeam processes a campaign for delivery, it replaces each merge tag with the corresponding value from the recipient's contact record. If the contact doesn't have a value for that field, the tag is replaced with its [fallback](#fallback-values) — or with nothing, if you didn't give it one. Merge tags can be used in two places: - **Subject line** — Personalised subject lines can significantly improve open rates. For example: `{{first_name}}, your order summary is ready`. Tags and fallbacks only — [conditions](#conditional-content) belong in the body. - **Email body** — Use merge tags anywhere inside the email content, including inside text blocks, button labels and link URLs. The same tags work in automation emails and templates. > Merge tags are case-sensitive. `{{First_Name}}` is not the same as > `{{first_name}}`. Always use lowercase tag names as listed in the reference > below. > ## Available Merge Tags SendBeam supports the following merge tags: - `{{first_name}}` — The contact's first name, as stored in their profile. - `{{last_name}}` — The contact's last name. - `{{email}}` — The contact's email address. Useful for account confirmation emails or personalised download links. - `{{custom_fields.key}}` — Any custom field stored on the contact, for example `{{custom_fields.company}}`. Values are HTML-escaped, so a field cannot inject markup into your email. - `{{workspace_name}}` — Your workspace's name — the business the email comes from, as shown at the top of the app. Sign off with it instead of typing the name into every email, and a rename reaches every template at once. - `{{sender_name}}` — The From name the workspace sends as (Settings → Workspace). Usually the same as the workspace name, sometimes a person's. - `{{unsubscribe_url}}` — A unique, one-click unsubscribe link generated for each recipient. Put it wherever you want the unsubscribe link to appear. - `{{web_version_url}}` — A "view in browser" link to the campaign's own page at `sendbeam.io/c/`. On a real send it is personalised for that recipient (their merge tags filled in, their unsubscribe link working); the plain page shows the campaign with empty fields. The Footer block in the email builder includes it. Empty in automation and transactional emails, which have no page. The **Merge tags** buttons under the campaign editor insert these at the cursor — First Name goes in as `{{first_name|there}}`, with the fallback text selected so you can type over it; **Business name** and **Sender name** are the two workspace tags. Custom fields you have declared get a button each. > If your email does not contain `{{unsubscribe_url}}` anywhere, SendBeam > appends a small "Unsubscribe" link at the very bottom when it sends, so every email can be > unsubscribed from. Designing your own footer with the tag looks better and lets you control > where it sits. > ## Fallback Values Not every contact has a value for every field. Put a pipe and some text inside the braces and that text is used whenever the value is missing, empty or only whitespace: - `{{first_name|there}}` — "Hi Ada," for Ada, "Hi there," for a contact with no first name. - `{{custom_fields.plan|free}}` — the contact's plan, or "free" when the field is not set. - `{{web_version_url|https://example.com/archive}}` — a link of your own where there is no campaign page (automation and transactional emails). The rules, in full: - Fallbacks work everywhere a tag does: the subject line (both lines of a subject-line test), the email body and the plain-text version, in campaigns, automations, templates and the API. - The fallback is everything after the first `|`, exactly as typed. Spaces are kept — `| there` gives " there" — so put the pipe right after the tag name. - Fallback text is treated like a contact value: in the HTML body it is escaped, so it cannot add markup or links. Plain text if you want plain text. - To show a literal pipe, write `\|`: `{{custom_fields.team|Sales \| Support}}`. That is the only escape; any other backslash is left alone. A fallback cannot contain braces. - An empty fallback (`{{first_name|}}`) is the same as no fallback: the tag renders as nothing. - A fallback never replaces a value that exists. A first name of "0" or "n/a" is shown as stored. > A fallback is for the greeting; it is not a substitute for collecting names. Ask for a first name > on your [signup forms](https://sendbeam.io/docs/forms) so fewer contacts ever see the fallback, and send a > test to yourself to see both versions. > ## Conditional Content Some content is for some recipients only — an offer for customers on a paid plan, a nudge for contacts with nothing in a field, a line that changes with what someone has spent. Wrap it in a condition: ``` {{#if custom_fields.plan is "pro"}}

Your Pro perks this month…

{{else}}

Upgrade to Pro and…

{{/if}} ``` A recipient whose plan is "pro" gets the first part; everyone else gets the second. The `{{else}}` part is optional — without it, recipients who fail the test get nothing where the block was. A condition is a field, an operator and usually a value: - **The field** is any merge tag from the list above, without its braces: `first_name`, `email`, `custom_fields.plan`, `web_version_url`… - **The operator** is written in words, the same ones segments use: `is`, `is not`, `contains`, `does not contain`, `starts with`, `ends with`, `is set`, `is not set`, `is greater than` and `is less than`. Text comparisons ignore case, so `is "pro"` matches "Pro" as well. - **The value** goes in straight double quotes — `"pro"` — or stands bare when it is a number: `is greater than 100`. Numbers compare as numbers, dates as dates. `is set` and `is not set` take no value. Conditions people reach for: - `{{#if first_name is set}}Hi {{first_name}},{{else}}Hi there,{{/if}}` — the same result as `Hi {{first_name|there}},`; a condition earns its place when more than one word changes. - `{{#if custom_fields.lifetime_value is greater than 100}}` — where your store sends [order events](https://sendbeam.io/docs/ecommerce), a customer's spend to date. - `{{#if email ends with "@yourcompany.com"}}` — a note only colleagues see. - `{{#if web_version_url is set}}` — a "view in browser" line only where there is a page to view (campaigns have one; automation emails do not). The rules, in full: - Conditions work in the email body and the plain-text version, in campaigns, automations and templates — in the visual builder, type them into a Text block. They do not work in a subject line: a subject is one line, a [fallback](#fallback-values) does that job there, and the Review step says so. - A condition can contain another condition, and no deeper. - Merge tags inside a condition work as they do anywhere else, fallbacks included, and values are escaped in the HTML body exactly as they are outside one. - A value cannot contain a double quote. Characters the editor stores as HTML entities — an ampersand, a non-breaking space — are read as the characters themselves, so `is "Sales & Support"` matches what is stored on the contact. - A condition SendBeam cannot evaluate — an operator it does not know, a field that does not exist, a `{{/if}}` with no opening, a block nested too deep — is treated like an unresolved tag: the block goes out exactly as typed, tags and both parts, and the Review step lists it under [unresolved tags](#unresolved-tags) with the fix. - An unsubscribe link inside a condition does not count as the email's unsubscribe link, because some recipients would never see it; SendBeam appends its own at the bottom, as it does when there is none. Keep `{{unsubscribe_url}}` outside any condition. > To see each version, pick a contact under **Render as** on the Preview tab — one who passes > the test and one who does not — or send yourself a test rendered as each. > ## Unresolved Tags A tag SendBeam does not recognise is not replaced — it goes out exactly as typed, braces and all, to every recipient. So it is checked before anything is sent: - The campaign wizard's **Review** step lists every `{{…}}` in the subject lines, the body and the plain-text version that will not resolve, says where each one is, and suggests the tag you probably meant (`frist_name`, `First_Name`, `custom.plan` and `{{ first_name }}` all point at the right tag). **Send now** and **Schedule** stay off until the tags are fixed or you tick *Send anyway*. - A draft's own page shows the same list, and its Send button asks for an explicit *Send anyway* while any remain. - **Send test** sends the copy as-is — seeing the literal braces in your own inbox is the point — and names the tags in the dialog afterwards. - Templates and automation email steps show the same warning while you edit, and an automation's activation check repeats it. `custom_fields.key` is checked against Settings → Custom fields: a key nobody has declared is flagged, with the nearest registered field suggested where there is one. A misspelled key that slips through still renders as nothing (or its fallback). The Review step also mentions, as information rather than a warning, when an email has no `{{unsubscribe_url}}` (SendBeam adds an Unsubscribe link at the bottom on send) or no `{{web_version_url}}` (recipients get no "view in browser" link). ## Testing Personalisation Before sending a campaign to your full audience, verify that merge tags resolve correctly — with real data, not placeholders. > The **Preview** tab on the Email step has a **Render as** box: search > for any contact by name or email and the preview re-renders with that contact's first name, last > name, email and custom fields — exactly what the send would produce for them, including > `{{custom_fields.key}}` values and fallbacks. Clear it to see the > tags as typed again. > **Send test** is available from the Email step and the Review step while you are composing (the builder saves a draft as you go, so there is always a campaign to test), and on the campaign's own page afterwards. 1. Click **Send test**. The dialog is addressed to the email you sign in with; add more addresses separated by commas if a colleague should see it too. 2. Optionally pick a contact under **Render as**. The copy is then rendered with that contact's data — the surest way to check a custom field — while still going to the addresses you entered. Its unsubscribe link is bound to the test, never to the contact. 3. Without a contact, the test goes out with your own first name filled in for `{{first_name}}` and no custom fields, so fallbacks show. 4. Check your inbox within a minute or two. The view-in-browser link in a test opens the draft. If merge tags appear unresolved (e.g. you see the literal text `{{first_name}}` in the email), check that: - The tag name is spelled correctly, in lowercase, with double curly braces and no spaces inside them — the Review step will have listed it under [unresolved tags](#unresolved-tags). - A custom-field tag uses the exact key stored on the contact, for example `custom_fields.company`. - The editor has not split the tag across formatting — check the HTML view for stray tags inside the braces. --- # Campaigns: Scheduling Campaigns Schedule campaigns to send at a future date and time. Rather than sending a campaign immediately, you can schedule it to go out at a specific date and time. This is useful when you want emails to arrive during peak engagement hours, coordinate sends with a product launch, or simply prepare campaigns in advance and not worry about remembering to send them manually. ## How to Schedule a Campaign Scheduling happens on the **Review & Send** step of the campaign builder, once your details, audience and email are complete. 1. Build the campaign and click through to the **Review** step. Check the summary of name, subject, from address and audience. 2. Under **Schedule**, pick the date and time in the date-and-time field. It must be at least a minute in the future. 3. Click the schedule button. The campaign status changes to **Scheduled** and you are taken to its page, which shows when it will send. > Scheduled campaigns are picked up by a background job that runs about once a minute, so > sending starts within a minute or so of the chosen time. > ## Timezones The date-and-time field uses your **browser's local timezone** — the one your computer or phone is set to. SendBeam stores the moment in UTC and shows it back to you in your local time on the campaign page. If your audience is mostly in another timezone, work out the local send time there and enter the equivalent in yours. For example, to reach New York at 9 am from London during British Summer Time, schedule for 2 pm. > If you schedule from a laptop set to a different timezone than you expect (for example while > travelling), the campaign will go out at the wrong moment. Double-check the time shown on the > campaign page after scheduling, especially around daylight saving changes. > ## Editing or Cancelling a Scheduled Send A scheduled campaign can still be edited and can be cancelled at any point before its send time. To cancel a scheduled send: 1. Open the campaign from the **Campaigns** list. 2. Click the **Cancel** button at the top of the page and confirm. 3. The campaign's status becomes **Cancelled**. It will not send. To change a scheduled campaign's content, audience or time, click **Edit** on its page; the builder reopens with everything filled in. Sending or scheduling from the Review step replaces the previous schedule. > A cancelled campaign cannot be sent again. If you still want to send it, use > **Duplicate** on its page to create a fresh draft with the same content. > When the scheduled time arrives, SendBeam resolves the audience and checks that the workspace can send. If the audience has become empty, the send would exceed the account's monthly email allowance, or sending is paused, the campaign is set to **Cancelled** instead of sent. The reason is shown on the campaign's page and as a tooltip on its status in the Campaigns list, and every admin of the workspace receives an email explaining why, with a link to the campaign. Duplicate it to send again once the problem is fixed. ## Spreading a send over a few hours The Details step has a **Pace** setting: as fast as your plan allows, or spread over two hours up to two days. Spreading a large send is kinder to a new sending domain and to inbox placement, and it keeps replies arriving at a rate you can answer. A workspace admin can set a default pace under Settings → General, and their own hourly cap below the plan's; a campaign's own pace wins over the default. A send in progress shows its pace on the campaign page. ## Best Times to Send On the Pro and Business plans a campaign can choose its own time per person: tick **Send at each contact's best hour** on the Details step and each contact receives it at the hour they usually open your emails — the most common hour of their opens over the last six months, once there are at least three — within a day of the start. Contacts without enough opens receive it at the start. The campaign shows as sending for up to a day; **Stop sending** on its page ends it early, keeping what has gone out. A campaign does this or runs an A/B test, not both, and the timing follows the hour in UTC, which is where a habit already shows. While the ideal send time depends on your specific audience, industry benchmarks offer a useful starting point: - **Tuesday to Thursday** consistently outperform Monday and Friday for B2B audiences, as recipients are less distracted by start-of-week and end-of-week tasks. - **Mid-morning (9 am – 11 am)** in your audience's local timezone tends to catch people when they are working through their inbox after morning tasks. - **Early afternoon (1 pm – 2 pm)** is another peak window, particularly for consumer-facing emails. - **Avoid late Friday afternoons and weekends** unless your audience is known to engage then — many emails sent at these times are buried or deleted unread. > The best way to find your audience's optimal send time is to test it: run an > [A/B test on the send time](https://sendbeam.io/docs/campaigns/creating#subject-line-test), which sends the same > email to a sample at two or more times and sends the rest at the time that did better. Use what you learn to schedule future > campaigns more effectively. > --- # Campaigns: Sending Campaigns What happens when you hit send. When you are happy with your campaign — content is polished, merge tags are in place, and the right audience is selected — it's time to send. SendBeam hands your messages to its **managed delivery**, which signs them for your domain and routes them through its mail infrastructure to maximise inbox placement. ## Before You Send Take a few minutes to run through this checklist before confirming a send. Mistakes in email campaigns can't be undone once delivery begins. - **Preview the email** — Use the desktop and mobile preview on the Email step to confirm layout, images, and text all look correct at both screen sizes. - **Send a test email** — Click **Send test** on the Email step, on the Review step, or on the campaign's own page. It goes to your own address by default, or to a list of addresses — a colleague, a client — up to 5 on Free and 20 on paid plans, with an hourly allowance per workspace. Choose *Render as* to fill the merge tags with a real contact's name, email and custom fields; the copy still goes to the addresses you entered, and its unsubscribe link cannot unsubscribe anyone. Test copies carry a working view-in-browser link, are not counted against your plan and do not appear in the stats. Check the copy on a desktop email client and on a phone: merge tags, images, links. - **Check the subject line** — Re-read it for typos and think about how any merge tag reads when the contact has no value for it. - **Verify the from address** — Make sure the domain is verified under Email & Domains and the display name looks professional. SendBeam re-checks the workspace's sender address on every send and only sends as its own shared address or a domain it has verified. - **Check the audience** — Confirm the right list or segment is selected, and look at the **recipients right now** figure under Audience on the campaign page. It is computed by the same rules as the send itself, so it is the number that will be queued (the list's contact count and a segment's preview count also include unconfirmed and unsubscribed contacts, so they can be higher). If the campaign cannot be sent — no one to send to, or the audience would take you past your monthly allowance — the reason is shown in the same place. The API equivalent is `GET /api/v1/campaigns/{id}/audience`. - **Check the unsubscribe link** — Put the `{'{{unsubscribe_url}}'}` merge tag where you want it. If it is missing, SendBeam adds a plain unsubscribe link at the bottom of the email. - **Check your allowance** — The send is refused if the audience would take the account past its monthly email limit. Usage is shown under Account settings → Billing & Plans. Each plan also has an hourly ceiling, shown under Billing; a campaign that reaches it simply continues in the next hour. > Open your test email on a mobile device, not just on desktop. More than half of all emails > are read on mobile, and layout problems often only appear at narrow screen widths. > ## The Send Now Flow To send a campaign immediately: 1. On the **Review & Send** step of the builder, check the summary of campaign name, subject, from address and audience. 2. Click **Send Now**. 3. The campaign is saved, its status changes to **Sending** as recipients are queued, and you are taken to the campaign page. Within seconds to a few minutes (depending on audience size), it transitions to **Sent**. You can also send a saved draft from its own page: open it from the Campaigns list, click **Send**, and confirm the dialog. > There is no recall or undo once the send starts. Treat **Send Now** as final and > use the checklist above first. > ## How Sending Works Once you confirm a send, SendBeam processes your campaign in the background. Here's what happens under the hood: - **Audience resolution** — SendBeam fetches the contacts matching your chosen audience (all contacts, list members, or segment). Only contacts whose status is **Subscribed** are included; on a double opt-in list, only confirmed members. On the Free plan only contacts who have confirmed a subscription are included, whatever the audience. - **Allowance check** — The whole audience must fit within what is left of the account's monthly email allowance, or the send is refused with a "Monthly email limit reached" message. For a scheduled campaign the check happens at send time; if it fails, the campaign is cancelled with the reason recorded on its page and workspace admins are emailed. - **Queuing** — One send record is created per recipient and the campaign moves to **Sending**. A background job drains the queue in batches, sharing each pass fairly between every campaign that is sending so a large campaign does not hold up a small one. Within the plan's hourly ceiling, what does not fit waits for the next hour. A recipient whose email cannot be sent is recorded as failed, with the reason. - **Merge tag substitution** — For each recipient, merge tags in the subject line and email body are replaced with that contact's data. - **Tracking** — Each email gets a unique tracking pixel for open detection and every link is rewritten through SendBeam's click redirect so that opens and clicks are recorded in your campaign statistics. A click-tracking link only redirects for an email SendBeam actually sent. Unsubscribe links are signed per recipient, and one-click unsubscribe headers are added. - **Delivery** — Messages are signed for your domain and handed to the receiving mail servers. Bounces and complaints flow back automatically and update the contact. If the workspace's bounce or complaint rate climbs, admins are emailed a warning, and past the safety line sending is paused — see [Delivery](https://sendbeam.io/docs/admin/settings#delivery). > For large campaigns (thousands of contacts), the **Sending** status may persist > for several minutes while the queue drains. Refresh the campaign page to see progress in the > recipients table. > ## Sent Status is Final Once a campaign reaches **Sent** status, it cannot be modified, resent, or recalled. The emails are already in transit and many will have been received by recipients within seconds of the send starting. If you discover a mistake in a sent campaign — a broken link, a typo, an incorrect merge tag — your options are limited to: - **Sending a follow-up campaign** — Create a new campaign acknowledging the error and providing the correct information. - **Duplicating and resending** — Use the [Duplicate](https://sendbeam.io/docs/campaigns/duplicating) feature to clone the campaign, fix the issue, and send a corrected version to the same audience. This is why the pre-send checklist and test emails are so important. A small investment of time before sending prevents the need for damage-control afterwards. --- # Campaigns: Campaign Statistics Understand how your campaigns performed. Every campaign in SendBeam has a page that shows its statistics, updated as delivery and tracking events arrive. Use it to evaluate campaign performance, understand audience engagement, and identify deliverability issues before they affect future sends. ## The Campaign Page To open a campaign's stats, click the campaign in the **Campaigns** list. The page shows the six headline numbers at the top, the campaign details (subject, from address, audience) underneath, and a recipients table with one row per contact. Stats begin populating as soon as the first messages are delivered. Open and click events trickle in over hours and days — most engagement happens within 24 hours, but it's not unusual to see opens weeks after a campaign was sent. > The Campaigns list shows sent, opens and clicks for every campaign; the > [Reports](https://sendbeam.io/docs/analytics) page aggregates them over 7, 30 or 90 days. > ## Key Metrics SendBeam tracks the following for each campaign. Each figure is a count of recipients, and the percentage shown beneath it is that count divided by **Sent**: - **Sent** — The number of recipients the campaign was dispatched to. - **Delivered** — Messages accepted by the receiving mail servers. - **Opened** — Recipients who opened the email at least once. Tracked via a hidden 1×1 pixel image loaded when the email is rendered; machine opens from scanners and prefetching are excluded. Repeat opens by the same person are not counted again. - **Clicked** — Recipients who clicked at least one link. A click also counts as an open. - **Bounced** — Emails that could not be delivered, hard and soft. See Bounce Types below. - **Unsubscribed** — Recipients who unsubscribed from this email or reported it as spam. These contacts are automatically marked as Unsubscribed or Complained and excluded from future sends. > Open tracking relies on image loading, which some email clients block. Your true open rate is > likely higher than the tracked figure. Click rate is a more reliable engagement indicator > since it doesn't depend on pixel loading. > ## Links and Email Clients Once a campaign has opens or clicks, two more panels appear above the recipients table. **Links clicked** lists every link in the email with its clicks and the number of people who clicked it. **Opened with** shows the share of human opens by email client (Gmail, Apple Mail, Outlook, Yahoo, Thunderbird…) and by device. Two honest caveats: link scanners and mail-client prefetchers are filtered out of both, and Gmail and Yahoo load images through a proxy, so their opens count but their device is unknown. The same data is available from `GET /api/v1/campaigns/{id}/report`. A campaign whose email carries a [Poll block](https://sendbeam.io/docs/campaigns/polls) gets a third panel, **Poll answers**, with how many recipients chose each answer. ## A/B Test Results A campaign with an A/B test shows a panel above the details with every version (subject lines, from names, or the first line of each content version), how many test emails each received and their open and click rates so far — and, where your store sends SendBeam its orders, what each version earned ([revenue attribution](https://sendbeam.io/docs/ecommerce/revenue)). While the test runs you can end it early — **Pick the winner now** uses the numbers; **Send A** or **Send B to everyone** overrides them. Once decided, the panel names the winner, when and how it was chosen, and how many recipients received it. The campaign's overall statistics include the test sample and the remaining send. ## Bounce Types Bounces fall into two categories, each requiring different action: - **Hard bounce** — A permanent delivery failure. Common causes include addresses that don't exist, domains that have no mail server, or receiving servers that have permanently rejected the address. SendBeam automatically marks hard-bounced contacts as **Bounced** and excludes them from all future campaigns. No action is required on your part, but you may want to remove or correct these addresses in your contact list. - **Soft bounce** — A temporary delivery failure. Common causes include full mailboxes, temporarily unavailable mail servers, or messages that were too large. A soft bounce counts in the campaign's Bounced figure, but the contact's status is unchanged and they are included in future sends. > A high bounce rate (above 2%) signals list quality issues and can damage the sending > reputation of your domain; past the pause level SendBeam stops the workspace's sending. > Regularly clean your contact list, and never import purchased or scraped email lists. > ## Recipients Table Below the headline metrics, the recipients table lists every contact the campaign was sent to with their current delivery status — Queued, Sent, Delivered, Opened, Clicked, Bounced or Complained — and the time they first opened and clicked. Fifty recipients are shown per page. Use it to: - **Answer "did they get it?"** — find a specific contact and check their status. - **Spot delivery problems** — a run of Bounced rows from one domain suggests that domain is rejecting your mail. - **Watch a send in progress** — while the campaign is Sending, rows move from Queued to Sent as the queue drains. For a per-email log across all campaigns, automations and API sends, see the **Log** tab under [Reports](https://sendbeam.io/docs/analytics). --- # Campaigns: In-email polls Ask your readers one question from inside the email, and see how they answered on the campaign's report. A poll is one question with two to five answers, each answer a button in the email. A recipient taps the button that matches, lands on a short thank-you page, and their answer shows up on the campaign's report as a distribution: how many chose each answer. It is the quick way to ask "was this useful?", "which of these should we write about next?" or "how likely are you to recommend us?" without sending anyone to a form. ## Adding a poll In the [email builder](https://sendbeam.io/docs/campaigns/email-builder), add the **Poll** block. Its settings hold the **Question** and the **Answers**, one per line — at least two, at most five. Blank lines are ignored, and a sixth line is not rendered. Under Style you can set the question's size and colour, the buttons' colour and corner radius, and the block's alignment, the same way as a Button block. A poll can sit in a campaign or in a template. The answers are tied to the campaign the email was sent from, so a template with a poll in it gives every campaign made from it its own results. That is also the catch: an automation sends without a campaign, so a poll in an automation's email collects answers nobody can see yet — see [Limits](#limits). > Keep the answers short — they are buttons, and five long labels wrap awkwardly on a phone. > "Yes", "No" and "Not sure" read better than sentences. > ## What a recipient sees The question as a line of text, then the answers as buttons. Tapping one opens a page in their browser that says thanks and nothing else — it does not know who they are or what they were asked, and it does not need to: the click itself is the answer. There is nothing to submit and no form to fill in. Every link in a sent email is tracked per recipient, and a poll button is a link like any other. So a poll answer counts as a click in the campaign's **Clicked** figure and its click rate, and a recipient who answers is shown as Clicked in the recipients table. ## Reading the answers Open the campaign. Once it has opens or clicks, a **Poll answers** panel appears above the recipients table, one section per poll in the email, with each answer's share and count and the number of people who answered. The same data is in `GET /api/v1/campaigns/{id}/report` under `polls` — one entry per poll block, with the question, the labels and a count per option. Poll answers are not listed under `links` there; they are reported as answers instead. ## How answers are counted - **One answer per recipient.** Someone who taps two buttons is counted once, for the answer they chose last — a second click is a changed mind, not a second vote. - **Machines are left out.** Link scanners and mail-client prefetchers follow every link in an email, including all five buttons; those clicks are recognised and excluded, so a scanner never answers your poll. - **Only email counts.** The poll also appears in the campaign's [web version](https://sendbeam.io/docs/campaigns/merge-tags), but a click there has no recipient to be attributed to and is not counted. - **Per campaign.** Answers belong to the campaign whose email carried the button. Duplicating a campaign starts a fresh count. ## Tagging by answer In the block's settings, **Tag people by their answer** takes an optional tag per answer. A recipient who taps that answer gets the tag the moment they do; a tag that does not exist yet is created. If they answer again, the tag for their earlier answer comes off and the new one goes on, so a person only ever carries one answer's tag per poll. An automation whose trigger is that tag starts as it would from any other tagging. - Tags are applied for campaign sends, and read from the campaign's block as it is now: editing the tags after sending changes what later taps apply. - A forwarded email tags the original recipient, since the buttons carry their link. - People are tagged whatever their status; automations still only start for subscribed contacts. - Machines never tag, for the same reason they never answer. ## Limits One question per block, two to five answers; add a second Poll block for a second question. The results are read on the report and through the API; to act on an individual's answer, tag it (above) and let an automation take it from there. Answers are reported for campaigns only. A template with a poll in it can be chosen for an automation's email step, and the email goes out with the buttons in it, but there is no campaign for those answers to belong to, so they are not shown anywhere yet. The step's settings say so when the template you pick has a poll; until that changes, keep polls in campaigns. In a content A/B test, keep the poll's question and answers the same in every version. A poll block keeps its identity when a version is copied, so its answers are reported together, under the wording in version A. To ask something different in one version, delete the block there and add a new Poll block in its place. --- # Campaigns: Duplicating Campaigns Clone an existing campaign to reuse its content and settings. Duplicating a campaign creates a new draft that inherits the content and settings of an existing campaign. It's one of the quickest ways to create a follow-up send, run a variation test, or produce a recurring send without starting from scratch every time. ## How to Duplicate a Campaign You can duplicate any campaign regardless of its status — draft, scheduled, sent or cancelled. The source campaign is never modified; duplication always produces a brand new draft. 1. Go to the **Campaigns** list in the sidebar. 2. Click the campaign you want to copy to open its page. 3. Click the **Duplicate** button at the top of the page. 4. SendBeam creates a new draft campaign and opens its page. Click **Edit** to review the copied settings and make any adjustments before sending. > From your own code, `POST /api/v1/campaigns/{id}/duplicate` does the same > thing. See the [API reference](https://sendbeam.io/docs/api). > ## Send Again to Non-Openers A sent campaign has a second button next to Duplicate: **Send again to non-openers**. It creates the same draft copy, but aimed at a segment called *"Did not open: [campaign name]"* whose one rule is *received the original and did not open it*. Bounced and complained recipients are left out, and the rule is evaluated again when the draft is sent, so anyone who opens the original in the meantime drops out. Change the subject line before sending — the point of sending again is a second chance with a different hook, not the identical email twice. The segment is reused if you send the same campaign again, so your segments list does not fill with copies. From the API, send `{"audience": "non_openers"}` as the body of `POST /api/v1/campaigns/{id}/duplicate`. ## What Gets Copied The following settings and content are copied into the new draft: - **Campaign name** — The new campaign is named *"[original name] (Copy)"* so you can immediately identify it as a duplicate. You should rename it to something meaningful before sending. - **Subject line** — The full subject line, including any merge tags, is copied verbatim. Update it if the new campaign has different messaging. - **From name and from email** — The sender identity is carried over. Change these if you are sending from a different address or brand persona. - **Email body** — The complete email content — the HTML and the plain-text version — is duplicated in full, including all styles, images, and links. The copy opens in the HTML view when you edit it. - **Recipient selection** — The audience type (all contacts, specific list, or segment) and the specific list or segment chosen are copied. Verify these are still correct for the new send. ## What Does Not Get Copied The following are intentionally excluded from the duplicate: - **Statistics** — The new campaign starts with zero opens, clicks, bounces, and unsubscribes. Stats from the original campaign are not carried over and remain only on the original. - **Schedule** — Even if the original campaign was scheduled or has a past send date, the duplicate is created as an unscheduled draft. You will need to schedule it separately or send it manually. - **Sent status** — The duplicate always begins as a **Draft**, regardless of whether the original campaign was sent. You must explicitly send or schedule the new campaign. > Duplicating a campaign does not affect deliverability or create any risk of double-sending. > The duplicate is a completely independent campaign that only sends when you explicitly > trigger it. > ## When to Use Duplication Duplication is especially useful in the following scenarios: - **Recurring newsletters** — If you send a monthly newsletter with a consistent layout, duplicate last month's campaign, update the content and date references, and send. This preserves your established design without rebuilding from scratch. - **A/B testing subject lines** — Duplicate a campaign, change only the subject line in the copy, and send each version to a different list or segment. Compare open rates in the statistics for both campaigns to determine which subject line performed better. - **Seasonal or event-based campaigns** — Promotional emails for recurring events (Black Friday, end-of-year sales, product anniversaries) often share the same structure year after year. Duplicate the previous year's campaign and update the offers and dates. - **Recovering a cancelled send** — If a scheduled campaign was cancelled (by you, or because the monthly allowance ran out), duplicate it and schedule the copy. > Segments cannot filter on who opened a previous campaign, so a "send again to non-openers" needs > care: sending the duplicate to the same audience reaches everyone again, including people who > already read it. > --- # Templates: Templates Design reusable emails for campaigns and automations. Templates are reusable emails that live independently of any single campaign or automation. Once you build a template, you can send it from as many automations as you like, and reuse its HTML in campaigns — giving every message a consistent look without starting from a blank canvas each time. ## What are templates? A SendBeam template stores a name, a category, an optional default subject line, and the full HTML of an email: layout, brand colours, logo, footer text and any boilerplate copy. Templates do **not** store recipients or a send schedule — those belong to the campaign or automation that uses them. Templates can contain: - Header and footer sections with your logo and unsubscribe link - Placeholder content that you replace per send - Brand colours, button styles, and typography - Merge tags such as `{{first_name}}` and `{{custom_fields.company}}` - Social links and legal text > Templates belong to the workspace. All team members with access to the workspace can view, use, and edit them. > ## Templates vs campaigns It helps to think of templates as the **design layer** and campaigns as the **delivery layer**. The two are separate so you can keep a polished design in one place and reuse it whenever you need it. | Aspect | Template | Campaign | | --- | --- | --- | | Purpose | Reusable email design | One specific email send | | Subject line | Optional default | Required | | Recipients | Not stored | All contacts, a list or a segment | | Send schedule | Not stored | Immediate or scheduled | | Used by automations | Yes — a Send Email step can pick one | No | | Editable after send | Yes | No (locked after send) | An automation's **Send Email** step references the template directly, so editing the template changes what future automation emails look like. A campaign has its own copy of the HTML, so changes to a template never alter campaigns that already used it. ## Built-in starter layouts The visual editor opens with a **Choose a Template** screen offering pre-built layouts you can start from, or a blank canvas. They cover the most common use cases: - **Welcome Email** — a warm greeting for new subscribers with a call-to-action button - **Newsletter** — a clean single-column layout for regular updates - **Product Announcement** — a layout for showcasing a new feature or launch - **Sale / Promotion** — a bold design for offers and discounts - **Event Invitation** — details and a registration button for events and webinars - **Simple Text** — a no-frills layout that reads like a personal email > Starter layouts are starting points, not saved templates. Pick one, make it yours, and save the result as a template so the whole team can reuse it. > ## How templates save time Once your brand template is set up, launching a new email is a matter of minutes rather than hours. Here is the typical workflow: 1. Open **Templates** and build your brand template with the visual editor. 2. For automations, choose the template in each **Send Email** step — the email is sent exactly as designed, with merge tags filled in per contact. 3. For campaigns, open the template, copy its HTML from the **HTML** view, and paste it into the campaign's HTML view; then replace the placeholder content. 4. Set your subject line, audience and schedule, then send. Because the header, footer, brand colours, and legal copy are already in place, you only need to focus on the unique content for that send. > Create separate templates for different email types — for example, one for newsletters, one for transactional-style announcements, and one for promotions — and file them under the matching category. Keeping them focused makes it faster to pick the right starting point. > --- # Templates: Creating Templates Build and edit reusable email templates. You can build as many templates as you need in SendBeam. Templates are created and managed from the **Templates** section in the main navigation. This page walks you through the full lifecycle: creation, design, saving, editing, and deletion. ## Create a new template Follow these steps to create a brand new template: 1. In the left-hand navigation, click **Templates**. 2. Click the **New Template** button in the top-right corner of the page. 3. Under **Template Details**, enter a name. Choose something descriptive — for example, "Monthly Newsletter — Brand v2" rather than "Template 3". 4. Pick a **Category** (Welcome, Newsletter, Promotion, Transactional or Custom) to keep your library organised. 5. Optionally enter a default **Subject**. Automations that send the template use it as the email's subject. 6. Design the email in the **Visual** view, or switch to **HTML** to paste your own markup, then save. > If you paste HTML from another tool, use the **HTML** view. It is kept exactly as written; the visual editor is for emails built from blocks. A template's HTML may hold up to 500,000 characters; anything larger is refused when you save. > ## Designing with the email builder The visual editor is the same drag-and-drop builder used for campaigns. Changes made here are saved with the template and used by any automation that sends it. Key builder features available when editing a template: - **Starter layouts** — the editor opens with a choice of pre-built layouts (Welcome Email, Newsletter, Product Announcement, Sale / Promotion, Event Invitation, Simple Text) or a blank canvas. - **Blocks panel** — add Header, Text, Image, Button, Divider, Spacer, Columns, Social Links and Footer blocks by clicking or dragging them onto the canvas. - **Block properties** — click any block to edit its content, alignment, colours and links in the properties panel. - **Merge tags** — the **Insert** buttons above the HTML view add `{{first_name}}`, `{{last_name}}`, `{{email}}` and `{{unsubscribe_url}}` at the cursor. You can type any merge tag by hand in either view. - **Preview** — the **Preview** view renders the email as recipients will see it, with a desktop and mobile toggle. - **Undo and redo** — the toolbar keeps a history of block changes. > Add placeholder instructional text in brackets — such as *[Replace with campaign headline]* — to signal to yourself and teammates exactly which parts of the template should be updated for each new send. > The Footer block includes an unsubscribe link out of the box. If an email has no `{{unsubscribe_url}}` anywhere, SendBeam appends a plain unsubscribe link at the bottom when it sends, so recipients can always opt out — but designing your own footer looks better. ## Saving and naming best practices Click the save button to store the template. There is no auto-save, so save before leaving the page. Your most recent save is what appears on the Templates page. Tips for keeping your template library easy to navigate: - Include the email type and version in the name, for example "Welcome Series — Minimal v1". - Use categories to group related templates (see [Template Categories](https://sendbeam.io/docs/templates/categories)). - Prefix retired templates with an underscore or label them "Archive —" so they are easy to spot. - Avoid generic names like "New Template" — they make it hard to pick the right design in an automation step. > Automations reference templates by ID, so renaming a template is safe. Editing its content changes every automation email sent from it afterwards. > ## Editing and deleting templates You can edit a template at any time. Edits affect automation emails sent after the change; campaigns keep their own copy of the HTML and are not affected. To edit an existing template: 1. Go to **Templates** and locate the template you want to change. Use the category chips to narrow the list. 2. Click the template's name (or **Edit** in the **⋯** menu at the end of its row). A template built in the visual editor reopens there, block by block; one that was pasted or written as HTML opens in the HTML view, because the visual editor cannot take bare HTML apart. Switch views as needed. 3. Make your changes. 4. Save to apply the updated design. To delete a template: 1. Open the template and click **Delete** at the top of the editor. 2. Confirm the deletion in the dialog that appears. > Deleting a template is permanent and cannot be undone. Check that no automation's **Send Email** step still uses it — those steps would have nothing to send. > --- # Templates: Template Categories Organise templates into categories for easy browsing. As your template library grows, finding the right design quickly becomes important. Template categories let you group related templates together so you and your team can filter and select designs without scrolling through an unsorted list. ## What are categories? A category is a label assigned to each template. Categories appear as filter chips at the top of the Templates page, allowing anyone in your workspace to narrow the list down to the type of email they are looking for in a single click. Every template belongs to exactly one category. The set of categories is fixed; you cannot add your own. > The **All** chip shows every template regardless of category. > ## Available categories SendBeam provides five categories that cover the most common email programme needs: - **Welcome** — for onboarding sequences, new subscriber greetings, and post-signup emails. Often used in automations. - **Newsletter** — for recurring content digests, industry roundups, and subscriber updates. Typically sent on a regular cadence (weekly, monthly). - **Promotion** — for discount offers, flash sales, seasonal campaigns, and any email with a primary commercial intent. - **Transactional** — for confirmations, receipts and notifications, typically sent from an automation or through the API. - **Custom** — for anything that does not fit the other four. > Keep the categories meaningful: use **Custom** for genuine one-offs rather than as a dumping ground, and encode finer distinctions in the template name (for example "Promotion — Black Friday"). > ## Choosing a category You pick a category when you create the template and can change it at any time. A few rules of thumb: - Categorise by *purpose*, not by design. A promotional email in newsletter styling is still a Promotion. - Use **Transactional** for emails that should look like plain, reliable system messages. - Agree the conventions with your team so everyone files templates the same way. ## Assigning and filtering by category You can assign a category to a template when you first create it, or at any point afterwards. To change a template's category after creation: 1. Go to **Templates** and open the template. 2. Under **Template Details**, choose a new value from the **Category** dropdown. 3. Save. To filter the template list by category: - Click any category chip at the top of the Templates page to show only templates in that category. - Click **All** to clear the filter and view every template. > Each template card shows its category badge and default subject line, so you can identify the right one at a glance. > --- # Templates: Using Templates in Campaigns Start a campaign or automation email from a template. Templates become truly useful when you use them as the starting point for every email you send. This page explains how to bring a template into a campaign, how to customise the loaded design, how automations send templates directly, and how to promote a finished campaign design back into your template library. ## Using a template in a campaign Your templates are offered in the campaign builder itself: 1. Go to **Campaigns**, click **New Campaign**, and fill in the **Details** and **Audience** steps. 2. On the **Email** step, the **Visual** view opens on a picker. Under **Your templates** — above the built-in starters — choose the template. 3. A template built in the visual editor loads as editable blocks. One that is HTML only (pasted or hand-written) opens in the **HTML** view instead, because the visual editor cannot take bare HTML apart; edit it there or in **Write**. 4. Use **Preview** to check it, then continue to **Review**. > Choosing a template copies the design into the campaign. The template itself is not modified, and the campaign is not linked to the template — they are independent from then on. The picker is also behind the **Starters** button in the visual editor's toolbar, so you can reach it after starting from scratch. > ## Customising after insertion A template is a **starting point**, not a locked frame. Once its HTML is in the campaign, edit it in the **Edit** view (a rich-text editor with bold, italic, lists, alignment, links and images) or directly in the **HTML** view. You are expected to update the content to suit the specific campaign — the template simply provides the branded structure. Typical customisations after loading a template: - Replace the hero image with a campaign-specific graphic or product photo. - Update the headline and body copy to reflect the current campaign message. - Change the call-to-action button label and URL. - Add or remove sections — for example, a second image for a multi-product promotion. - Adjust colours for a special campaign theme (seasonal, event-specific). - Insert merge tags where personalisation is needed; the **Merge tags** buttons under the editor add them at the cursor. > If your template uses placeholder text in brackets such as *[Campaign headline goes here]*, search the HTML for `[` before sending so no placeholders slip through. > None of the changes you make within the campaign feed back into the original template. This means your master template remains clean and unchanged no matter how many campaigns customise it. ## Using a template in an automation Automations use templates directly. In a **Send Email** step, choose **Use Template** and pick the template from the dropdown; the step sends the template's HTML with its default subject, filling in merge tags for each contact. Alternatively choose **Write custom email** to give the step its own subject and HTML. > Because the step references the template rather than copying it, editing the template changes every email that automation sends from then on. Duplicate the template first if you want to experiment without affecting a live sequence. > ## Saving campaign content as a new template Sometimes you build something great inside a campaign and want to reuse it for future sends. Promote the campaign's design into a template with **Save as template**: 1. In the campaign wizard, on the **Email** step, click **Save as template** in the editor's toolbar. On a campaign's own page — a draft, a scheduled one or one already sent — the same button sits next to **Duplicate**. 2. Give it a name (the campaign name is offered) and confirm. The template lands under **Templates** in the *Custom* category, with the campaign's subject line as its default subject; a link to it appears right there. An email built in the visual editor is saved with its blocks, so the template reopens in the visual editor and can be edited block by block. An email written or pasted as HTML is saved as HTML only and opens in the HTML view. Either way the template is a snapshot of the design at that moment: later changes to the campaign do not update the template, and changes to the template do not affect the campaign. > This workflow is especially useful when your design is refined over several campaigns. Once it feels right, promote it to a template so the entire team can benefit from the polished layout going forward. > You now have a full picture of how templates and campaigns work together in SendBeam. To learn about automating email sends based on subscriber events, continue to the [Automations](https://sendbeam.io/docs/automations) section. --- # Templates: Custom Blocks Write a block of your own in MJML once, mark what may change, and let anyone place it in the email builder without breaking the design. The [email builder](https://sendbeam.io/docs/campaigns/email-builder) comes with sixteen block types. A custom block is one of your own: a hero on your brand colour, a product card laid out the way your site lays it out, a footer with the legal line nobody may reword. Whoever looks after the brand writes it once, in MJML, and marks the parts that may change. From then on it sits in the builder under **Your blocks**, and whoever writes the email fills in those parts and nothing else. ## What a custom block is A custom block has two halves. The **design** is MJML — a small markup language for email that compiles to HTML known to render the same in Outlook, Gmail and Apple Mail — and it is locked: the builder never shows it and a marketer cannot edit it. The **slots** are the words, links and images the design declares as changeable; they are the only fields the builder offers for the block. The MJML is compiled once, when the block is saved. The builder places the compiled result, so sending an email with custom blocks in it is no different from sending any other. ## Writing a block 1. Go to **Templates → Custom blocks** and click **New block**. 2. Write the block on the left. The right-hand pane compiles it as you type and shows what MJML made of it, with any attribute it did not understand listed above the preview. 3. Give it a name (this is what the builder shows) and, if it helps, a line of description. 4. Under **Slots**, give each slot a label the marketer will understand and a default it shows until they type something. 5. Click **Save block**. A block is a whole MJML document: start with ``, put styles and fonts in ``, and the block itself — one or more ``s — in ``. The builder's own document is 600 pixels wide and white; a section's background colour fills the block's width. The [MJML documentation](https://documentation.mjml.io/) covers the tags. Anything active — script, forms, embedded frames — is removed when the block is compiled. ## Slots A slot is a double-bracketed name anywhere in the MJML: | Written as | The marketer gets | Notes | | --- | --- | --- | | `[[headline]]` | A text field | What they type is escaped; line breaks are kept. No markup can come in through a slot. | | `[[cta:url]]` | A link field | Use it as an `href`. Only `http(s)`, `mailto` and `tel` addresses, and merge tags such as `{{web_version_url}}`, are kept; anything else becomes `#`. | | `[[hero:image]]` | An image address field | Use it as an `src`. Same rules as a link. | A name is letters, digits and underscores. The same name used twice is one slot filled in both places, which is how a button's link and a heading's link stay the same. Up to twenty slots per block. Merge tags such as `{{first_name|there}}` work inside a block exactly as they do anywhere else in the email. ## Using a block in an email In the email builder — in a campaign, a template or an automation's email — the block palette on the left ends with **Your blocks**. Click one, or drag it onto the email, and it appears with its defaults. Select it and the side panel lists its slots by their labels; type into them and the block updates. There is nothing else to change on it: no colours, no padding, no text outside the slots. ## Changing a block later Editing a block in the library changes what the builder offers from then on. An email that already placed the block keeps the copy it took when it was placed — a sent campaign never changes — and a draft picks up the new design the next time it is opened in the builder, with the values already typed into its slots kept. Deleting a block removes it from the palette; emails that used it are untouched. ## From the API `POST /api/v1/custom-blocks` with `name` and `mjml` compiles and stores a block; the response carries the compiled `html` and `head_html`, the `slots` the markup declared and any `warnings`. `POST /api/v1/custom-blocks/preview` compiles without saving. A campaign or template created through the API places a block as `{ "type": "custom", "props": { "blockId", "fragment", "head", "slots", "values" } }` in its `blocks`, and `html_content` is rendered from it as for any other block. The [API reference](https://sendbeam.io/docs/api#tag/Templates) has the full shapes. --- # Automations: Automations Set up trigger-based email workflows that run on autopilot. Automations in SendBeam let you build email workflows that fire automatically in response to contact events. Once an automation is active, it works around the clock — enrolling contacts, sending emails, waiting, evaluating conditions, and applying tags without any manual effort on your part. > Automations are available on every plan. The number that can be **live** at once, **per workspace**, depends on the plan: 1 on Free, 5 on Starter, unlimited on Pro and Business. Emails sent by automations count towards your monthly email allowance. > ## What are automations? An automation is a sequence of **steps** that executes for each contact individually, triggered by a specific event. Every contact moves through the automation at their own pace — a wait step pauses that contact's journey without affecting anyone else's. Automations are made up of two building blocks: - **Triggers** — the events that enrol a contact. An automation can have up to three. - **Steps** — send an email, wait, branch on a condition, add or remove a tag, set a field, unsubscribe, or start another automation. Only contacts whose status is **Subscribed** are enrolled, and a contact who unsubscribes, bounces or complains part-way through leaves automatically (their run is marked **exited**) before any further step runs. A contact who reaches the end of their path — including the end of an empty branch — is marked **complete**. By default a contact goes through an automation once (an anniversary automation is the exception — it repeats yearly). Allowing them to repeat is a setting on the automation, with a cooldown, because re-entry re-sends the whole sequence. See [Letting contacts repeat](https://sendbeam.io/docs/automations/triggers#repeats). ## Automations vs. campaigns Both automations and campaigns send emails through SendBeam, but they serve very different purposes: | Feature | Campaigns | Automations | | --- | --- | --- | | Timing | Sent once, now or at a scheduled time | Ongoing — triggered per contact | | Audience | All contacts, a list or a segment | Individual contacts as events occur | | Multi-step | No — single email | Yes — as many steps as you like | | Personalisation | Merge tags | Merge tags + condition steps | | Use case | Newsletters, promotions | Welcome series, onboarding, drips | > Use **campaigns** for time-sensitive announcements and broadcasts. Use **automations** for relationship-building sequences that should run continuously as new contacts join. > ## Common use cases Here are some of the most effective automation workflows: - **Welcome series** — Greet new subscribers with a warm introduction email, followed by a brand story email two days later, and a first offer on day five. - **Onboarding drip** — Guide new contacts through your product over their first two weeks with tips, tutorials, and check-in emails timed from the day they joined. - **Post-purchase follow-up** — Send a thank-you email when a `purchased` tag is applied (by hand or via the API), then a review request three days later. - **Lead magnet delivery** — Send the download the moment someone submits a signup form, then a short series of educational emails. - **Tag-driven hand-offs** — End one sequence by adding a tag that starts the next, so contacts flow from onboarding into a nurture track automatically. ## Automation statuses Every automation in SendBeam has one of three statuses: - **Draft** — The automation has been created but is not yet active. No contacts will be enrolled. You can freely edit all settings, steps, and the trigger. - **Active** — The automation is live and enrolling contacts as the trigger fires. Contacts already enrolled continue progressing through their steps. - **Paused** — The automation has been stopped. Contacts already enrolled are held at their current step and carry on when you re-activate the automation. No new contacts are enrolled while paused. > Deleting an automation removes it together with its steps and enrolments. Pause it first and check the **Enrolled Contacts** list for contacts mid-sequence before deleting. > --- # Automations: Triggers The events that start an automation, the filters that narrow them, and the rules that decide who is enrolled. Every automation begins with a trigger — an event that tells SendBeam to enrol a contact into the flow. When a trigger fires, the contact enters at the first step and works down from there. An automation can carry up to **three** triggers. They are combined with OR: a contact who matches *any* of them enters. The trigger is the first node on the automation's canvas. Click it to choose the type, set its target and filters, and decide whether contacts may go through more than once. ## What is a trigger? A trigger is an event SendBeam watches for. The moment a matching event happens for a contact whose status is **Subscribed**, an enrolment is created and — for an event the app itself caused (a form submission, a tag, a new contact, a list join, a field change, an API call) — the first steps run straight away, in the background of that very request. Date triggers, and steps that follow a wait, are picked up by a scheduler that runs about once a minute. Unsubscribed, bounced and complained contacts are never enrolled. Triggers do not fire retroactively. Activate an automation with a **Joined a list** trigger and the contacts already on that list stay where they are — only people added afterwards enter. > To run a new automation for people you already have, give it a **Tag added** trigger, activate it, then apply that tag to those contacts in bulk from the Contacts page. Activate first — a tag applied before the automation is live fires nothing. > ## Who a trigger enrols A trigger's name tells you *when* it fires. It does not tell you *who* it fires for, and the two are easy to confuse. Every trigger in the builder therefore carries a **Who this enrols** line, and the same label appears beside each automation on the Automations page and on its own page, so you can see the reach of everything you have built without opening any of it. - **Everyone** — no one is excluded. **Contact created** is always this: every contact added to the workspace by any route, a row of an import included. So is any trigger whose target you have left unchosen — "any tag", "any list", "any form". - **A named thing** — one tag, one list, one form, one field. A deliberate audience, though still everyone who passes through it. - **On request** — **Started through the API**, **Started by another automation** and a named **Something happened elsewhere** event. Nobody enters unless something names them. - **Narrowed** — any of the above with an [Only if](#trigger-filters) rule on it. > If a trigger says **Everyone** and you did not mean everyone, the fix is an > **Only if** rule on the trigger — not a Condition step, which lets the contact > in first and only then decides what to do with them. > ## Available trigger types ### Contact created Fires when a new contact is created — by hand, through a signup form, via the API, or by a CSV import. - A CSV import enrols every newly created contact whose status is Subscribed, so pause a welcome sequence before importing an existing audience. - Contacts that already existed during an import are not enrolled. ### Joined a list Fires when a contact is added to a list — from the list's page, a signup form, or the API. - Fires only for the list you chose. Leave it on **Any list** to fire for every list. - On a [double opt-in](https://sendbeam.io/docs/lists/double-optin) list it fires when the contact **confirms**, not when they are first added. ### Submitted a form Fires when someone submits a SendBeam signup form. - Signup forms only; contact forms never create contacts or start automations. - Fires even if the contact already existed — it is based on the submission, not on contact creation. - Fires on submission, before any double opt-in confirmation. For confirmed subscribers only, use **Joined a list** on the form's list instead. ### Tag added Fires when a tag is applied. Useful for post-purchase follow-ups and hand-offs between automations. - Fires however the tag arrived — the Contacts page, a bulk action, another automation's **Add Tag** step, or the API. - Fires only for the tag you chose; leave it on **Any tag** to fire for all of them. - Only fires when the tag is newly applied. A contact who already had it is not re-enrolled. ### Tag removed The mirror of the above: fires when a tag is taken off a contact. Good for win-back sequences when someone leaves a segment you maintain with tags. ### Field updated Fires when a named field on the contact changes to a different value. Enter the field name — a custom field like `plan`, or a built-in one like `first_name`. - Only a real change counts. Saving the same value again fires nothing. - It fires on updates, not on contact creation — use **Contact created** for that. ### Anniversary of a date Fires every year on the month and day held in a date field — birthdays, renewal anniversaries, the anniversary of signing up. - Name a custom field holding a date as `YYYY-MM-DD`, or use `created_at` for the date the contact joined. - Set an offset to fire early or late: **7 days before** a renewal date, say. - Checked once per processing run rather than on an event, so it fires on the day rather than at an exact time. - **Runs every year.** A new automation with this trigger has repeats switched on, with a 300-day gap between a contact's runs, so it fires on every anniversary and never twice in one year. Automations created before 14 September 2026 keep whatever repeat setting they were saved with — open one and check the box if it should be yearly. ### On a specific date Fires once, on the calendar date in a date field — a trial ending, a renewal, an event. Takes the same offset and direction as the anniversary trigger. - Needs a custom date field. `created_at` is not accepted here, because a specific date on the signup date could only ever be in the past. ### Started via the API Fires only when you ask it to. Nothing enrols on its own — you name the contact in a request: ``` POST /api/v1/automations/{id}/trigger { "email": "sam@example.com" } ``` The automation must be active and must carry this trigger, otherwise the request is refused. See the [API reference](https://sendbeam.io/docs/api) for the full shape. ### Something happened elsewhere Fires when another system tells SendBeam that something happened, by **name**: ``` POST /api/v1/events { "event": "deal_won", "email": "sam@example.com" } ``` You type the same name into the trigger — `deal_won` — and every automation listening for it runs for that contact. The sender says what happened; you decide what it should do. Either side can change without telling the other, which is what makes this different from **Started via the API**, where the caller has to know the automation's id and breaks when you rebuild the automation. - Names are 1–60 characters: letters, numbers, dot, dash or underscore. They are lower-cased, so `Deal_Won` and `deal_won` are the same event. - An event **never creates a contact**. Somebody else's system mentioning an address is not consent to email it. An address you do not already hold comes back `"matched": false` — and as `200`, not an error, so a sending system does not retry a perfectly normal case for ever. - Anything that can make an HTTP request can send one: n8n, Zapier, Make, a cron job, or your own application. No app review and no listing anywhere. ### Started by another automation Fires when another automation reaches a **Start Automation** step naming this one. That is how a long sequence is split into shorter ones that hand over to each other. - The automation being started has to carry this trigger, or the step does nothing. - An automation cannot start itself. ### Cart abandoned Fires when your store reports a cart or checkout that was started but not completed. Fed by [e-commerce events](https://sendbeam.io/docs/ecommerce) — the WooCommerce plugin's own timer, or a Shopify checkout webhook. **This is a best-effort heuristic, not a guarantee**: no platform can prove a shopper will never come back, and it only ever fires for a cart where the store already knows an email address. ### Product viewed Fires when a known contact views a product page. Only ever fires for a KNOWN contact — there is no anonymous visitor tracking behind this, so an anonymous browsing session fires nothing. See [e-commerce events](https://sendbeam.io/docs/ecommerce). ### Order placed Fires when your store reports a completed order, and adds the order's value to the contact's `lifetime_value` custom field (created automatically, as a Number field, the first time an order arrives) — it INCREASES the total by each order rather than replacing it. See [e-commerce events](https://sendbeam.io/docs/ecommerce). ## Using more than one trigger Press **Add trigger** to add a second or third. They are OR'd, so a contact who matches any one of them enters — once. Matching two triggers at the same moment does not enrol anyone twice. Each trigger keeps its own target and its own filters, so "tagged *VIP*" and "joined the *Launch* list" can feed the same sequence on different conditions. ## Trigger filters A filter narrows who a trigger lets in. A contact who fires the trigger but fails its filters is not enrolled at all — which is different from entering and then being sent down a **Condition**'s No path. Set one in the builder: open the trigger and use **Only if…**. Every rule you add must be true for the contact to be let in — tests on contact fields, including **Source**, which is how you separate people who filled in a form from people who arrived in an import. > Worth doing on **Contact created** in particular. That trigger fires however a > contact arrives — by hand, through a form, through the API — so in a workspace that holds > more than one kind of person, an unfiltered one enrols all of them. SendBeam warns you about > this when you activate an automation in a workspace whose contacts come from several sources. > ## Enrolment rules The same rules apply however a trigger fires — dashboard, form, import or API: - Only contacts whose status is **Subscribed** are enrolled. - A contact is never enrolled twice in the same automation while an earlier run of theirs is still going. - A contact who has **finished** the automation does not re-enter unless you turn repeats on — see below. - Runs are independent across automations: a contact can be part-way through one and enter another at the same time. > Unsubscribes are respected inside a sequence too. Before each step runs, SendBeam re-checks the contact's status and ends the run if they are no longer subscribed. You do not need a Condition step for that. > ## Letting contacts repeat By default a contact goes through an automation **once**. Tick **Let a contact go through this more than once** to allow re-entry, and set the minimum gap between runs — 24 hours by default. The one exception is the **Anniversary of a date** trigger, which is yearly by nature: a new automation with that trigger starts with repeats on and a 300-day gap, and the builder says so next to the box. > Re-entry re-sends the whole sequence. With a **Tag added** trigger, a tag that gets removed and re-added — by an integration, a re-import, or a bulk retag — would mail the contact the entire series again. That is why repeats are off unless you ask for them, and why the cooldown exists even when they are on. > The **On a specific date** trigger is different: a renewal date or a trial end is one day, so it stays once-only unless you switch repeats on yourself. --- # Automations: Action Steps The building blocks of an automation: emails, waits, conditions and the actions that change a contact. Steps are the individual things an automation does. Once a trigger enrols a contact, they move from step to step at their own pace, following whichever path the flow sends them down. Each step is configured on its own. Changing a step's settings while the automation is active affects every contact who reaches it from then on. ## Available step types ### Send Email Sends an email when the contact reaches this step. Either pick a saved [template](https://sendbeam.io/docs/templates), or write the step's own subject, HTML body and optional plain-text fallback. Merge tags are filled in with the contact's data at the moment of sending. - Sent from the workspace's sender name and address (Settings → Email & Domains). - Open and click tracking, the unsubscribe link and the one-click unsubscribe header all work the same as for campaigns. - Each email counts towards the account's monthly allowance. If the allowance runs out the send fails, is logged, and the contact moves on. - Only subscribed contacts reach it: anyone who unsubscribed, bounced or complained since enrolment leaves before any step runs. > A template referenced by a step is live: edit the template and every email sent from that step afterwards changes with it. > Tick **Test a second subject line** to give the step a Subject B. Half of the contacts reaching the step get each subject (the same body either way), and the automation's page shows sent, opened and clicked per subject. See [Testing a sequence](#testing-a-sequence). ### Wait Holds the contact before the next step. Four ways to say when: - **Wait for a length of time** — minutes, hours or days, counted from when the contact reaches the step. - **Wait until a time of day** — the next time it is, say, 09:00. Tick *Send at the time each contact originally signed up* to use each contact's own signup hour instead. - **Wait until a day of the week** — pick one or more days, so a sequence only ever lands on weekdays. - **Wait until a day of the month** — the 1st, the 15th. A month too short for the day you chose uses its last day, so the 31st still fires in February. The last three take a timezone, so "09:00" means 09:00 where your audience is. Waits are honoured by a scheduler that runs about once a minute, so a wait may end up to a minute late — that is the one place an automation is not instant. ### Condition Splits the flow in two. Contacts who match go down the **Yes** path, contacts who do not go down the **No** path. Either path may be left empty, which simply ends the run for the contacts who take it. A condition holds a group of rules combined with **all**, **any** or **none**. Each rule tests either: - **A contact field** — status, first name, last name, email, source, or any custom field by name. - **Email activity** — whether the contact opened or clicked the email sent by an *earlier* step in this same automation. Only emails the contact has actually passed are offered. Field operators: equals, does not equal, contains, does not contain, starts with, ends with, is greater than, is less than, is empty, is not empty. Greater and less than compare numerically when both sides are numbers. ### Split Sends a share of the contacts reaching it down branch **A** and the rest down branch **B** — a 50/50 by default, or any whole-number split you set. Each contact is placed once, the moment they reach the step, and stays on that branch for the rest of their run; a contact who is enrolled again later is placed afresh. Either branch may be left empty, which ends the run for the contacts who take it. Set A to 100% to send everyone down the branch that did better. The numbers are on the automation's page. ### Add Tag / Remove Tag Applies or removes a tag. Useful for marking progress — tagging someone `onboarded` once they finish a sequence. - If the change would be a no-op — adding a tag they already have — the step completes silently. - Adding or removing a tag can start another automation using the matching tag trigger. ### Set Field Writes a value onto the contact. First name, last name, source and language write to the contact itself; any other name becomes a custom field you can merge into a later email or test in a condition. Language takes a code from the list (`fr`, `de`); a value that is not a language clears it rather than stopping the flow, so a form answer can set it safely. Choose **Increase by** instead of **Set** to add a number to the field's current value rather than replacing it — a running counter such as a visit count or a loyalty total. Increase by only works on a Number custom field (an undeclared key is created as a Number field the first time you use it this way, never as Text), and the amount may be negative to count down. This is what powers `lifetime_value` on [Order placed](https://sendbeam.io/docs/automations/triggers#available-trigger-types) — build your own counters with this step the same way. ### Unsubscribe Takes the contact off one list, or unsubscribes them entirely. Use it to close out a re-engagement sequence for people who never came back. > Unsubscribing from everything stops all future email to that contact and cannot be undone from inside the automation. Leaving a single list is the softer option. > ### Start Automation Hands the contact to another automation, which is how a long sequence is split into shorter ones. The automation being started must carry the **Started by another automation** trigger, and an automation cannot start itself. ### Call Webhook Tells your own systems that a contact has reached this point in the automation, so a CRM can be updated, a task raised, or an order checked without anybody watching the flow. The step carries no address of its own. It sends the `automation.step_reached` event, and the webhook endpoints you have already set up under **Settings → Webhooks** deliver it — signed, retried on failure, and scoped to particular automations if you want only some of them. That means one endpoint serves every automation you build, and a URL that changes is changed in one place. Give the step a **label** — `notify-crm`, say. It arrives as `step.label`, and it is what the receiving system should branch on: an automation's name changes when somebody renames it, and a step's id changes when the automation is rebuilt. > An endpoint has to subscribe to `automation.step_reached` before this step does anything. The step tells you whether one does, and links to the form with the event already chosen; activation warns you as well. > ## How a contact moves through A contact advances one step at a time along whichever path the flow gives them. Emails, tags, field writes and unsubscribes all complete within the same processing pass; a wait holds the contact until its time is up. Every contact's position is tracked independently and by *step*, not by position in a list — so editing an automation, reordering it or inserting a step never moves a contact who is already part-way through onto something they were not meant to receive. > You can see exactly where each contact is in the automation's **Enrolled Contacts** table — their status, the step they are on, and when their next action is due. > ## Branching with conditions Drop a **Condition** onto the canvas and it grows two connectors, one marked Yes and one marked No. Add steps to either. A branch left empty ends the run for the contacts who reach it — they finish the automation rather than being ejected from it. Because a condition can test email activity, the common shape is: send an email, wait a few days, then branch on whether they opened it — one path nudges, the other congratulates. ## Bringing branches back together Two branches can end at the same step. Point both at it and the flow draws them converging — everything after that point is shared, so a common ending does not have to be built twice. An automation can never loop back on itself. If an edit would send a contact round in a circle the automation refuses to save, because a loop in a sending engine means someone emailed forever. ## Testing a sequence There are two ways to test inside an automation. A **Split** step tests two different paths — a shorter sequence against a longer one, a discount against none. A **second subject line** on a Send Email step tests only the subject, with the same email behind both. The automation's page shows a table per test: for a split, how many contacts entered each branch and what the emails on that branch (and only that branch — steps after the two rejoin count for neither) sent, opened, clicked and, where a store is connected, earned; for a subject test, sent, opened and clicked per subject. The figures cover sends since the test was added, within the send history SendBeam keeps. Once both sides have enough sends, the one ahead on click rate (for a split) or open rate (for a subject) is marked **Ahead**. Nothing is promoted for you. A campaign test can pick a winner because the rest of the audience is waiting to be sent to; an automation has no such moment — contacts arrive one at a time, for as long as it runs. When you have seen enough, set the split to 100% for the better branch, or keep the better subject and untick the test. The report stays for the steps as they are now: if you delete or replace a branch's steps, their history goes with them. --- # Automations: Building Workflows Create your first automation workflow step by step. The automation builder is one canvas. The automation's name sits at the top; everything else — the trigger and every step — is a node on the flow below it. The **trigger is the first node**. Click it to choose what starts the automation and whether contacts may repeat; click any step to configure it. Both open in the same panel under the canvas, so the flow itself stays readable. Nodes are laid out for you. There is nothing to drag into position: the canvas arranges the flow, and you pan, zoom and **fit to width** to move around it. > Before building your first automation, it helps to have an email [template](https://sendbeam.io/docs/templates/creating) ready for the **Send Email** steps. A step that uses a template takes its subject and content from the template at send time, so editing the template updates every automation that uses it; **Customise for this step** copies the template into the step when one automation needs its own version. You can also write an email directly in the step, in which case a subject and content are both required. > ## Draft from a brief Where the assistants are switched on for your workspace, **New automation** offers **Describe it**: say what starts the automation, what it sends and how far apart, and a recipe comes back — a trigger, a sequence of emails, waits and tags — which you resolve exactly like a gallery recipe, choosing the list, form or tag it names, and import as a draft. Nothing runs until you activate it, and every email is yours to edit first. Each plan includes a daily allowance of drafts. ## Starting from a recipe **New automation** opens a gallery: a blank canvas, or one of eight recipes that already have the trigger, the steps and starter copy in place — a welcome series, a tag hand-off, a birthday email, a renewal reminder, re-engagement, post-purchase follow-up, form → sales follow-up and a signup anniversary. Open one and it shows exactly what it does before anything is created: the trigger, every step in order (both branches of a condition), and how repeats will be set. A recipe cannot know what your workspace calls things, so the same screen asks for each thing it needs — the list a welcome series watches, the tag a hand-off keys on, the Date field a birthday email reads. For each you pick an existing item (one of the same name is pre-selected), **create it now** (a tag or list by name; a field with the right type, declared under Settings → Custom fields), or, for a tag, list or form, **choose later**. A slot left for later opens in the editor marked as still to choose, and the automation cannot be activated until it is filled — or until you say "use any tag" on purpose, because "any tag" would be a different automation from the one the recipe describes. Every import is a **draft**. The copy is a starting point: change it on the canvas, then activate. Recipes are also available through the API (`GET /api/v1/automation-recipes`, `POST /api/v1/automation-recipes/{slug}`). ## Creating a new automation Follow these steps to create a new automation from scratch (choose **Blank** in the gallery): 1. Navigate to **Automations** in the left sidebar and click **New Automation** in the top-right corner. 2. Enter a descriptive name (e.g., "Welcome Series — Newsletter") and, optionally, a description. Both are for internal reference only and are never visible to contacts. 3. Click the **trigger node** at the top of the canvas and choose what starts the automation. There are fourteen trigger types, and you can add up to three — a contact matching any of them enters. See the [Triggers](https://sendbeam.io/docs/automations/triggers) page for a full breakdown. 4. Pick the tag, list or form the trigger watches — or leave it on "any" to fire for all of them. Date triggers take a date field and an optional offset. Only this workspace's tags, lists and forms can be chosen; through the API a reference to another workspace's template, tag, list or form is refused with a `400` that names the offending field. 5. Decide whether a contact may go through more than once. This is off by default, except for an anniversary trigger, which starts yearly — see [Letting contacts repeat](https://sendbeam.io/docs/automations/triggers#repeats). 6. Draw the flow (see below), then save. The automation is created as a **Draft**. ## Building the flow Every connector carries a **+**, starting with the one directly beneath the trigger. Click it, choose a step type, and the step is inserted at that point with whatever was below pushed down beneath it. Click a step's card to open its settings underneath the canvas. The card itself shows a summary — the subject line, the tag's name, "3 rules, match any" — so you can read the flow without opening anything. A card outlined in amber is missing something it needs, and will do nothing until you fill it in. A **Condition** grows two connectors, **Yes** and **No**, and you build a branch under each. Two branches may end at the same step, so a shared ending is only built once. ### Moving and deleting steps Use the **⋮** menu on a card: - **Move step** — every connector turns into a target; click where it should go. Nothing is dragged, so this works on a phone as well as a desktop. - **Delete step** — the gap closes and the steps on either side join up. > Deleting a **Condition** also deletes one of its branches. The flow can only continue down one path, so the other becomes unreachable — SendBeam tells you how many steps will go with it and asks first. > > Use the automation's **Description** to note what the sequence is for and why it is built the way it is — especially helpful for flows a teammate may edit later. > ## Example: 3-email welcome series Here's how to build a classic three-email welcome series that introduces new subscribers to your brand over five days: 1. Set the trigger to **List Joined** and select your main newsletter list. 2. Add a **Send Email** step and select your "Welcome" template. This is the first email the contact receives shortly after subscribing (on a double opt-in list, shortly after they confirm). 3. Add a **Wait** step and set it to **2 days**. This gives the subscriber time to read the welcome email before the next one arrives. 4. Add a second **Send Email** step with your "Top Tips" template — content that delivers immediate value and sets expectations for your emails. 5. Add a **Wait** step and set it to **3 days**. 6. Add a third **Send Email** step with your "Special Welcome Offer" template. This final email includes a time-sensitive discount or invitation. 7. Optionally add an **Add Tag** step at the end to apply a `welcome-series-complete` tag. This makes it easy to see who has finished the series, and can trigger the next sequence. Your completed workflow should look like this in the builder: - Trigger: List Joined — Newsletter - Step 1: Send Email — Welcome - Step 2: Wait — 2 days - Step 3: Send Email — Top Tips - Step 4: Wait — 3 days - Step 5: Send Email — Special Welcome Offer - Step 6: Add Tag — welcome-series-complete Anyone who unsubscribes during the two waits leaves the sequence automatically, so no status checks are needed. ## Saving and activating Click the save button when you are done. The builder does not auto-save, so save before leaving the page. Saving creates the automation as a **Draft** and opens its detail page. When your workflow is ready, click **Activate** (the play button) on the detail page. SendBeam checks that your account has a free slot within its plan's live-automation limit (1 on Free, 5 on Starter, unlimited on Pro and Business); if not, it tells you to pause another automation or upgrade. Once active, the automation begins enrolling contacts as its trigger fires. Event triggers run the first steps at once; a wait, or a date trigger, is honoured by the scheduler within about a minute. > Activating an automation does not retroactively enrol contacts. Only contacts who trigger the event *after* activation are enrolled. To bring existing contacts in, use a **Tag Added** trigger and tag them in bulk from the Contacts page. > --- # Automations: Managing Automations Activate, pause, and monitor your automation workflows. Once you've built an automation, you'll manage it from the **Automations** list page and the individual automation detail view. This page covers everything you need to keep your workflows running smoothly — from first activation through ongoing monitoring and eventual retirement. ## Activating an automation A newly created automation starts in **Draft** status. No contacts are enrolled until you activate it. To activate a draft automation: 1. Open the automation from the **Automations** list. 2. Review the trigger and steps shown on the detail page to confirm everything is correctly configured. 3. Click the **Activate** (play) button at the top of the page. 4. SendBeam checks the account's live-automation limit for your plan. If you are at the limit, it asks you to pause another automation or upgrade. 5. The status badge changes to **Active**. The automation is now live. From the moment of activation, SendBeam enrols contacts as the trigger fires. An event trigger (a form submission, a tag, a new contact, a list join, a field change, an API call) runs the first steps straight away; anything time-based — a wait, a date trigger — is picked up by the scheduler, which runs about once a minute. > Before activating a new automation for real, test it with yourself as the contact: add your own address to the trigger list or apply the trigger tag, then verify you receive the emails correctly before going live for your full audience. > ### What activation checks Activating runs the automation through a pre-flight before switching it on, and refuses with the reasons if it could not do what it says: - there are steps, and a trigger; - every email step has something to send — a template that still exists, or its own subject and content; - every tag, list, form or automation a step or the trigger points at still exists in this workspace; - waits have a length or a weekday; - the workspace can send: a verified from-address (or the shared one), sending not paused, allowance left. Things that will run but may not be what you meant come back as warnings and do not block: a flow with no email step, a step nothing leads to, a step that starts this same automation again. The same checks run every time you open the automation, so a template deleted *after* activation shows up as a problem on the page and as a **Needs attention** badge in the list. If a run fails — a send is refused, a template has gone — the automation records the reason, shows it on its page for seven days, and the workspace admins get one email a day per automation about it. Contacts are not stuck: they continue past the failing step, so the fix is a matter of correcting the cause, not restarting anyone. ## Pausing an automation You can pause an active automation at any time. This is useful when you want to change the workflow, investigate an issue, or suspend sends during a sensitive period. To pause an active automation: 1. Open the automation and click the **Pause** button at the top of the page. 2. The status badge changes to **Paused**. When an automation is paused: - **No new contacts are enrolled** — trigger events that occur while paused are ignored. - **In-progress contacts are held** — their steps are not processed while the automation is paused. - **Paused automations do not count** towards the plan's live-automation limit. To resume a paused automation, click **Activate** again. Held contacts carry on from the step they were at. Any wait that ran out while the automation was paused is treated as finished, so the next step runs on the following processing pass. ## Viewing enrolment stats The automation detail page shows headline numbers and a per-contact table. The headline numbers are: - **Total Enrolled** — the number of runs started since the automation was created. - **Active** — contacts who are mid-workflow and have not yet completed or exited. - **Completed** — contacts who have reached the end of the steps. The **Enrolled Contacts** table lists every run with the contact's name and email, the run's status (active, completed or exited), the current step, when the next action is due, and when they were enrolled. To see the emails an automation has actually sent, open the **Log** tab under [Reports](https://sendbeam.io/docs/analytics) and filter by Automations. ### Starting later When a large number of contacts enrol at once — an import, a list add — the ones beyond your plan's hourly threshold are given start times spread over the next couple of hours instead of all going at the same moment, so a big arrival becomes a steady stream from your domain. The page shows them as **Starting later**; nothing is dropped and there is nothing to set. ## Editing an automation Click **Edit** on the detail page to open the builder. You can change the name, description, trigger and steps whether the automation is a draft, active or paused. Pausing first is still a good habit when you are restructuring a live sequence. 1. Pause the automation if contacts are mid-sequence and the change is significant. 2. Make your changes in the builder — update step settings, reorder steps, swap templates, or adjust wait durations — and save. 3. Click **Activate** to resume the automation with the updated configuration. Changes take effect for contacts from the point they reach each step — they do not retroactively affect steps already completed. For example, if you update the template on a Send Email step, contacts who have already passed that step received the old template. Only contacts who reach that step after the update will receive the new version. > Reordering or removing steps while contacts are mid-run changes which step they land on next, because a run remembers its position by step number. Pause, edit, and check the Enrolled Contacts table before re-activating. > ## Deleting automations Deleting an automation is a permanent action that cannot be undone. The automation, its steps and its enrolments are removed. To delete an automation: 1. Pause it first so nothing is mid-flight, and check the Enrolled Contacts table. 2. Open the automation and click **Delete** at the top of the page. 3. Confirm. The automation, its steps and every enrolment are removed immediately. From your own code, `DELETE /api/v1/automations/{id}` does the same (an API key with `automations:write`, on any plan). Contacts who were enrolled in a deleted automation are not affected — they remain in your contacts database. Tags applied by the automation remain on the contact unless you remove them. > If you want to retire an automation without losing its history, pause it instead of deleting it. Paused automations do not count towards any plan limit and can always be reviewed later. > --- # Forms: Forms Build embeddable signup forms to grow your email list from your website. ## What are forms? Forms are embeddable HTML signup widgets you create inside SendBeam and place on your website. When a visitor fills out the form, SendBeam automatically creates a contact and adds them to the list you specify — no server-side code required on your end. Forms are the primary way most teams grow their email audience organically. You can create as many as you need: one in a blog sidebar for newsletter signups, another on a landing page for a lead magnet, and a third in the site footer as a general subscribe prompt. Each form is independent, with its own fields, protection settings and target list. A form can also be a [contact form](#contact-forms) that emails you the visitor's message instead. > **No SendBeam branding.** Embedded forms show no SendBeam logos or attribution. > The form appears as a native part of your website. > ## How forms work The process from visitor to subscriber is fully automatic: 1. A visitor lands on your page and fills in the form fields. 2. SendBeam receives the submission over a secure HTTPS connection. 3. A contact record is created (or updated if the email already exists — new names and custom fields are merged in), and the contact is added to the form's target list. 4. If the list uses double opt-in (or your account is on the Free plan), the membership is held as unconfirmed and a confirmation email is sent automatically. 5. Any active automation with the **Form Submitted** trigger (or **Contact Created**, for a new contact) starts for the contact straight away. **List Joined** automations for the form's list start too — immediately, or once the contact confirms if the list uses double opt-in. 6. The visitor sees your thank-you message or is redirected to the URL you set. The embed snippet is plain HTML and JavaScript that posts to the form's endpoint. The thank-you message and redirect are returned by SendBeam on each submission, so you can change them without touching your site; if you change the form's fields or enable Turnstile, copy the snippet again. > **Works on any platform.** The embed code is plain HTML and JavaScript. It > runs on static sites, WordPress, Webflow, Squarespace, Wix, and custom-built applications > — anywhere you can paste a snippet of HTML. > ## Views and conversion rate Every form counts how many times it has been shown to a person, and shows its submissions as a share of that: the **Views**, **Submissions** and **Conversion** columns on the Forms page, the **Performance** panel on each form's own page, and `views`, `submissions` and `conversion_rate` on each form in the [API](https://sendbeam.io/docs/api). Two forms with the same number of signups can be doing very different jobs — one seen by a hundred visitors, the other by ten thousand — and this is what tells them apart. - **What counts as a view.** The form rendered for a visitor on its hosted page, in a pop-up, through the WordPress plugin, or where the inline code has been pasted. Search-engine crawlers, link previews and prefetches are left out, and repeated loads from one visitor stop counting after a while, so a page reloaded in a loop does not become a thousand views. - **What counts as a submission.** Every submission the form accepts — everyone who filled it in and was shown your thank-you message, whether or not a new contact came of it. Attempts the automated-submission checks drop are not counted, so they cannot bury your rate. - **The rate.** Submissions divided by views, shown once the form has been seen at least once. A form you submit through the API rather than by showing it to people has submissions with no views, so its rate can read over 100%. - **Since when.** Both counts started the day this was introduced; earlier submissions are not counted, so the two numbers always cover the same period. > **Inline embeds need the code copied again.** The hosted page, the pop-up and the WordPress > plugin count views on their own. The inline snippet is plain HTML on your site, so a snippet pasted > before views existed sends nothing — copy it again from the form's page and its views start counting. > Submissions through the old snippet are counted either way. > ## Testing a second version Once a form has views, you can try a second version against it. On the form's page, under **Second version**, write what B should say instead — a different headline, line of copy, button label, thank-you message or button colour, any or all of them — and start the test. From then on, half of the people who see the form on its hosted page, in the pop-up or through the WordPress plugin see version B, picked at random on each view, and each version's views and submissions are counted on their own. - **The fields never change.** A version can change the words and the look, not what is collected, so every submission lands on the contact the same way. - **No cookie.** The choice is made fresh on every view, so someone who reloads may see the other version. Nothing is set on the visitor's browser. - **Inline embeds show A.** The pasted snippet is plain HTML on your site and cannot switch; its views and submissions count towards version A. - **You decide.** The page shows which version is converting better and tells you when each has been seen enough times for that to mean something. **Use B** makes B's words the form's own; **Keep A** ends the test with everything as it was. Nothing is switched automatically. - **From the API.** `ab_test` on the form: send `{ "status": "testing", "b": { … } }` to start, `{ "status": "decided", "winner": "b" }` to decide, `null` to clear. `versions` carries each version's views, submissions and rate. ## Spam protection Every SendBeam form includes built-in protections so you do not need to configure anything extra to keep bots out: - **Automated-submission checks** — a post that carries the signs of a script rather than a person is silently discarded. They are always on and need nothing from you. - **Rate limiting** — submissions are limited per visitor and capped per form per day. The caps are shown, and adjustable, on the form's settings page. - **Email format validation** — malformed addresses are rejected at submission time with a clear error. > **Enable double opt-in for the strongest protection.** Even if a bot submits > a fake address, it will never confirm, so the contact stays unconfirmed and never receives > campaigns sent to the list. Double opt-in is a list-level setting — see > [Double Opt-In](https://sendbeam.io/docs/lists/double-optin) for details. > ## Forms vs other signup methods Forms are not the only way to add contacts to SendBeam. Here is when to use each approach: - **Embedded forms** — best for organic, self-service signups directly from your website. - **CSV import** — best for bulk-loading an existing list you have collected elsewhere. - **Manual add** — best for adding individual contacts one at a time. - **API** — best for programmatic signups from a custom form, checkout flow, or application. ## Contact forms Not every form is a signup. Set a form's type to **Contact** and SendBeam does the opposite job: the visitor's message is emailed *to you*, sent from your verified sending address with the visitor as `Reply-To`, so you answer by hitting reply. - **No contact is created.** Someone reporting a bug has not asked for your newsletter. Contact forms never touch your contacts, lists or automations. - **Same spam protection** as signup forms, applied automatically. - **Honest failures.** If the notification cannot be sent, the visitor gets an error instead of a false "sent", so your site can fall back to a plain `mailto:` link. - **Fields:** `email` and `message` are required; `name` and `subject` are optional. Any extra field you declare on the form is included in the email. Signup forms can use the same notification: set *Email me about new subscribers* to get a short email each time someone joins. Notifications appear in the **Log** tab under Reports. ### Protecting public forms The public endpoint has no API key, on purpose: anything in a web page is visible to every visitor. Instead, every form is protected against automated submissions, rate-limited per visitor and capped per form per day; the caps are shown, and adjustable, on the form's settings page. For forms on busy or public sites, the form's **Protection** section adds two more layers: - **Allowed sites.** List the origins the form lives on (for example `https://www.example.com`). Browsers cannot forge their origin, so this stops the form being embedded or driven from anywhere else. A hand-written script can still fake it, which is what the next layer is for. - **Cloudflare Turnstile.** Cloudflare's free, mostly invisible bot check. Create a widget in your Cloudflare dashboard, paste the site key and secret key into the form, and the embed code adds the widget automatically. Every submission must then carry a valid token; SendBeam verifies it with Cloudflare before doing anything else. --- # Forms: Creating a Form Create a new embeddable signup form. ## Create a new form 1. Go to **Forms** in the main sidebar. 2. Click the add button in the upper right corner to open the **Create New Form** panel. 3. Give the form a name and choose its **Form Type**: *Signup* (adds the visitor to your contacts) or *Contact* (emails the message to you). 4. For a signup form, choose a list under **Add to List**. For a contact form, enter the address messages should go to under **Send messages to**. 5. Set the **Thank You Message** and, optionally, a **Redirect URL**. 6. Create the form, then open it to choose fields, set protection options and copy the embed code. The form's detail page shows the embed code and a direct API endpoint; the snippet uses plain, inline-styled inputs so it fits most sites as-is. ## Form type: signup or contact A **signup** form does what forms have always done in SendBeam: it creates a contact, adds them to the target list, and fires any automations that trigger on form submission or list join. A **contact** form does the opposite job. The visitor writes a message, and SendBeam emails it to the address in *Send messages to*, from your verified sending address, with the visitor's address as Reply-To. You answer by hitting reply. Nothing is added to your contacts, lists or automations, because writing in is not the same as subscribing. The destination must be the address of a member of this workspace or an address on one of the workspace's verified sending domains; anything else is refused when the form is saved. Two optional settings apply to both types: *Notification subject prefix* puts a fixed tag at the start of every notification subject (for example `[Acme site]`), and on a signup form *Email me about new subscribers* sends you a short note each time someone joins. See [Contact forms](https://sendbeam.io/docs/forms#contact-forms) for the details and [Protecting public forms](https://sendbeam.io/docs/forms#protecting-public-forms) for the anti-abuse settings. ## Form name and target list The **form name** is internal — visitors never see it. Use a descriptive name that tells you where the form lives and what it is for, such as "Blog sidebar — newsletter" or "Pricing page — product updates". Clear names make it easy to find the right form later. The **target list** is the list that new subscribers are added to when they submit the form. Select any of this workspace's lists from the **Add to List** dropdown, or leave it as *No list* to create contacts without a list membership. If you need a new list, create it first under **Lists**, then return to the form. Through the API, a `list_id` that is not one of the workspace's lists is refused with a `400`. > One form maps to one list. If you want signups from a particular page to go to a different > list, create a separate form for that page. You can change the list on the form's detail > page at any time. > ## Choosing fields The **Email** field is always present and cannot be removed — it is the core identifier for every contact in SendBeam. Under **Form Fields** on the form's detail page, tick any additional fields you want to collect: - **First name** — useful for personalising campaigns with merge tags like `{"{{first_name}}"}`. - **Last name** — helpful when you need the full name on record. - **Custom fields** — enter extra field keys in the **Custom fields** box, comma-separated (for example `company, plan, region`). Each becomes an input in the embed code and is stored in the contact's custom fields, ready for merge tags and segments. Fields not declared on the form are ignored. Through the API, the same list is the form's `fields` array. Only the email address is required. Placeholders in the embed code are plain text you can edit to match your brand voice. > **Fewer fields, more signups.** Each additional field reduces conversion rate. > Start with email only and add fields only when you have an immediate, specific use for that > data. You can always collect more information later. > ## Double opt-in and saving Double opt-in behaviour is **inherited from the target list**, not set on the form itself. If the list you selected has double opt-in enabled, every submission through this form will trigger a confirmation email automatically. There is nothing to configure on the form. To check the double opt-in setting, go to **Lists** — the table shows which lists require it. On the Free plan every list behaves as double opt-in. See [Double Opt-In](https://sendbeam.io/docs/lists/double-optin) for the full workflow. A confirmation email is sent at most once per address every 10 minutes, however many times the form is submitted, and each one counts towards your monthly email allowance. > If your form uses double opt-in, update the success message to tell visitors to check their > inbox — for example, "Almost there! Check your email to confirm your subscription." Visitors > who do not see this prompt will not know they need to take another step. > When you are happy with the form's settings, save them on the detail page. The embed code underneath reflects the current fields and protection settings; copy it whenever they change. A form's **Status** can be set to *Inactive* to stop accepting submissions without deleting it. --- # Forms: Embedding Forms Get and use the HTML embed code on your website. ## Get the embed code 1. Open the **Forms** page and click on the form you want to embed. 2. Scroll to the **Embed Code** section on the form detail page. 3. Click the copy icon to copy the snippet to your clipboard. The embed code is a self-contained HTML snippet: the form markup with inline styles, a hidden check field, and a small JavaScript block that posts the submission as JSON to `https://sendbeam.io/api/forms/` and shows the response. It loads no external script (unless Turnstile is enabled), so it does not slow down your page. > **What updates automatically and what does not.** The thank-you message and > redirect URL are returned by SendBeam on every submission, so changing them takes effect > immediately. The fields, the button label and the Turnstile widget are baked into the snippet > — copy it again after changing those. The snippet also reports that the form was shown, which is > what the [conversion rate](https://sendbeam.io/docs/forms#views-and-conversion-rate) is measured against. > ## Paste into your site Paste the snippet wherever you want the form to appear. The form fills the width of its parent container, so wrapping it in a `` with a max-width gives you control over how wide it renders. For the best results, place the form in a location that gets attention: - Above the fold on a dedicated landing page - In the sidebar of a blog or article layout - Within the body of a post, after the first few paragraphs - In the site footer as a persistent subscribe prompt ## Hosted page Every active form also has its own page at `https://sendbeam.io/f/` — the link and an **Open** button sit at the top of the form's page in the app. Use it wherever there is no site to paste a snippet into: a link in a social bio, a QR code on print, a post, or a client's site you cannot edit. The page shows the same fields, double opt-in and thank-you message as the embed and carries your workspace name. Turning the form off (Status → Inactive) takes the page down with it. It can stand as a page of its own: under **Form settings** give it a **headline**, a **line of copy**, a **button label** and an **image**, and the page shows them above the form and uses them as its share preview when the link is posted. The page is not indexed by search engines unless you tick **Let search engines find the hosted page** on the form; embeds and pop-ups are never indexed either way. ## Pop-up Signup forms and contact forms can both open in a modal over any page. Copy the **Pop-up** snippet from the form's page and put it anywhere in your HTML: ``` ``` The form renders inside an iframe of the hosted page, so your site's CSS and ours never interfere. Options are attributes on the script tag: - `data-delay` — milliseconds before the modal opens (default 5000; `0` opens at once). - `data-once` — how long to stay away after a close or a submission: `day` (default), `week`, `forever`, or `never` to show on every visit. Remembered in the visitor's browser. - `data-trigger="button"` — no timer; show a floating button in the corner instead (`data-label` sets its text). - `data-trigger="scroll"` / `"exit"` — open at a scroll depth (`data-scroll`, default 60%) or on exit intent. - `data-trigger="manual"` — no timer and no button; only elements with `data-sendbeam-open` open it. - Any element with `data-sendbeam-open=""` opens the modal on click, so your own "Subscribe" or "Contact" link can trigger it. ### Styles A pop-up arrives over whatever somebody was reading, so it has to look like it was made rather than generated. There are four looks; `split` is the default, and every one of them uses the colours, radius and type you already pass (see [Customising](https://sendbeam.io/docs/forms/customising)). On a phone every style becomes a bottom sheet — full width, rounded at the top, the field and button stacked. - `data-style` — `split` (default), `editorial`, `bold` or `slide`. - `data-image` — an `https` picture: the side panel on `split`, the thumbnail on `slide`. Without one, `split` uses your accent colour. - `data-eyebrow` — the small line above the headline, up to 40 characters (a pill on `bold`). - `data-button` — the submit button's label, up to 30 characters. - `data-proof` — `1` adds "Joined by N readers", counted from the list the form adds to. It is hidden until there are enough subscribers for it to help. The headline and the line under it are `data-heading` and `data-sub`, as before. One example of each: ``` ``` Every pop-up carries a small "Powered by SendBeam" line. An address that is not `https`, a style nobody has heard of or copy past its length is ignored rather than rendered, so a typo costs you the option and not the pop-up. ### Contact forms A contact form's snippet is the same script with different defaults: `data-trigger="button"` and `data-label="Contact us"`, so visitors get a floating button that opens the form when they want it, and nothing is remembered between visits. A contact form is never a timed interruption; set `data-delay` yourself if you really want one. Messages arrive at your *Send messages to* address exactly as from the inline form, with the visitor as Reply-To, and the same origin list, rate limits and bot check apply. > If the form restricts submissions to certain origins, the pop-up only opens on those sites (and on the hosted page itself): the frame is refused elsewhere, which is the same rule the inline embed follows. The plain hosted page is meant to be shared anywhere and is not restricted. > ## Platform tips The process for pasting HTML differs slightly across website builders. Here are quick notes for the most common platforms: - **Raw HTML / static sites** — paste directly into the HTML file where you want the form to appear. - **WordPress** — in the Gutenberg editor, add a **Custom HTML** block and paste the snippet. For sidebars and footers, use **Appearance > Widgets** and add a **Custom HTML** widget. - **Webflow** — drag an **Embed** element onto the canvas, double-click it to open the code editor, and paste the snippet. Publish your site for the form to appear (it will not execute inside the Webflow Designer preview). - **Squarespace** — add a **Code Block** to the page, paste the snippet, and make sure "Display Source" is unchecked. Requires a Business plan or higher. - **Wix** — use **Add > Embed Code > Custom Embeds > Embed a Widget** and paste the snippet into the code panel. - **React / Vue / Next.js / Nuxt** — build a native form component that posts JSON to the form's endpoint (shown under **API Endpoint** on the form page) with `email` and any other fields, plus the hidden check field named on the form's Embed tab. The response contains `success`, `message` and `redirect_url`. > **Content Security Policy (CSP) headers** can block the snippet's inline > script or its request. If the form does not submit, allow inline scripts for it (or move the > script into your bundle) and add `https://sendbeam.io` to `connect-src`. > ## Contact forms and Turnstile A contact form's embed code has name, email, subject and message fields and a **Send message** button instead of a subscribe button. Everything else works the same way: it posts JSON to the form's endpoint and shows the thank-you message on success. If the form has **Turnstile** enabled (a site key and secret key in its Protection section), the embed code also includes Cloudflare's widget script and a ``. The widget is invisible to most visitors and adds a hidden `cf-turnstile-response` field; the snippet sends that value as `turnstile_token`. SendBeam rejects any submission whose token is missing or invalid. > **Content Security Policy.** If your site sends a CSP header, allow > `https://challenges.cloudflare.com` in `script-src`, > `frame-src` and `connect-src`, or the widget will not load and every > submission will be refused. > If you build your own form instead of using the snippet, post the same fields to the endpoint and include the Turnstile widget yourself. When the form lists **allowed sites**, the request must come from a browser on one of those origins. ## Test after embedding Always run a quick end-to-end test after pasting the embed code: 1. Load the page on your live website (not a local preview). 2. Verify the form renders and all fields are visible. 3. Submit the form with a real email address you control. 4. Confirm the success message or redirect appears as expected. 5. Open **Contacts** in SendBeam to verify the new contact was created (and, for a signup form with a list, that they appear on the list). 6. If using double opt-in, check that the confirmation email arrives and the confirmation link works. 7. For a contact form, check the notification arrives at your *Send messages to* address and that pressing reply addresses the visitor. 8. With allowed sites set, a submission from any other origin should be refused with a 403; with Turnstile, one without a token should be refused too. > Re-run this test after any major website update, theme change, or platform migration. > Framework hydration, caching layers, or updated CSP rules can silently break an > embedded form between deploys. > --- # Forms: Customising Forms Style your forms and configure success messages. ## Styling the form The embed snippet is ordinary HTML with inline styles, and it is yours to edit. SendBeam does not host a stylesheet or inject any markup, so whatever you paste into your page is exactly what visitors see. Two approaches work well: - **Edit the inline styles** — each input and the button carry a `style` attribute. Change the colours, border radius, padding and font to match your site. - **Strip the inline styles and use your own CSS** — remove the `style` attributes and target the form by its id (`sendbeam-form-`) from your stylesheet. This keeps the form in step with your design system. Whichever you choose, keep these parts intact so the form keeps working: - The form's `id` and the `name` attributes on the inputs (`email`, `first_name`, `last_name`, and for contact forms `name`, `subject`, `message`). - The hidden input in the off-screen wrapper, and the wrapper itself. Its name is unique to your form and it must stay hidden and empty. - The message element (`sendbeam-msg-`) where the response is shown. - The script block, which posts the submission and handles the response. - The Turnstile `div` and script, if the form has Turnstile enabled. > Use your browser's developer tools or your design system documentation to find the exact > hex values your website already uses. Matching them makes the form feel like a native > element rather than a third-party widget. > The four [pop-up styles](https://sendbeam.io/docs/forms/embedding#pop-up-styles) take the same colours. A pop-up is a card rather than a bare form, so it lays itself out — accent, text, muted, field, border, radius, font and size are read exactly as they are here, and the style decides the rest. Set your colours once and the embedded form, the hosted page and all four pop-ups agree. ## Button text A form can also carry its own **headline**, **line of copy** and **button label**, set on its page under Form settings. They show on the hosted page and in the pop-up wherever the embed or plugin did not pass its own; a heading passed in the URL still wins. The submit button reads "Subscribe" (or "Send message" for a contact form). Edit the button's text in the snippet to something more specific and action-oriented — it is the last thing a visitor reads before deciding to sign up. Effective patterns: - Benefit-focused: "Get weekly tips", "Send me the guide", "Start learning" - First-person: "Sign me up", "Get my discount", "Join the list" - Context-specific: "Download now" for a lead magnet, "Get early access" for a waitlist Avoid generic labels like "Submit" or "Click here" — they do not tell the visitor what they are getting and perform noticeably worse than action-oriented alternatives. ## Success message and redirect After a successful submission, the snippet shows your thank-you message under the form or sends the visitor to a URL. Both are set on the form's page in SendBeam and returned with each submission, so you can change them without re-copying the snippet: - **Thank You Message** — shown in the message element. Good for simple confirmations. Customise it to set expectations, for example: "You're on the list! Check your inbox for a welcome email." - **Redirect URL** — the visitor is sent to this page after submitting. Useful for lead-magnet funnels where you want to deliver a download link, show a thank-you page, or present an upsell offer. When a redirect is set, it takes precedence over the message. > If the target list uses **double opt-in**, your thank-you message must tell > visitors to check their email and click the confirmation link. A generic "Thanks for > subscribing!" will leave visitors confused about why they haven't received anything yet. > ## Fields and placeholders Which inputs appear is decided by the **Form Fields** section on the form's page (email is always included; first name, last name and any custom fields are optional). The snippet lists them in a fixed order — email, first name, last name, then custom fields — but you can reorder the inputs in your HTML freely; only their `name` attributes matter. Each input uses a placeholder rather than a label. Edit the placeholder text to anything you like — for example, "Your email address" or "What should we call you?" — or add `` elements above the inputs for accessibility. > To capture something beyond names — a company, a plan, a region — add the key under > **Custom fields** on the form's page. The regenerated snippet includes an input > for it, and the value lands in the contact's custom fields, ready for merge tags and > segments. If you keep an older copy of the snippet, add an input with that `name` > yourself. > --- # Integrations: Integrations Connect SendBeam to the tools you already use: the official n8n node and WordPress plugin, and ready-made examples for Zapier, Make and the frameworks and hosts your sites run on. Everything here talks to the same two things: the [REST API](https://sendbeam.io/docs/api), with an API key from your workspace, and your [public forms](https://sendbeam.io/docs/forms). The official integrations wrap them so you choose lists, tags and forms by name instead of copying IDs. ## Official integrations Built and maintained by SendBeam, with their own documentation. | Integration | What it does | | --- | --- | | [**n8n**](https://sendbeam.io/docs/n8n) | A community node with 25 actions for your contacts, lists, tags, campaigns, automations and email, and a trigger for every SendBeam event. | | [**WordPress**](https://sendbeam.io/docs/wordpress) | A plugin that puts your forms and pop-ups on the site, collects opt-ins at registration, comments and checkout, and sends the site's own email. | | [**E-commerce**](https://sendbeam.io/docs/ecommerce) | Cart Abandoned, Product Viewed and Order Placed as native automation triggers — WooCommerce through the plugin, Shopify via a webhook you configure directly, no app required. | ## Setup guides One guide per integration: what you need, the steps, and the code. To browse or search what SendBeam connects to, use the [integrations directory](https://sendbeam.io/integrations) — it has the whole list with a search box, and each entry links back here. ### Automation - [Kestra](https://sendbeam.io/docs/integrations/kestra) — A declarative Kestra flow that adds a contact and sends site email through SendBeam, plus a webhook trigger that starts a flow from SendBeam's events. - [Make](https://sendbeam.io/docs/integrations/make) — Use Make's HTTP module to add contacts and send email through the SendBeam REST API. - [n8n](https://sendbeam.io/docs/integrations/n8n) — The official SendBeam node for n8n: 25 actions for contacts, lists, tags, campaigns, automations and email, and a trigger for every SendBeam event. - [Trigger.dev](https://sendbeam.io/docs/integrations/trigger-dev) — A typed Trigger.dev task that adds a contact and sends site email through SendBeam, with retries and a webhook coming back the other way. - [Windmill](https://sendbeam.io/docs/integrations/windmill) — Windmill scripts in TypeScript and Python that add contacts and send site email through SendBeam, ready for the Hub or your own workspace. - [Zapier](https://sendbeam.io/docs/integrations/zapier) — The SendBeam app for Zapier: an instant trigger for every SendBeam event, plus 17 actions and 5 searches for contacts, lists, tags, email, campaigns and automations. ### Apps you already use - [Airtable](https://sendbeam.io/docs/integrations/airtable) — Keep SendBeam in step with an Airtable base: each new record with an email becomes a contact. - [Calendly](https://sendbeam.io/docs/integrations/calendly) — Add people who book a Calendly meeting to SendBeam, so follow-ups go out after the call. - [Eventbrite](https://sendbeam.io/docs/integrations/eventbrite) — Add Eventbrite attendees to SendBeam as contacts, tagged by event, for reminders and follow-ups. - [Google Sheets](https://sendbeam.io/docs/integrations/google-sheets) — Add each new row of a Google Sheet to SendBeam as a contact — handy for lists people still keep in a spreadsheet. - [Gumroad](https://sendbeam.io/docs/integrations/gumroad) — Add Gumroad buyers to SendBeam as contacts, so each product can have its own list. - [HubSpot](https://sendbeam.io/docs/integrations/hubspot) — Add new HubSpot contacts to SendBeam, so your CRM and your newsletter share one list of people. - [Jotform](https://sendbeam.io/docs/integrations/jotform) — Add Jotform submissions to SendBeam as contacts and put them on a list, through n8n, Make or Zapier. - [Lemon Squeezy](https://sendbeam.io/docs/integrations/lemon-squeezy) — Add Lemon Squeezy customers to SendBeam from each new order, using Lemon Squeezy's webhooks or Zapier. - [Microsoft Teams](https://sendbeam.io/docs/integrations/microsoft-teams) — Send SendBeam's operational alerts straight into a Microsoft Teams channel — no automation tool in between. - [Notion](https://sendbeam.io/docs/integrations/notion) — Add people from a Notion database to SendBeam: a new page with an email becomes a contact. - [Pipedrive](https://sendbeam.io/docs/integrations/pipedrive) — Bring the people in your Pipedrive into SendBeam, and send what they opened and clicked back to the person record. - [Shopify](https://sendbeam.io/docs/integrations/shopify) — Add Shopify customers to SendBeam as contacts, and tag the ones who agreed to marketing. - [Slack](https://sendbeam.io/docs/integrations/slack) — Send SendBeam's operational alerts straight into a Slack channel — no automation tool in between. - [Stripe](https://sendbeam.io/docs/integrations/stripe) — Add new Stripe customers to SendBeam as contacts, ready for onboarding emails and receipts. - [Supabase](https://sendbeam.io/docs/integrations/supabase) — Send email straight from your Postgres: a row lands in orders, the customer gets their receipt. - [Tally](https://sendbeam.io/docs/integrations/tally) — Send Tally form submissions to SendBeam as contacts, using Tally's own webhooks or an automation tool. - [Typeform](https://sendbeam.io/docs/integrations/typeform) — Turn Typeform responses into SendBeam contacts, on the list and with the tags you choose. - [Webflow](https://sendbeam.io/docs/integrations/webflow) — Send Webflow form submissions to SendBeam as contacts, or embed a SendBeam form in Webflow directly. ### CMS - [WordPress](https://sendbeam.io/docs/integrations/wordpress) — The official plugin: forms and pop-ups on the site, opt-ins at registration, comments and checkout, and the site's own email from your verified domain. ### Framework - [Astro](https://sendbeam.io/docs/integrations/astro) — A newsletter signup and a contact form as Astro components, posting straight to SendBeam with no server code. - [Eleventy](https://sendbeam.io/docs/integrations/eleventy) — Nunjucks includes for a signup and a contact form, with form IDs read from the environment at build time. - [Hugo](https://sendbeam.io/docs/integrations/hugo) — Partials for a signup and a contact form on a Hugo site, with the form IDs in hugo.toml. - [Next.js](https://sendbeam.io/docs/integrations/nextjs) — Client components for newsletter signup and a contact form that post to SendBeam, with no API route to maintain. ### Hosting - [Cloudflare Pages](https://sendbeam.io/docs/integrations/cloudflare-pages) — A contact form and newsletter signup on Cloudflare Pages without a Pages Function, plus the _headers file for Turnstile. - [Netlify](https://sendbeam.io/docs/integrations/netlify) — Forms on a Netlify site that keep working when you move hosts, with the netlify.toml headers for Turnstile. - [Vercel](https://sendbeam.io/docs/integrations/vercel) — A Next.js or static site on Vercel with SendBeam forms and a vercel.json headers block. ## Missing an integration? Tell us which tool you want SendBeam to work with on [Request an integration](https://sendbeam.io/integrations/request). If you have built an integration with SendBeam, [list your integration](https://sendbeam.io/integrations/submit) and we will add a page for it. --- # Integrations: Code workflows: Trigger.dev, Windmill and Kestra Copy-pasteable, typed blueprints for running SendBeam from code-first workflow platforms: a Trigger.dev task, Windmill scripts in TypeScript and Python, and a Kestra YAML flow. If you would rather write a background job than map nodes in a visual tool, SendBeam is a JSON API behind one header, and these three blueprints are the whole integration: a typed task, a script, a flow. Each adds a contact, optionally puts them on a list, sends a site email, and can be started by SendBeam's own webhooks. ## The three blueprints - [Trigger.dev](https://sendbeam.io/docs/integrations/trigger-dev) — a v3 `task()` in TypeScript with a typed payload and retries, plus a Next.js route that verifies SendBeam's signature and triggers a task for each event. - [Windmill](https://sendbeam.io/docs/integrations/windmill) — the same job as a TypeScript script and as a Python script, with the key held as a Windmill resource, and signature verification for Windmill's script webhooks. - [Kestra](https://sendbeam.io/docs/integrations/kestra) — a declarative YAML flow of HTTP tasks with the key as a Kestra secret, and a webhook-triggered flow that routes on the event type. ## What every blueprint does the same way - **The key lives in the platform's secret store**, one per environment, never in source. A staging run must not write to your production list. - **409 is success.** `POST /api/v1/contacts` and adding to a list answer 409 when the person is already there, so a retried run never fails on its own earlier work. - **Retries are the platform's job.** A 5xx or a 429 from SendBeam is left to the task runner's retry policy rather than caught and swallowed. - **Inbound events are verified.** `X-SendBeam-Signature` is `t=,v1=`; check it against the raw bytes, refuse anything older than five minutes, and use `X-SendBeam-Delivery` as the idempotency key. - **Marketing consent is yours to hold.** Add someone to a marketing list only if they agreed; site email through `/api/v1/transactional` needs no consent. ## Which calls to use | To | Call | Permission | | --- | --- | --- | | Add or update a person | `POST /api/v1/contacts` | `contacts:write` | | Put them on a list | `POST /api/v1/lists/{id}/contacts` | `lists:write` | | Send a receipt, a reset, a reminder | `POST /api/v1/transactional` | `transactional:send` | | Report an order, a cart or a refund | `POST /api/v1/ecommerce/events` | `ecommerce:write` | | Be told when something happens | Settings → Webhooks | — | The full reference, with every field and response, is under [API reference](https://sendbeam.io/docs/api); the event payloads and the signature are under [Webhooks](https://sendbeam.io/docs/api/webhooks). --- # Integrations: AI agents: the SendBeam MCP server Connect Claude Code, Cursor or any other Model Context Protocol client to a SendBeam workspace. A workspace API key becomes the tools its permissions allow — every documented API operation, run with the same checks. If you work in an AI coding assistant or an agent, SendBeam can be one of its tools. The MCP server turns the [HTTP API](https://sendbeam.io/docs/api) into tools the agent can call by name — list contacts, create a tag, draft a campaign, read a report — with the workspace and permissions of the API key you connect it with. ## What it is A [Model Context Protocol](https://modelcontextprotocol.io) server over Streamable HTTP at `https://sendbeam.io/api/v1/mcp`. There is nothing to install and nothing to run: point a client at the URL with a workspace API key as the bearer token. Every request stands on its own, so there is no session to keep alive. The server is switched on per workspace while it settles. If your workspace does not have it yet, the URL answers `404`; ask us at [contact](https://sendbeam.io/contact). ## The key decides what the agent can do Create a key under **Settings → API keys** and choose its permissions with the agent in mind. The agent is only offered the tools those permissions allow, and every call runs through the same checks as a direct API request: a key without `campaigns:send` cannot send, a key without `contacts:write` cannot change a contact, and no key can see another workspace. A good starting point is read permissions plus `campaigns:write` and `templates:write`, so the agent can draft but not send. Add `campaigns:send` only when you want it to. Tools that send email, activate an automation, delete, import or suppress are marked as needing confirmation. Well-behaved clients ask you before calling them; the server also tells the agent to. ## Claude.ai and Claude Desktop These sign in rather than take a key. Add a custom connector with the URL `https://sendbeam.io/api/v1/mcp`; Claude opens SendBeam, where you sign in, choose the workspace and tick what the connection may do. That creates a key under **Settings → API keys** named after the app: the connection is that key, and deleting it disconnects the app. Connecting again replaces it. ## Claude Code One command, with your key: ``` claude mcp add --transport http sendbeam https://sendbeam.io/api/v1/mcp \ --header "Authorization: Bearer sb_live_XXXXXXXX_YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY" ``` Then, in a session, ask for what you want: “How many contacts joined the newsletter list this week?” or “Draft a campaign to the Customers segment announcing the autumn range; don't send it.” Run `/mcp` to see the connection and the tools it offers. ## Cursor, VS Code and others Any client that supports remote MCP servers over HTTP takes the same URL and header. In Cursor, add to `.cursor/mcp.json`: ``` { "mcpServers": { "sendbeam": { "url": "https://sendbeam.io/api/v1/mcp", "headers": { "Authorization": "Bearer sb_live_XXXXXXXX_YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY" } } } } ``` VS Code (`.vscode/mcp.json`) uses the same shape with `"type": "http"`. Keep the key out of source control: most clients can read it from an environment variable or their own secret store. ## Clients that only speak stdio A client that launches servers as local processes can reach the same endpoint through the standard bridge: ``` npx -y mcp-remote https://sendbeam.io/api/v1/mcp \ --header "Authorization:Bearer ${SENDBEAM_API_KEY}" ``` Put that as the server command in the client's configuration, with `SENDBEAM_API_KEY` in its environment. ## What the agent sees One tool per operation in the [API reference](https://sendbeam.io/docs/api), named after the operation — `listContacts`, `createTag`, `getCampaignStats`, `sendCampaign` — with the operation's path, query and body fields as one set of arguments and the same descriptions. Because the tools are generated from the same document as the reference, they can never drift from it; anything new in the API is a tool the next time the agent lists them. Operations that take a file (CSV import, image upload) and the account-level and sign-in routes are not offered. Results come back as the API's JSON. Lists are paged: the agent passes `page` and `limit` exactly as a script would, and a very long result is cut with a note to ask for a smaller page. ## Limits and good habits - **Writes count the same.** A tool that writes spends the key's hourly write allowance for the plan, like any API call. Reads are unmetered. - **One workspace per key.** To let an agent work across several sites, connect one server per workspace, each with its own key, under different names. - **Revoke to disconnect.** Deleting the key under Settings → API keys ends the agent's access at once. - **Prefer drafts.** Let the agent create campaigns and automations as drafts and review them in SendBeam before sending or activating. --- # n8n: n8n Use SendBeam from n8n: keep contacts in step with the rest of your stack, move people on and off lists, tag them, send email, and start workflows from what happens in your workspace. The SendBeam node for [n8n](https://n8n.io) connects a workflow to one SendBeam workspace. It comes as two nodes: **SendBeam**, with the actions below, and the **SendBeam Trigger**, which starts a workflow when something happens in the workspace — see [SendBeam Trigger](https://sendbeam.io/docs/n8n/trigger). ## What the node does | Resource | Actions | | --- | --- | | **Automation** | Start for contact, Get many | | **Campaign** | Create, Get, Get many, Get report, Send (now or scheduled), Duplicate (optionally to non-openers), Delete | | **Contact** | Create or update, Get, Get many, Update, Unsubscribe, Delete, Add to list, Remove from list, Add tag, Remove tag | | **Email** | Send to contact, Send transactional | | **List** | Create, Get many | | **Tag** | Create, Get many | - Contacts are picked **by email** by default — the address the workflow already has — or from a searchable list, or by ID. - Lists, tags, segments and automations are chosen by name. Adding a tag that does not exist yet creates it. - Adding a tag or a list membership a contact already has succeeds, so a workflow can be re-run safely. - **Start for contact** works on active automations that have an API trigger, for contacts who are subscribed and not already in that automation. - **Send to contact** only mails subscribed contacts. **Send transactional** is for mail a person asked for, such as receipts and password resets, and goes to any address except ones that bounced or marked mail as spam. ## Install it 1. In n8n, go to **Settings → Community nodes** and choose **Install**. Only the instance owner and admins can install community nodes. 2. Enter `n8n-nodes-sendbeam`, confirm, and wait for n8n to finish installing it. 3. Open the nodes panel and search for **SendBeam**. The node is tested with n8n 2.38, which needs Node.js 24 or later if you run n8n yourself. ## Connect it 1. In SendBeam, go to [Settings → API keys](https://sendbeam.io/settings/api-keys) and create a key for this n8n instance, with the permissions below. Giving each n8n instance its own key means you can replace one without disturbing anything else. 2. In n8n, add a **SendBeam API** credential and paste the key. Saving it runs a connection test that reads your contacts. 3. Use that credential on every SendBeam node in the workflow. ## Which permissions each action needs Give the key only what the workflow uses. | Permission | Needed for | | --- | --- | | `contacts:read` | The credential test, and finding contacts by email | | `contacts:write` | Creating, updating, unsubscribing and deleting contacts | | `lists:read / lists:write` | Choosing a list; adding and removing members; creating lists | | `tags:read / tags:write` | Choosing a tag; adding and removing tags; creating tags | | `campaigns:read / campaigns:write` | Finding and reporting on campaigns; creating, sending, duplicating and deleting them; sending email to a contact | | `automations:read / automations:write` | Choosing an automation; starting one for a contact | | `segments:read` | Choosing a segment as a campaign audience | | `transactional:send` | Sending transactional email to any address | | `webhooks:read / webhooks:write` | The SendBeam Trigger, which sets up and removes its own endpoint | ## Example workflows - **Add new customers to a list.** A Stripe or Shopify trigger → *Contact → Create or update* with the email and name mapped from the order → *Contact → Add to list*, picking the list by name. Re-running it for an existing customer changes nothing. - **Tag people by what they did.** A Typeform or Tally trigger → *Contact → Add tag*, with the contact picked by the email from the form and the tag typed as a name, such as `webinar-2026`. - **Start an onboarding sequence from your app.** A Webhook node your app calls on signup → *Contact → Create or update* → *Automation → Start for contact*. - **Tell your team about unsubscribes.** The SendBeam Trigger with *Contact Unsubscribed* → a Slack node posting `{{ $json.data.contact.email }}`. - **Send a receipt.** An order webhook → *Email → Send transactional*, with the customer's email in **To**. ## Source and releases The node is open source under the MIT licence. The code, issues and release notes are on [GitHub](https://github.com/sendbeam-io/n8n-nodes-sendbeam), and each release is published to [npm](https://www.npmjs.com/package/n8n-nodes-sendbeam) by the repository's own release workflow. --- # n8n: SendBeam Trigger Start an n8n workflow when a contact is created or unsubscribes, an email is opened or clicked, a form is submitted, a campaign is sent or a sending domain is verified. The **SendBeam Trigger** node starts a workflow when something happens in your SendBeam workspace. It uses SendBeam's [webhooks](https://sendbeam.io/docs/api/webhooks), and sets them up for you. ## How it works 1. Add the SendBeam Trigger to a workflow, choose its credential and the events that should start it. 2. **Publish** the workflow. The node registers an endpoint in SendBeam, which you will see under [Settings → Webhooks](https://sendbeam.io/settings/webhooks) named after the workflow. 3. From then on each chosen event starts the workflow, usually within a minute of it happening. Unpublishing the workflow removes the endpoint again. The credential's key needs `webhooks:read` and `webhooks:write`. To change which events start the workflow, edit the node and publish again. ## Events In n8n the events have readable names, such as *Contact Created* for `contact.created`. | Event | Sent when | | --- | --- | | `contact.created` | A new contact was added, by any route (form, import, API, dashboard). | | `contact.updated` | A contact’s fields, tags membership aside, changed. | | `contact.unsubscribed` | A contact unsubscribed, or was set to unsubscribed. | | `contact.resubscribed` | A previously unsubscribed contact subscribed again with fresh consent. | | `contact.bounced` | A contact’s address hard-bounced and is now suppressed. | | `contact.complained` | A contact marked a message as spam and is now suppressed. | | `contact.deleted` | A contact was deleted (an erasure or manual delete). | | `contact.tag_added` | A tag was added to a contact. | | `contact.tag_removed` | A tag was removed from a contact. | | `contact.list_joined` | A contact joined a list (immediately, or on double opt-in confirmation). | | `contact.list_left` | A contact left or was removed from a list. | | `email.sent` | An email was accepted by SendBeam’s managed delivery for sending. | | `email.delivered` | An email was delivered to the recipient’s mail server. | | `email.opened` | A recipient opened an email (first open only). | | `email.clicked` | A recipient clicked a link in an email (first click only). | | `email.bounced` | An email bounced. | | `email.complained` | A recipient marked an email as spam. | | `campaign.sent` | A campaign finished sending to its whole audience. | | `form.submitted` | A public form (signup or contact) was submitted and accepted. | | `domain.verified` | A sending domain finished DNS verification successfully. | | `domain.failed` | A sending domain’s verification failed or lapsed. | | `workspace.paused` | Sending stopped for this workspace — automatically because delivery results deteriorated, or because we paused it. Nothing goes out until it resumes; everything else keeps working. | | `workspace.resumed` | Sending started again for this workspace. | | `workspace.health_warning` | Delivery results for this workspace are deteriorating. Sending continues, but this is the warning before a pause. | | `automation.failed` | An automation could not complete a step — a deleted template, a tag that no longer exists, a send that failed. The automation stays active; the run that hit it stopped. At most one per automation per day. | | `automation.step_reached` | A contact reached a "Call a webhook" step in one of your automations. Unlike every other event here, this one is sent because an automation asked for it — add the step to an automation and choose which of your endpoints should hear about it. Carries the contact, the automation and the step's own label. | ## What a workflow receives Each event is passed on whole, so a workflow can branch on `event` and read the rest from `data`. Contact events carry the person under `data.contact`; events about a tag or a list add `data.tag` or `data.list`. Email events carry the address, subject and send at the top of `data`. ``` { "id": "b1a4c0de-5f6a-4b7c-8d9e-0f1a2b3c4d5e", "event": "contact.created", "created_at": "2026-09-11T08:12:16.602Z", "data": { "contact": { "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "email": "jane@example.com", "status": "subscribed", "first_name": "Jane", "last_name": "Doe", "source": "form", "custom_fields": {}, "created_at": "2026-09-11T08:12:16.410Z", "subscribed_at": "2026-09-11T08:12:16.410Z", "unsubscribed_at": null, "tags": [] } } } ``` Expressions for the fields workflows use most: ``` Contact events {{ $json.data.contact.email }} {{ $json.data.contact.first_name }} Tag events {{ $json.data.tag.name }} List events {{ $json.data.list.name }} Email events {{ $json.data.email }} {{ $json.data.subject }} Any event {{ $json.event }} {{ $json.created_at }} ``` Every field of every event is described under [The payload](https://sendbeam.io/docs/api/webhooks#the-payload) in the webhooks reference. ## n8n on your own computer SendBeam sends events to n8n's webhook address over https, and n8n on a laptop defaults to `http://localhost:5678`, which SendBeam cannot reach. The node says so when you publish. 1. Start a tunnel to n8n, for example with `cloudflared tunnel --url http://localhost:5678` or ngrok. 2. Set n8n's `WEBHOOK_URL` to the tunnel's https address and restart n8n. 3. Publish the workflow again. A quick tunnel's address changes each time it starts, so publish again after restarting it. ## Testing a workflow Once the workflow is published, **Send test event** on its endpoint under [Settings → Webhooks](https://sendbeam.io/settings/webhooks) sends one of the events it listens for straight away. A test event has the same shape as a real one, with `test: true` and obviously fake values, so mappings built from it keep working when real events arrive. --- # WordPress: WordPress plugin Put your SendBeam forms on a WordPress site, add a pop-up, collect opt-ins at registration or checkout, and send the site's own email from your verified domain. The SendBeam plugin connects one WordPress site to one SendBeam workspace. Install it, press **Connect SendBeam**, approve what the site may do, and you are connected in two minutes — no API key to create or paste ([Connecting the plugin](https://sendbeam.io/docs/wordpress/connecting)). It then puts your forms on the site, runs pop-ups with their own targeting, collects opt-ins from the things people already do on a WordPress site, and can carry the site's own email. ## What the plugin does - **Forms** — a *SendBeam Form* block and the `[sendbeam_form]` and `[sendbeam_contact]` shortcodes. Any form, on any number of pages. - **Pop-ups** — as many as you like, each with its own form, targeting and trigger. - **Audience** — an opt-in tick box on account registration, comment forms and WooCommerce checkout. - **Form plugins** — people who fill in Contact Form 7, Elementor Pro, WPForms, Gravity Forms and Fluent Forms forms, with their consent, added to SendBeam. See [Form plugins](https://sendbeam.io/docs/wordpress/form-plugins). - **Site email** — everything WordPress sends with `wp_mail()` goes out through your verified sending domain instead of the server's own mailer. Forms render from their hosted page on sendbeam.io, so a change you make in SendBeam appears on the site straight away and your theme's CSS never fights the form's. They still take your site's colours — see [Forms on a page](https://sendbeam.io/docs/wordpress/forms). ## Install it Install **SendBeam** from Plugins → Add New, or upload the release zip from [the repository](https://github.com/sendbeam-io/sendbeam-wordpress). It needs WordPress 6.1 or later and PHP 7.4 or later. Activating it adds **Settings → SendBeam** and nothing else; no tables are created and no files are written. ## Connect it Go to **Settings → SendBeam**, tick what the site should be able to do and press **Connect SendBeam**. A SendBeam window opens, you sign in or create an account, you approve the list, and the plugin gets its own key — see [Connecting the plugin](https://sendbeam.io/docs/wordpress/connecting) for what each permission allows and how to disconnect. To connect by hand instead: 1. In SendBeam, go to [Settings → API keys](https://sendbeam.io/settings/api-keys) and make a key for this site. Give each site its own key so you can revoke one without disturbing the others. 2. In WordPress, go to **Settings → SendBeam** and paste it into **Connect**. 3. The header shows **Connected** when the key works. If it says the key is rejected, it is the wrong key or the wrong workspace; if it says the key cannot read your forms, the key is right and is missing a permission — see below. You can define `SENDBEAM_API_KEY` in `wp-config.php` instead, and the plugin will use that and never write a key to the database. Useful where the database is shared with staging. ## Which permissions each feature needs Only what you use. Placing a form or a pop-up needs no key at all. - **Forms (read)** — lets the settings page and the block list your forms so you pick one by name instead of pasting an ID. Without it you can still type IDs by hand. - **Lists (read)** — the Audience tab and its subscriber counts. - **Contacts (read and write)** and **Lists (write)** — only for the opt-in box on registration, comments or checkout, and for connected form plugins. - **Tags (read and write)** — only when a connected form adds a tag. - **Send site email** (`transactional:send`) — only for Site email. ## What it sends us Worth knowing before you switch things on, and the same list is in the plugin's readme. - **Showing a form** — the visitor's browser loads it from sendbeam.io. The URL carries the appearance you chose, which is your settings, not anything about the visitor. Your server makes no request. - **A submission** — what the visitor typed on that form. - **The settings screens** — while an administrator has them open, your server asks us for your forms, lists and counts, using your key. Cached for five minutes. Nothing happens on front-end page loads. - **An opt-in** — only when someone ticks the box: their address, name and which of the three places they were in. - **A connected form plugin** — only for a form you switched on, and only when the person consented: their address, name, which form plugin it was, and the list and tag you chose. - **Site email** — each message your site sends, so we can deliver it. --- # WordPress: Connecting the plugin One button in wp-admin connects a WordPress site to a SendBeam workspace: you approve what the site may do, and the plugin gets its own API key. You do not have to create an API key by hand. In WordPress, go to **Settings → SendBeam**, tick what you want the site to be able to do and press **Connect SendBeam**. A window opens on sendbeam.io, you sign in (or create an account there and then), you approve the list, and the window closes with the site connected. ## What happens when you press Connect 1. A window opens on **sendbeam.io**. It is a window, not a frame inside your site — so you can see the address bar and the certificate before you type a password. Nothing on your site can read it. 2. If you have no account yet, you get the ordinary signup: your email, a workspace name (your site's name, filled in for you), a password, and a code sent to your inbox to prove the address is yours. 3. You see one line for each thing the site asked for, and a workspace to connect it to if you have more than one. Untick anything you do not want. *Showing your forms* stays on: nothing in the plugin works without it. The sending-domain line has a box with the domain your email will come from — see below. 4. Press **Connect**. SendBeam creates an API key for that workspace, named `WordPress · your site name`, carrying exactly what you approved, and hands it to your site's server. The key never travels through the browser and is never shown on screen. 5. The window closes and the plugin says **Connected**, with a checklist of what is left to do. Pressing **Cancel** creates nothing at all, and the plugin is told you said no. ## Choosing the domain your site sends from The box on the sending-domain line starts with your site's own address, without the `www.` — but it is only a suggestion, and you can type anything you own over it. A subdomain such as `mail.yourdomain.co.uk` works, and so does a different domain altogether if your email should come from your brand rather than from the site's address. Whatever you enter is the domain the records are for, the one the plugin shows you, and the one your site's email is signed by. **If your site is on a hosting company's own domain** — anything ending in `wordpress.com`, `myshopify.com`, `netlify.app`, an address like `203.0.113.10`, and so on — the line is offered switched off, and the box starts empty. Mail cannot be sent from a domain somebody else runs: you cannot add DNS records to it, so it could never verify. Enter a domain you own, or leave the line unticked and skip the step; you can connect again later when you have one. ## What Connect sets up for you A key on its own is not a working setup. Before one email can leave your site you need a sending domain, a list, a form and a from name — four screens, in an order nobody tells you. So approving the connection does the parts it can do, and the plugin shows you what is left: - **Your sending domain.** If you left *Set up this site's sending domain* ticked, the site's own domain is added to the workspace with its DNS records, ready to verify. See below. - **A list and a signup form.** A workspace with no signup form gets a list called **Subscribers**, with double opt-in on, and a form called **Newsletter signup** that fills it. If you already have a form, nothing is created — the plugin offers the one you have. - **A from line.** A workspace with no sender name takes the site's name, and one with no sender address takes `hello@` the domain you chose. Anything you have already chosen is never changed. Sending from that address still waits for the domain to verify. Nothing here overwrites a decision you have made. Connecting a second time replaces that site's key rather than adding another, so you never have to work out which of four keys belongs to which site. ## Verifying your sending domain Email sent from your own domain has to be signed by it, and that means adding a handful of DNS records at whoever holds your domain name. The plugin lists exactly the ones yours needs, each with a **Copy** button, so nothing is typed by hand. - If your registrar supports automatic set-up, the plugin shows **Set up DNS automatically**. It opens your registrar, you sign in there and approve the records, and it brings you back to WordPress — to the same page you pressed the button on, with the check already running. You do not end up on a SendBeam settings page. - Otherwise, add the records at your registrar and press **Check now**. DNS takes a few minutes to spread; SendBeam also re-checks on its own, so the step ticks itself in the end even if you walk away. **Check now** is limited to a dozen presses an hour — if you reach it, the plugin says how long to wait, and nothing is lost by waiting. - Each record has its own tick. A record that has been found says so; one that has not been checked yet says that instead of pretending it is wrong. Two of three records right is progress, and it is worth being able to see which one is left. Until the domain is verified, the plugin will not switch your site's email over to SendBeam, even if you granted that permission. A site whose mail is routed through an unverified domain is a site whose password reset emails stop arriving, and that is not a thing to find out about afterwards. ## What each permission allows - **Show your forms on the site** — the plugin can list and display the forms in that workspace (`forms:read`). Always on. - **Add people who tick an opt-in box at registration, in comments or at checkout** — the plugin can create and update contacts, put them on the list you choose and tag them (`contacts:read`, `contacts:write`, `lists:read`, `lists:write`, `tags:read`, `tags:write`). It cannot download your audience: exporting is a separate permission the plugin never asks for. - **Send the site's own email through SendBeam** — password resets, order emails and notifications go out from your verified sending domain (`transactional:send`). See [Site email](https://sendbeam.io/docs/wordpress/site-email). - **Send WooCommerce order, cart and product events** — the store can report events that drive the e-commerce automation triggers (`ecommerce:read`, `ecommerce:write`). See [E-commerce events](https://sendbeam.io/docs/ecommerce). - **Set up this site's sending domain** — the domain you enter on that line is added to the workspace when you connect, and afterwards the plugin can read whether it has verified and which records are still missing (`domains:read`). Read-only: the plugin can never add, remove or change a domain later, and it cannot see anything else under Settings. Nothing here lets the site send a campaign to your audience: that permission is not part of a connection and has to be chosen deliberately on a key you make yourself. ## One key per site Every connection makes its own key, in one workspace. Connect a second site and it gets a second key, so one site can be cut off without disturbing the other, and the **Last used** column under [Settings → API keys](https://sendbeam.io/settings/api-keys) tells you which sites are still live. Connecting the same site again replaces that site's key rather than adding another. A key belongs to the workspace you chose in the window, and it can never read or write another one. If you picked the wrong workspace, connect again and choose the right one — the old key is revoked for you. ## Reconnecting Connecting again is how you change your mind: tick a permission you left off, move the site to another workspace, or send from a different domain. The plugin offers **Reconnect** on the connected card, and **Reconnect with more permissions** on any step that is waiting for one you did not grant. A reconnection *replaces*: the site's previous key is revoked as the new one is made, matched both by its name and by the domain it was connected for, so renaming the site in WordPress or changing the sending domain cannot leave a second live key behind. Nothing else in the workspace is touched. ## Disconnecting Press **Disconnect** on the plugin's own page. The site tells SendBeam to revoke its key, then forgets it, along with the settings the connection itself filled in. Disconnecting revokes this site's key. Your sending domain, list, form and sender stay in the workspace; other sites sending from the same domain are unaffected. Nothing is deleted anywhere, so connecting the site again later picks up exactly what is already there. Deleting the plugin without disconnecting leaves the key alive. If that has already happened — or if the plugin could not reach SendBeam when you pressed the button, which it will tell you — go to [Settings → API keys](https://sendbeam.io/settings/api-keys), find the key named after the site and revoke it there. ## If something goes wrong - **The window says the link is not complete.** The request from your site was missing something, or its return address was not on the same site. This happens on a site whose address is not https. Fix the site's address in WordPress and try again. - **It says you need an admin seat.** Only a workspace admin can create a key. Ask an admin to connect the site, or to give you the admin role under Settings → Team. - **The plugin says the connection expired.** The handover is good for ten minutes and works once. Press Connect again. - **Pop-ups are blocked.** Allow pop-ups for your own site's admin area and press Connect again — the window is where you type your password, so it is never put inside the page. - **It says that is not a domain name.** The sending-domain box wants the domain on its own — `harbourlane.co.uk`, not `https://harbourlane.co.uk/` and not an email address. Nothing was connected; correct it and press Connect again. - **The plugin says the domain was not set up.** It also says why: a domain your hosting company owns cannot be used, and a plan at its limit of sending domains says so. Everything else about the connection still worked. You can still connect the old way: make a key under [Settings → API keys](https://sendbeam.io/settings/api-keys) and paste it into the plugin. See [the plugin overview](https://sendbeam.io/docs/wordpress). --- # WordPress: Forms on a page Place any SendBeam form with the block or a shortcode, and give it your site's colours so it stops looking like something pasted in. ## The block Search for **SendBeam** in the block inserter and add *SendBeam Form*. Pick the form from the dropdown — it lists the forms in the connected workspace, so nobody has to know an ID. If the site's key cannot read forms, the block falls back to a plain ID field and still works. Editors need no key of their own: the block asks WordPress for the list, and WordPress asks us. The key stays on the server. ## Shortcodes - `[sendbeam_form]` — the default signup form. - `[sendbeam_form id="8f3c1a2e-…"]` — any specific form, as often as you like. - `[sendbeam_contact]` — the contact form chosen in settings. - `[sendbeam_popup_button label="Subscribe"]` — a button that opens a pop-up on a page where it does not appear on its own. `height` works on all of them, though you rarely need it — see below. Shortcodes work anywhere shortcodes do: a Shortcode block, a widget, a classic editor, a page builder. ## Defaults **Settings → SendBeam → Forms** holds the default signup form and the contact form. The block and the shortcodes fall back to these, so most sites choose a form once and never name one again. The **Your forms** table on the same tab lists every form in the workspace with a ready-made shortcode and a copy button. ## Appearance Forms are served by SendBeam, so by default they carry SendBeam's styling. The **Appearance** panel on the Forms tab hands the form your site's own: button colour, text colour, field background, field border, corner radius, text size and typeface. Leave a field blank to keep our default. Set the typeface to *Match my theme* and the form inherits the font of the page it sits in. The values are validated before they are used — a colour must be a hex value, sizes are clamped, and the typeface is chosen from a list rather than supplied as a font stack. **Hide the form name and subtitle inside the embed** is on by default, because the section of your page around the form almost always has its own heading. The *Powered by SendBeam* line stays. ## Height An embedded form measures itself and tells the page how tall it is, so the plugin sizes the frame to match. You should not need to set a height, and a fixed one only helps if you are deliberately constraining the form. Picking a number by hand leaves either a scrollbar or a band of empty space under the button. ## Reacting to a submission The page fires a `sendbeam:submitted` event on `document` when a form in it is submitted, carrying the form ID. Use it to record an analytics goal or move the visitor on. ``` document.addEventListener('sendbeam:submitted', function (e) { // e.detail.formId window.dataLayer && window.dataLayer.push({ event: 'newsletter_signup' }); }); ``` --- # WordPress: Pop-ups As many pop-ups as you like, each with its own form, targeting, trigger and wording. ## Adding one **Settings → SendBeam → Pop-ups**. Each one has a form, a headline, a line of copy, where it shows, how it opens and how long it stays away once dismissed. Add as many as you want. The headline matters more than it looks. A pop-up arrives over whatever someone was reading with no page around it to explain itself; two fields and a button give them no reason to fill it in. ## Styles Each pop-up has a **Style**, and each style takes the colours you set for the form, so it looks like the rest of your site rather than like something bolted on. On a phone every one of them becomes a sheet that rises from the bottom of the screen. - **Split** — a picture down one side and the words beside it. The default, and without a picture the side panel is your accent colour. - **Editorial** — centred, a serif headline and a short rule, with a *No thanks* link for people who would rather not. - **Bold** — your accent colour is the whole card, type reversed out of it. - **Slide** — a small card in the bottom corner with no dimming behind it, so the page stays readable while it is open. The fields beside it fill the style in: - **Image** — the panel on Split, the thumbnail on Slide. Pick from the media library; it has to be served over `https`. - **Eyebrow** — the small line above the headline, up to 40 characters. On Bold it is drawn as a pill. - **Button label** — up to 30 characters. *Subscribe* when you leave it empty. - **Show subscriber count** — adds "Joined by N readers", counted from the list the form adds to. It stays hidden until the number is big enough to be worth saying. ## Targeting Rules are ordered, and **the first one that matches a page wins**. Nothing else is shown, so two pop-ups can never argue over the same visitor. Put the specific rules above the general ones. - **Every page** - **Home page only** - **Single posts** - **Pages** - **Pages whose address contains…** — any text, matched anywhere in the path, so `/pricing` catches `/pricing/` and `/pricing/plans`. A common pair: a discount offer on single posts by exit intent, and a gentler newsletter prompt everywhere else on scroll. The posts rule goes first, so a post gets the offer rather than both. ## Triggers - **After a delay** — a number of seconds. Zero opens it as soon as the page loads, which is usually a mistake. - **When they scroll far enough** — a percentage of the page. Around 60% means they have read most of it. - **When they move to leave** — exit intent, bound only where there is a real pointer, so it cannot misfire on a phone. - **From a floating button** — a small button fixed to the corner, with your label. - **Only from a button I place** — nothing automatic; see below. ## How often someone sees it **After it is closed** decides how long the pop-up stays away: a day, a week, never again, or show it every visit. A submission counts as a close, so nobody who has just subscribed is asked again. This is remembered in the visitor's own browser, so it is per person and per device, and clearing site data resets it. It applies to every automatic trigger. A button someone deliberately presses always opens the form — they asked for it. ## Opening one from a button Give any element `data-sendbeam-open=""`, or use `[sendbeam_popup_button label="Subscribe"]`. The loader is added to pages carrying such a button even where the pop-up does not appear by itself. --- # WordPress: Collecting opt-ins An opt-in tick box on account registration, comment forms and WooCommerce checkout, adding people to any number of your lists. Most people who would join your list are already doing something else on the site. This adds a tick box to those moments rather than asking them to find a form. ## Where the box appears - **Account registration** — on the WordPress registration form. - **Comment forms** — directly above the *Post Comment* button, where the decision is being made, on any theme. - **WooCommerce checkout** — above the place order button, on both the classic checkout and the block checkout WooCommerce installs by default. The row is disabled with an explanation if WooCommerce is not active. ## Setting it up 1. Go to **Settings → SendBeam → Audience**. 2. Tick the places you want to ask in. 3. Choose the lists people should join. Any number; a list set to double opt-in still sends its own confirmation before anyone receives anything. 4. Write the wording beside the box. Say how often you send and that they can leave. The key needs **Contacts (read and write)** and **Lists (write)**. Without a list chosen, or without a key, the box does not render at all. ## What it will not do Three deliberate limits, because getting these wrong is a legal problem rather than a bug. - **Nobody is subscribed who has not ticked the box.** - **The box is never pre-ticked.** A pre-ticked consent box is not consent under the GDPR, so there is no setting to pre-tick it. - **Existing users are never bulk-imported.** They never agreed to anything, and there is no button that would do it. A failure never breaks what the visitor was actually doing — the account is created, the comment posts, the order goes through, and the subscription is attempted afterwards. ## Where they land Each contact records where it came from — `wordpress-registration`, `wordpress-comment` or `woocommerce-checkout` — in its **source** field. [Segments](https://sendbeam.io/docs/segments/creating) can filter on it, so you can send to the people who came in at checkout without keeping separate lists for them. A returning customer who ticks the box is added to the list rather than failing: an address already on file is found and used. ## When something fails The last twenty attempts are listed under **Recent subscriptions** on the same tab, with the address, where it came from, and the reason if it did not work. A silent failure is worse than a visible one. --- # WordPress: Form plugins Send the people who fill in Contact Form 7, Elementor Pro, WPForms, Gravity Forms and Fluent Forms forms to SendBeam, onto the list and with the tag you choose. If your site already runs its forms on another plugin, you do not need to rebuild them in SendBeam. Connect a form, and each person who submits it and consents becomes a SendBeam contact — on the list you choose, with a tag if you want one. ## Which form plugins Contact Form 7, Elementor Pro, WPForms, Gravity Forms and Fluent Forms. The **Audience** tab of Settings → SendBeam lists which of them are active on your site and where each one is set up. The connection for a form plugin only loads when that plugin is active. ## Setting up each one Every form is off until you switch it on, and each is set up where its own plugin keeps form settings. - **Contact Form 7** — open a form and use its **SendBeam** tab. Tick *Send submissions from this form to SendBeam*, then name the email, name and consent fields as they appear on the Form tab, without the square brackets. A single `your-name` field is split into first and last name. - **Elementor Pro** — in the Form widget, add **SendBeam** under *Actions After Submit*. A SendBeam section appears: enter the field IDs from each field's Advanced tab, pick the list and, if you like, a tag. Exported templates leave your list and tag out. - **WPForms** — in the form builder, open **Settings → SendBeam**, switch it on and pick the email, name and consent fields from the dropdowns. - **Gravity Forms** — open a form's **Settings → SendBeam** and add a feed. Map the email, first name, last name and consent fields, and add conditional logic if only some entries should be sent. - **Fluent Forms** — Fluent Forms has no place for another plugin's settings, so choose the forms on the **Audience** tab of Settings → SendBeam. Field names are the ones in each field's settings; a name field is `names.first_name` and `names.last_name`. ## Consent A connected form sends someone to SendBeam in exactly two cases: - **They ticked the consent field you named** — an acceptance box, a GDPR agreement or a checkbox on the form. This is the default. - **You marked the form as a signup form** — a form people fill in precisely to subscribe, where submitting it is the consent. A contact form, a quote request or an order form is not a signup form: give it a consent field, and people who leave it unticked are never sent. The plugin never adds or pre-ticks a box on your forms. ## What happens after a submission The form finishes exactly as it would without SendBeam: its own email is sent and its entry is saved. The subscription is queued and runs straight afterwards, so a slow connection to SendBeam never slows the form down. - An address already in SendBeam is found and used rather than failing. - The contact's **source** names the form plugin — `contact-form-7`, `elementor-form`, `wpforms`, `gravity-forms` or `fluent-forms` — so [segments](https://sendbeam.io/docs/segments/creating) can filter on it. - A tag you set is created in SendBeam the first time it is used. - A list set to double opt-in still sends its own confirmation email first. - Every attempt, and the reason for any failure, is listed under **Recent subscriptions** on the Audience tab. ## Permissions The site's API key needs **Contacts (read and write)**, **Lists (write)** to add people to a list, and **Tags (read and write)** when a form adds a tag. The list dropdowns need **Lists (read)**. --- # WordPress: Site email Route everything WordPress sends with wp_mail() through your verified sending domain, with no SMTP to configure. A WordPress server usually sends mail as itself, from an address on a domain with no authentication behind it, which is why password resets end up in spam. Switching this on sends them from your verified sending domain over HTTPS instead. There is no SMTP host, port or password to get wrong. ## What it covers Everything that goes through `wp_mail()`, which is nearly everything: password resets, new user notifications, comment moderation, WooCommerce order confirmations, booking and membership plugins, contact form notifications. None of them need a setting of their own. ## Turning it on 1. [Verify a sending domain](https://sendbeam.io/docs/getting-started/sending-domain) in SendBeam if you have not. 2. Give the site's API key the **Send site email** permission (`transactional:send`). 3. **Settings → SendBeam → Site email**, tick *Send this site's email through SendBeam*, and save. ## Who it appears to come from Leave **From name** and **From address** empty and each message keeps whatever the sending plugin set, falling back to the workspace sender. Set them to override both. The address must be on a domain verified in the workspace — that is the whole point of the exercise. ## If SendBeam cannot send **Fall back to the server's own mailer** is on, and should stay on. A refused or failed message then goes out the way it did before the plugin, so a password reset still arrives. Turn it off only if you would rather a message failed loudly than left unauthenticated. ## What is not sent - **Messages with attachments** are left to the server's mailer. - **Addresses that previously bounced or complained** are refused, as everywhere else in SendBeam. Recipients do not need to be contacts, and someone who unsubscribed from your newsletter still gets their receipt — this is transactional mail, kept separate from marketing. ## Checking it works **Send a test email** on the same tab sends one to your own address using the saved settings and reports exactly what happened. Below it, **Recent site email** lists the last twenty messages and whether each went through SendBeam, fell back to the server, or failed. --- # E-commerce: E-commerce events Cart Abandoned, Product Viewed and Order Placed as native SendBeam automation triggers — one endpoint, fed by the WooCommerce plugin, a Shopify webhook, or your own code. `POST /api/v1/ecommerce/events` turns what happens in your store into three [automation triggers](https://sendbeam.io/docs/automations/triggers): **Cart abandoned**, **Product viewed** and **Order placed**. A store posts one normalised event; SendBeam matches or creates the contact by email (exactly the same rules as everywhere else a contact comes from an external event — a bounced, complained or deleted address is never resubscribed by this) and fires any automation built on the matching trigger, at once, the same way a form submission or a tag does. ## The three triggers - **Order placed** — the reliable one. Every order fires it, and also adds the order's value to the contact's `lifetime_value` custom field (see below). - **Cart abandoned** — best effort, from every source. Nothing can prove a cart was truly abandoned rather than completed a minute later somewhere else; this is a heuristic, not a guarantee, and it only ever fires for a cart where the store already knew an email address. - **Product viewed** — only ever fires for a KNOWN contact. There is no anonymous visitor tracking behind this: if the store has no email for the visitor at view time, nothing is sent, on purpose. ## The endpoint Authenticate with an API key carrying the **E-commerce events** permission (`ecommerce:write`) — create one under [Settings → API keys](https://sendbeam.io/settings/api-keys) — in the `x-api-key` header, same as every other `/api/v1/*` write. The body: ``` { "type": "cart_abandoned" | "product_viewed" | "order_placed" | "order_refunded" | "order_cancelled", "email": "jane@example.com", "name": "Jane Doe", // optional "value": 84.50, // optional — order/cart total "currency": "GBP", // optional "external_id": "1001", // order_placed: the order's id in your store — needed for revenue; // order_refunded / order_cancelled: required, names the order to adjust "placed_at": "2026-09-22T10:14:00Z" // optional — when the order was placed } ``` A successful call answers with what happened — nothing is ever silently dropped: ``` { "ok": true, "processed": true, "contact_id": "...", "contact_created": false, "enrolled": true, "lifetime_value": 214.30 } ``` When the address is suppressed, or the workspace's contact cap is reached, the call still answers `200` (your store's checkout hook should never fail because of our suppression list) with `{ "ok": true, "processed": false, "reason": "suppressed" | "contact_limit" }`. ## WooCommerce Use the [official SendBeam plugin](https://sendbeam.io/docs/wordpress). Once its API key is set (**Settings → SendBeam**, with the E-commerce events permission), turn on each event under **Settings → SendBeam → E-commerce events**: - **Order placed** fires from WooCommerce's own order-processed hook — reliable, on by default once enabled. - **Product viewed** fires from the product page for a logged-in customer, or one known from an earlier step in the same session; otherwise nothing is sent. - **Cart abandoned** is a wp-cron heuristic: adding to cart is timestamped, and if no order follows within a window you set (60 minutes by default) it fires once — only when WooCommerce already knows the shopper's email. This is the same honest limitation every dedicated cart-recovery plugin has; WooCommerce core has no real "abandoned cart" event to hook. ## Shopify No app, and no Shopify Partner / app-store listing — you wire this up directly from your own store. In Shopify Admin, go to **Settings → Notifications → Webhooks**, choose **JSON** format, and create a webhook for each topic you want: | Shopify topic | SendBeam trigger | | --- | --- | | `Order creation` / `Order payment` | Order placed | | `Checkout creation` / `Checkout update` | Cart abandoned (best effort — see below) | Use the same URL for every topic, with your workspace id (shown on [Settings → E-commerce](https://sendbeam.io/settings/ecommerce)) in the query string: ``` https://sendbeam.io/api/v1/ecommerce/events?tenant= ``` Shopify shows a webhook **signing secret** the first time you create a webhook on the store. Paste it into [Settings → E-commerce](https://sendbeam.io/settings/ecommerce); every delivery's `X-Shopify-Hmac-SHA256` header is then verified against it (HMAC-SHA256 over the raw body) before anything is processed. No secret saved means Shopify-signed events are refused — there is no unsigned fallback for this path. > `checkouts/create` and `checkouts/update` fire on an *incomplete* > checkout, not a confirmed abandonment — completeness and timing vary by Shopify plan, same > honest caveat as WooCommerce's cart_abandoned. There is **no Shopify webhook topic for "a product page was viewed"** — Shopify does not emit one — so Product viewed cannot be > fed from Shopify at all. > Building an actual Shopify app with an App Store listing is a separate future step that needs the account owner's own Shopify Partner account — this endpoint is what you can use today without one. ## Anything else (n8n, Zapier, your own code) Post the normalised shape directly with your API key: ``` curl -X POST https://sendbeam.io/api/v1/ecommerce/events \ -H "x-api-key: sb_live_XXXXXXXX_..." \ -H "Content-Type: application/json" \ -d '{ "type": "order_placed", "email": "jane@example.com", "name": "Jane Doe", "value": 84.50, "currency": "GBP" }' ``` ## Lifetime value Every `order_placed` event adds `value` to the contact's `lifetime_value` custom field — created automatically as a Number field the first time an order arrives, and always INCREASED, never overwritten, so two orders add up. Use it in a segment, a merge tag, or a condition step the same as any other Number field. The same increment mechanism is available generally: the [Set Field step](https://sendbeam.io/docs/automations/steps) has an **Increase by** mode for building your own running counters. ## Revenue An `order_placed` that carries the order's id (`external_id`) is also stored as an order and credited to the campaign or automation email the buyer engaged with shortly before — a click within 7 days, or an open within 1 day. The figure then appears on the campaign, on each Send email step of an automation, on each version of an A/B test (which can pick its winner by revenue), and across the account on Insights. The rules, and what they deliberately are not, are on [Revenue attribution](https://sendbeam.io/docs/ecommerce/revenue). ## What this is not - Not a Shopify app — no App Store listing, no OAuth install flow. A merchant wires the webhook up themselves, today, with the steps above. - Not anonymous behavioural tracking — Product viewed and Cart abandoned only ever fire for a contact the store already has an email for. - Cart abandoned is always a heuristic. Treat it as "probably didn't finish", not as proof. --- # E-commerce: Revenue attribution Which campaign, automation step or A/B version an order followed — last touch, within a window — and where SendBeam shows what your email earned. Opens and clicks say whether an email was read. Revenue says whether it worked. Once your store sends SendBeam its orders, each order is credited to the campaign or automation email that preceded it, and the figure appears wherever you would look for it: on the campaign, on the automation step, on each version of an A/B test, and added up across every workspace on your account. Nothing has to be configured — an order that arrives is attributed as it arrives. ## How an order reaches SendBeam Orders come in as `order_placed` events on the [e-commerce events endpoint](https://sendbeam.io/docs/ecommerce) — the same call that fires the **Order placed** trigger. A Shopify store posts them from its own **Order creation** webhook; anything else (n8n, Zapier, your own code) posts the shape below with an API key that has the **E-commerce events** permission. ``` curl -X POST https://sendbeam.io/api/v1/ecommerce/events \ -H "x-api-key: sb_live_XXXXXXXX_..." \ -H "Content-Type: application/json" \ -d '{ "type": "order_placed", "email": "jane@example.com", "external_id": "1001", "value": 84.50, "currency": "GBP", "placed_at": "2026-09-22T10:14:00Z" }' ``` Three fields matter for revenue, on top of the event itself: - **`external_id`** (or `order_id`) — the order's id in your store. This is what makes an order an *order* rather than an event: with it, the order is stored once, a retried delivery is a no-op, and it can be attributed. **Without it the event still fires your automations and moves `lifetime_value`, but nothing is stored and nothing is attributed.** Shopify's webhook always carries one; if you post events yourself, include it. The [WordPress plugin](https://sendbeam.io/docs/wordpress) reports WooCommerce orders without an id at present, so those fire your automations and update `lifetime_value` but are not counted as revenue; to count them today, post the order with its id from your own code or an automation tool. - **`value`** and **`currency`** — the order total in the store's own currency. Also required: an order without a total is not stored. The currency stays with the order; see [Currencies](#currencies). - **`placed_at`** — when the order was placed. The windows below are measured back from this moment; it defaults to the time the event arrives. The order is recorded against the contact whose email address it carries. If nobody with that address is in the workspace yet, a contact is created for them, the same way any other store event creates one. If the address is on your suppression list, or the workspace is at its contact cap, the event is acknowledged but nothing is stored — [the response says which](https://sendbeam.io/docs/ecommerce#anything-else). An order whose contact never received an eligible email is stored but credited to nothing, and appears in no revenue figure. ## What counts Attribution is **last touch, within a window**: the most recent email to that contact that they engaged with shortly before ordering gets the credit. Precisely: - An email the contact **clicked** within the **7 days** before the order wins. If they clicked more than one, the most recent click wins. - Failing that, an email the contact **opened** within the **1 day** before the order wins — the most recent open. - A click always beats an open, even when the open happened later. Clicking is the stronger signal. - A **campaign** send and an **automation** send are both eligible. An automation's email is credited to the step that sent it, so a flow with three emails shows which one sells. - Transactional and notification emails — a receipt, a password reset, a form notification — are never credited. A receipt did not earn the order it is a receipt for. - For a campaign with an [A/B test](https://sendbeam.io/docs/campaigns/creating#subject-line-test), the version the contact received is recorded with the order, so the test can be judged on what each version earned. An order is settled once and never re-attributed. An order with no eligible email in the window is recorded as unattributed and stays that way. An order counts at the total your store sent with it, less any refund your store reports afterwards, and drops out altogether if the order is cancelled — see [What this is not](#honest-scope) for what is still not counted. > This is the standard model every email platform uses, and it is deliberately approximate. It > says "this order followed this email", not "this email caused this order". Treat the figures > as a comparison between campaigns and versions, which is what they are good at. > ## Where the figure appears - **The campaign page** — a **Revenue** tile beside Sent, Opened and Clicked, with the number of orders under it. The same figure is in `GET /api/v1/campaigns/{id}/report` as `revenue`, with `by_currency` listing each currency separately. - **The automation page** — each **Send email** step shows what it earned and how many orders, so you can read down a flow and see where the money is. - **A/B tests** — each version's box shows its revenue and orders alongside its open and click rates. When you set a test up, the winner can be chosen by **revenue per recipient** instead of opens or clicks: the version whose test recipients spent the most, per person, goes to everyone else. A revenue test with no orders by the time the wait is up is a tie, and a tie goes to A. - **Insights** — [Account → Insights](https://sendbeam.io/docs/analytics/account-insights) adds it up across every workspace on the account for the last 30 days, with a column per workspace, so an agency can read what email earned across all its client stores. - **Segments** — [segments](https://sendbeam.io/docs/segments) can be built on buying too: whether someone has ordered, and how recently. Each of these appears only once an order has actually been attributed. A workspace without a store, or one whose orders have not followed any email yet, shows no revenue figure at all — rather than a zero that would claim its email earned nothing. ## Currencies Amounts are **never converted** and never added across currencies. A store selling in pounds and one selling in euros have no common total without an exchange rate and a date, and inventing one would give you a confident number that is simply wrong. Where a figure covers more than one currency — an account whose workspaces sell in different currencies, or a store that takes several — the largest is shown as the headline and the others are listed beside it. The order count covers them all. In the API, `total` is the largest currency and `by_currency` has every one. ## What this is not - Not a causal claim. Last touch within a window is an attribution rule, not proof. - Not anonymous tracking. An order is credited only when the buyer's email address is a contact who received an email — there is no cookie, pixel or session stitching behind it. - Not adjustable yet. The 7-day click and 1-day open windows are fixed; every campaign and every workspace is measured the same way, which is what makes the figures comparable. - Net only of what the store reports. An `order_refunded` event (or Shopify's `refunds/create` webhook) takes the refunded amount off the order, and an `order_cancelled` event (or `orders/cancelled`) removes it. A refund or cancellation your integration never sends stays uncounted. - Not retroactive. An order that arrived without an `external_id` before your integration sent one was never stored, and cannot be attributed later. --- # RSS to email: RSS to email Turn new posts on a site's RSS or Atom feed into a campaign, on a schedule, without writing the email twice. ## What it does Most sites already publish a feed — a blog's `/rss.xml`, a changelog, a podcast, a releases page. RSS to email watches that feed and, when something new appears, sends it to the audience you chose as an ordinary campaign: it shows up under **Campaigns**, it has open and click statistics, it carries the unsubscribe and view-in-browser links, and it counts against your monthly emails like any other send. You add a feed under **Messaging → RSS to email**, pick who receives it and how often, and that is it. Nothing goes out when nothing is new. ## How a send is decided - **As posts appear** — the feed is checked every 15 minutes; anything new is sent at once. - **Daily** — sent in the hour you choose (UTC), only if there are new posts since the last send. - **Weekly** — the same, on the weekday you choose. "New" means published since the last send: SendBeam remembers the newest post it sent and takes everything above it in the feed, up to the *posts per email* limit you set. The very first send takes only the newest few posts, never the whole archive. If a post you sent has since been deleted from the feed, the post dates decide instead. **Send now** on the feed's page sends whatever is new straight away; **Send latest anyway** re-sends the newest posts even if they went out before. **Pause** stops the checks without deleting anything. ## What the email looks like With no template chosen, the built-in digest layout is used: the feed's title, an optional intro line you write once, then each post as a linked heading, its date, a short plain-text summary and a *Read more →* link. The subject defaults to the newest post's title; you can write your own with `{{item_title}}`, `{{feed_title}}` and `{{item_count}}`. To use your own design, save a template that contains the **Latest posts** block (in the email builder) — or the `{{rss_items}}` tag in HTML — and pick it on the feed. The posts are dropped in where the block is. A template without the block gets the posts just before the end. Post text is treated as untrusted: HTML from the feed is reduced to plain text and escaped, and only `http(s)` links are kept, so a feed can never inject markup into your email. ## One feed per site Each workspace is a site, so each site's blog gets its own feed, its own list and its own sending domain, while the account holds one plan and one bill. An agency running client newsletters sets up one feed per client workspace; a person with four blogs sets up four feeds and forgets about them. --- # RSS to email: Setting up a feed Add an RSS or Atom feed, choose the audience and schedule, preview the email, and send. ## Add a feed 1. Go to **Messaging → RSS to email** and click **Add a feed**. 2. Give it a name (internal), paste the feed URL — usually `https://yoursite.com/rss.xml`, `/feed` or `/atom.xml` — and choose the audience and schedule. 3. Click **Add feed**. SendBeam fetches and reads the feed there and then: you see how many posts it holds and the newest title, and a URL that is not a working feed is refused with the reason (a web page rather than a feed, an HTTP error, a feed over 2 MB). You then land on the feed's page; press **Preview** to see the email it would send right now. The feed must be public over `http(s)` and under 2 MB. Both RSS 2.0 and Atom are read; the feed's own title becomes the email heading in the built-in layout. Changing the URL later checks it the same way before it is saved. ## Settings - **Send to** — all subscribed contacts, a list or a segment, like any campaign. - **Design** — the built-in digest layout, or one of your templates that contains the *Latest posts* block. - **Subject** — `{{item_title}}` (the newest post) by default; `{{feed_title}}` and `{{item_count}}` are also available. - **Intro** — one line shown above the posts in the built-in layout. - **How often** — as posts appear (checked every 15 minutes), daily or weekly, with the hour (UTC) and weekday. - **Posts per email** — the most posts in one email, 1–10. Anything beyond that waits for the next send. The sender is the workspace's [sender details](https://sendbeam.io/docs/getting-started/sending-domain), the same as every campaign. ## Preview and test **Preview** fetches the feed now and renders the email as it would go out, telling you how many posts are in the feed and how many are new since the last send. It changes nothing. **Send now** sends the new posts immediately. Its other button, **Resend latest posts…**, does not send by itself: it asks once more, then emails the newest posts even if they went out before — useful to see a real send in your inbox. ## From the API Feeds are ordinary resources: `GET/POST /api/v1/rss-feeds`, `GET/PATCH/DELETE /api/v1/rss-feeds/{id}`, `POST …/preview` and `POST …/run` (body `{"force": true}` to send the latest even when nothing is new). Campaigns created by a feed carry `rss_feed_id`. See the [API reference](https://sendbeam.io/docs/api). ## Troubleshooting - **"That URL is not an RSS or Atom feed"** — the address answered, but with a web page rather than a feed. Look for the feed link in the site's `` or try `/rss.xml`, `/feed`, `/atom.xml`. - **"The feed answered HTTP 403"** — the site blocks automated fetches; allow the `SendBeam RSS` user agent. - **Nothing was sent at the scheduled hour** — there was nothing new, or sending is paused for the workspace. The feed's page shows the last check and any error. - **Posts look wrong** — summaries come from the feed's `description` / `summary`; if the feed only has full content, the first 320 characters are used. --- # Analytics: Analytics Track your email performance and subscriber growth. SendBeam's **Reports** page gives you a view of how your email programme is performing. From open rates on individual campaigns to subscriber growth, everything is surfaced in one place so you can make data-driven decisions without leaving the app. ## What you can track Reports covers every email the workspace sends — campaigns, automations and API sends — for a period you choose. - **Sending volume and delivery** — how many emails were sent and how many were delivered. - **Engagement** — open rate and click rate across all sends in the period, with machine opens excluded. - **Delivery health** — bounce rate and unsubscribe rate, the metrics most likely to affect your sender reputation. - **Subscriber growth** — how many contacts were added each day. - **Top campaigns** — the campaigns sent in the period ranked by open rate. > Numbers reflect the delivery and tracking events received so far. If you sent a campaign very recently, allow a few minutes for delivery events to arrive before drawing conclusions. > ## Dashboard layout The Reports overview has three zones, each designed to answer a different question at a glance. ### Top-level stat cards Across the top of the page you will find six summary cards for the selected period: - **Emails Sent** — total messages dispatched: campaigns, automations and API sends. - **Delivered** — messages the receiving mail server accepted (including any later opened or clicked). - **Open Rate** — emails opened at least once ÷ emails sent, with machine opens excluded. - **Click Rate** — emails with at least one click ÷ emails sent. - **Bounce Rate** — bounced or complained emails ÷ emails sent. - **New Contacts** — contacts created in the workspace during the period, however they were added. ### Charts Below the cards are daily bar charts for **Sends Over Time**, **Opens Over Time**, **Clicks Over Time** and **Contact Growth**, plus **Bounce Rate Trend** and **Unsubscribe Rate** bars. Hover over a bar to see the exact figure for that day. Everything responds to the period switch at the top: **7 days**, **30 days** or **90 days**. ### Top Performing Campaigns At the bottom of the page is a table of the campaigns sent in the period with their sent count, opens, clicks, open rate and sent date. Click a campaign to open its own statistics page. ## Navigating to Reports 1. Sign in to your SendBeam workspace. 2. Click **Reports** in the left-hand sidebar. 3. Use the **7 days / 30 days / 90 days** switch to select the period you want to analyse. 4. Review the stat cards and charts for an overview, then scroll down to the campaigns table to investigate individual send performance. 5. Click any campaign row to open its dedicated stats page. ## The email log The **Log** tab on the Reports page lists every individual email the workspace has sent, newest first: recipient, subject, type (campaign, automation or API), delivery status, and when it was opened, clicked and sent. Filter it to **Campaigns**, **Automations** or **API** to find what you are after. The log is the place to answer "did this person get the email?" and to see automation and API sends, which have no campaign page of their own. Send records are kept for as long as the workspace exists; they are not limited by plan. The individual open and click events behind them are kept for 180 days. When a contact is deleted, their address in the log is replaced by an anonymous placeholder. > For a complete offline copy of your data, an admin can download the workspace export under Workspace settings → General → Your data. Per-message delivery logs are not included in that export. > --- # Analytics: Metrics Explained Understand opens, clicks, bounces, unsubscribes, and more. SendBeam records a set of standard email engagement events for every email you send. Understanding exactly what each metric measures — and where its limitations lie — helps you interpret your reports accurately and avoid drawing the wrong conclusions from the numbers. ## Opens and machine opens An **open** is recorded when a subscriber's email client loads the invisible tracking pixel that SendBeam adds at the bottom of each email. Opens are counted once per email: a subscriber opening the same email three times counts as one opened email. The **Opened** figure on a campaign is therefore the number of recipients who opened it. ### Machine opens Security scanners, link checkers and Apple Mail's privacy protection fetch email content — including tracking pixels — on behalf of the user, regardless of whether the email is actually read. SendBeam treats an open as a **machine open** when it comes from a known scanner or bot, or when it happens within seconds of the send — faster than a person could open it. Machine opens are recorded but excluded from every open count and rate. - Open rates are still an approximation: some real readers block images, and some automated fetches slip through. - Click data is much less affected, making clicks the more reliable engagement signal. - For audiences with heavy Apple Mail usage, treat click rate as your primary engagement KPI rather than open rate. > The Reports page labels its open rate "machine opens excluded" so you know the filter is applied. The same filter applies to campaign pages and the email log. > ## Clicks SendBeam tracks link clicks by rewriting every link in your email to pass through a short redirect on `sendbeam.io`. When a subscriber clicks a link, the redirect logs the event and immediately forwards them to the original destination. - Clicks are counted once per email, so the **Clicked** figure is the number of recipients who clicked any link. - A click also counts as an open, since the email must have been read. - Clicks that look automated (security scanners following every link) are excluded in the same way as machine opens. > Merge tags are resolved before links are rewritten, so a link built from a custom field (for example a personalised landing page) is tracked like any other. > ## Bounces A bounce occurs when an email cannot be delivered to the recipient's inbox. SendBeam receives bounce notifications from the receiving mail servers automatically and distinguishes two types. ### Hard bounces A hard bounce means permanent delivery failure — typically because the email address does not exist, the domain is invalid, or the receiving mail server has blocked the address outright. SendBeam sets the contact's status to **Bounced** and no campaign will send to them again. Continuing to send to invalid addresses damages your sender reputation with inbox providers, so leave them bounced or delete them. ### Soft bounces A soft bounce is a temporary failure — the inbox is full, the receiving server is down, or the message was too large. A soft bounce counts as a bounce in the email's status and in the campaign's bounce figure, but it does not change the contact's status, so they are included in future sends as normal. > A bounce rate above 2% is considered high by most inbox providers, and SendBeam pauses a workspace's sending if its bounce or complaint rate crosses the pause level in the [Acceptable Use Policy](https://sendbeam.io/legal/acceptable-use). If your bounce rate spikes after importing a new list, stop and clean the list before continuing. > ## Unsubscribes Every email SendBeam sends carries a one-click unsubscribe link. If your content includes the `{{unsubscribe_url}}` merge tag the link goes where you put it; otherwise SendBeam appends a plain "Unsubscribe" link at the bottom. Emails also carry the `List-Unsubscribe` headers that let Gmail, Outlook and others show their own unsubscribe button. When a subscriber unsubscribes, the following happens automatically: 1. The unsubscribe is counted against the campaign it came from. 2. The contact's status is changed to **Unsubscribed**. Status is workspace-wide, so this covers every list they belong to. 3. Campaigns no longer include the contact, whichever audience is chosen. 4. The subscriber sees a confirmation page. Every unsubscribe link is signed for its recipient. A link without a valid signature — one that has been altered, or copied from an email sent before links were signed — shows an "invalid link" page instead of unsubscribing anyone. A spam complaint has the same effect, with the status set to **Complained**, and is included in the campaign's unsubscribed count. Contacts can also be unsubscribed by hand by editing their status on the contact page. ## Rate formulas and benchmarks SendBeam calculates its rates against the number of emails *sent*: - **Delivered %** = (Delivered ÷ Sent) × 100 - **Open rate** = (Opened ÷ Sent) × 100, machine opens excluded - **Click rate** = (Clicked ÷ Sent) × 100 - **Bounce rate** = (Bounced ÷ Sent) × 100 - **Unsubscribe rate** = (Unsubscribed ÷ Sent) × 100 Because the denominator is sent rather than delivered, open and click rates come out slightly lower than tools that divide by delivered. Keep that in mind when comparing against published benchmarks. ### Industry benchmarks The following ranges are broad averages across industries. Your numbers may vary significantly based on your audience, sending frequency, and content type. | Metric | Below average | Average | Good | | --- | --- | --- | --- | | Delivered | < 95% | 95–98% | > 98% | | Open rate | < 15% | 20–30% | > 35% | | Click rate | < 1% | 2–4% | > 5% | | Unsubscribe rate | > 0.5% | 0.1–0.3% | < 0.1% | | Bounce rate | > 2% | 0.5–1% | < 0.5% | > The most reliable benchmark is your own historical average. Focus on improving your metrics relative to your previous campaigns rather than chasing industry numbers that may not reflect your audience or niche. > --- # Analytics: Insights Across Workspaces One view of every workspace on your account: contacts, this month's sending, and deliverability health with the workspace pulling it down named. Reports show one workspace. If you run several sites on one account, **Account → Insights** adds them all up: how many people are on all your lists, how much of this month's email allowance has gone, how many automations are live, and whether deliverability is healthy across the board — with the workspace responsible named when it is not. Nothing here is a snapshot. Every figure is derived when the page loads from the same records the rest of SendBeam uses, so Insights can never disagree with a workspace's own Reports. ## Where to find it Open the account name at the foot of the sidebar and choose the **Insights** tab, or go straight to `/settings/insights`. Once your account has more than one workspace it also appears in the sidebar under **Insights → All workspaces**. Only workspace admins can see it, because it includes the contacts and sending of every workspace on the account. With a single workspace the page still works — the figures are simply the same as that workspace's Reports — and it explains that adding another workspace under Account → Workspaces is what unlocks the side-by-side comparison. ## The pooled figures - **Workspaces** — how many the account owns. - **Contacts** — subscribed contacts across the account, counted the way your plan's contact limit is: a person on two workspaces counts once. - **Emails this month** — messages sent this billing month by every workspace, against the plan's monthly allowance. This is the same meter as Billing & Plans. - **Live automations** — automations currently active across every workspace. - **Revenue from email** — shown once a store has sent orders that followed an email: what every workspace's campaigns and automations earned in the last 30 days, with each currency counted on its own (see [Revenue attribution](https://sendbeam.io/docs/ecommerce/revenue)). An account with no attributed orders sees no tile rather than a zero. ## Deliverability across workspaces The **Deliverability** panel shows the account's bounce rate and complaint rate over the last 30 days, from every message sent by every workspace — campaigns, automations and the API alike. These are the exact rates SendBeam's sending guardrail measures when it decides whether a workspace's sending should be paused, so the number you read here is the number that matters. The panel gives the account one of five bands: - **Good** — both rates are where they should be everywhere. - **Watch** — a rate is higher than we like, in one workspace or pooled across several. Sending continues; it is the moment to look at list quality and the opt-in route. - **At risk** — a workspace's rate is at the level where sending gets paused. - **Paused** — sending is already paused in at least one workspace, which is named. - **Not enough data** — too little has been sent in the last 30 days for a rate to mean anything. The pooled rates weight each workspace by how much it sends, so a busy workspace with a clean list is not dragged down by a small one that bounces. That also means a small noisy workspace can hide inside a healthy average — which is why the band is only ever as good as the worst individual workspace, and why the sentence under it names that workspace. ## Contact growth and sending **Contact growth** plots the total number of contacts across the account for each of the last 90 days, from when each contact was created. The vertical axis runs from the count at the start of the period to today's count rather than from zero, so a large list that gained a few hundred still shows the shape of the change; both ends of the axis are printed. Archived contacts are left out, like everywhere else in the app. **Sending** shows messages per week for the last eight weeks across every workspace. Hover a week to see its bounce and complaint rates alongside the volume — a rate on its own hides how much mail it was measured on. ## The by-workspace table Every workspace on the account, worst deliverability first, with its contacts, the contacts it added in the last 90 days, its emails this month, what it sent in the last 30 days, its two rates and its band — and, once any workspace has revenue attributed, a revenue column. Rates show as a dash until a workspace has sent enough for them to be meaningful. **Open reports** switches you into that workspace and opens its own Reports page, so you can go from "this one is dragging the average down" to the campaign responsible in one step. It needs a seat in that workspace; a workspace you are not a member of is listed but not linked. --- # Workspace admin: Admin Overview Manage your SendBeam workspace — team, billing, settings, and API. This section of the documentation covers the tools and settings that control a whole workspace: inviting team members and managing access, verifying the domain you send from, setting up your sender identity, billing for the account, and working with the SendBeam API. > **Admin features.** Anyone can read these pages, but the features they describe are only available to people who hold the **admin** role in a workspace. If you are a member and need one of these settings changed, ask a workspace admin to make the change or to give you the admin role under Settings → Team. > ## What the Admin Section Covers As an admin of a SendBeam workspace, you are responsible for the foundational configuration that every other team member depends on. This includes verifying the domain you send from, setting your sender identity, keeping the subscription active, and controlling who has access to the workspace. The admin documentation is split into four focused areas: - **Team Management** — Invite team members, assign roles, and control who can access your workspace. - **Billing & Plans** — View your account's plan, understand usage limits, upgrade or downgrade, and manage your payment details. - **Settings & Workspaces** — Configure your sender name and address, verify your sending domains, create and switch between workspaces, and export or delete a workspace. Delivery itself is managed by SendBeam. - **API Reference** — Authenticate against the SendBeam REST API, manage contacts and campaigns programmatically, and explore all available endpoints. ## Who Can Access Admin Pages SendBeam has three roles in each workspace: **owner**, **admin** and **member**. Admins can do everything members can, plus manage the team, sender details and sending domains, API keys, webhook endpoints and the workspace export. The owner — exactly one per workspace, an admin seat — is additionally the only person who can delete the workspace, change the account's plan or open the billing portal, and can transfer ownership to another member. Members and admins see those settings pages, but the controls they cannot use are replaced with a note saying who can. A workspace can have several admins. The person who creates a workspace is its first admin; they can promote other members to admin from the Team page, and any admin can invite new people directly as admins. Roles are per workspace, so the same login can be an admin in one workspace and a member in another. > **Handing over a workspace.** There IS a separate **owner** seat, held by one person, and it is what permits billing changes and deleting the workspace. An admin cannot demote or remove the owner. To hand over, the owner transfers the seat under Settings → Team; promoting someone to admin is not the same thing. > ## Admin Area Overview Here is a brief summary of each admin area to help you navigate to the right page for your task: - **Team Management** — Invite new members by email as a member or an admin, view your current team list, change roles, and remove users who no longer need access. The number of seats you can use across the account's workspaces is determined by your plan. - **Billing & Plans** — See which plan your account is on (Free, Starter, Pro or Business), monitor pooled usage across contacts, emails sent, workspaces and team size (sending domains and live automations are counted per workspace and shown under Workspace settings → General), manage your payment method, view invoices, and upgrade or cancel your subscription. - **Settings & Workspaces** — Verify the domain you send from, set your sender name and address, manage the workspaces on your account, and export or delete a workspace. Delivery, authentication and bounce handling are managed by SendBeam. - **API Reference** — Generate and manage API keys for programmatic access to SendBeam. Use the REST API to sync contacts, trigger campaigns, query campaign statistics, and more. Full endpoint documentation with request and response examples is available here. ## Getting Started as an Admin If you have just created a new SendBeam workspace, there are a few admin tasks you should complete before your team starts using the platform. Completing these steps ensures that campaigns can actually be sent and that your team has the access they need. 1. **Verify your sending domain.** Add your domain in [Settings > Email & Domains](https://sendbeam.io/docs/admin/settings) and publish the DNS records shown. Until then, your workspace sends from a shared platform address. 2. **Set your sender identity.** Set your sender name and from address on the same page. The from address must be on a verified domain or the shared platform address. Sending from your own verified domain significantly improves deliverability. 3. **Choose the right plan.** The Free plan is suitable for getting started, but if you need to invite team members or hold more than 500 contacts, you will need to upgrade. Review the [Billing & Plans](https://sendbeam.io/docs/admin/billing) page to compare tiers and upgrade if needed. 4. **Invite your team.** Once you are on a plan with more than one seat, head to [Team Management](https://sendbeam.io/docs/admin/team) to invite colleagues. Each invited user receives an email with a link to join the workspace. You can assign them the member role, which gives them access to contacts, campaigns, and templates without exposing billing or admin settings. > **Check the plan first.** The most common reason campaigns stop sending is an account that has reached its monthly email quota or a workspace that has been paused for a high complaint or bounce rate. Both show on the Billing page. > --- # Workspace admin: Team Management Invite team members, manage roles, and control access. SendBeam allows you to collaborate with your team directly within your workspace. As an admin, you control who has access, what role they are assigned, and when their access is removed. This page explains how roles work, how to invite and manage team members, and how your plan determines how many users can be active at once. > **Admin access required.** Only admins can invite people, change roles, or remove users. Members can open the Team page to see who is in the workspace, but the invite form and the role and remove controls are not shown to them. Only the **owner** can transfer ownership. > ## Understanding Roles SendBeam has three roles: **owner**, **admin** and **member**. Every workspace has exactly one owner — the admin seat that can delete the workspace, change the account's plan, open the billing portal and hand the workspace to someone else — and can have any number of admins. Admins can invite any number of people, up to the seat limit allowed by the account's plan. The roles are designed so that day-to-day marketing work can be delegated to members, sensitive settings — sender settings, API keys, team management — stay with admins, and the two things that cannot be undone or cost money stay with one person. **Owner** - Everything an admin can do (the owner holds an admin seat) - Delete the workspace - Change the account's plan, cancel the subscription and open the billing portal - Transfer ownership to another team member — the only way the owner's seat changes hands - Cannot be given another role or removed by an admin; whoever created the workspace owns it, and existing workspaces went to their earliest admin Here is a breakdown of what each role can do: **Admin** - Full access to all features across the platform - Manage contacts, lists, tags, segments, and forms - Create, edit, schedule, and send campaigns - Create and manage email templates - Build and manage automations - View analytics and campaign statistics - Manage team members: invite, change roles, and remove users - See billing settings and usage (changing the plan or cancelling is the owner's) - Configure workspace settings: sender identity and sending domains - Create additional workspaces on the account (they own the ones they create) - Generate and manage API keys - Export the workspace (deleting it is the owner's) **Member** - Manage contacts, lists, tags, segments, and forms - Create, edit, schedule, and send campaigns - Create and manage email templates - Build and manage automations - View analytics and campaign statistics - Cannot change the plan or open the billing portal - Cannot invite, modify, or remove team members - Cannot change sender details or sending domains - Cannot create, view or revoke API keys - Cannot export or delete the workspace > **The member role is right for most collaborators.** Copywriters, designers, and marketing managers who create and send campaigns do not need admin access. Giving team members the member role keeps your billing and configuration settings protected while giving them full creative access. > ## Inviting Team Members Inviting a new team member sends them an email with a link to join your workspace. They do not need an existing SendBeam account — the invitation link walks them through account creation if necessary, then places them in your workspace with the role you selected. If they already have a SendBeam login, accepting the invitation adds this workspace to that login rather than creating a second account. 1. Navigate to **Settings** in the left sidebar, then open the **Team** tab. 2. In the **Invite Team Member** form, enter the email address of the person you want to invite. You can invite one person at a time. 3. Select their role from the dropdown: **Member** or **Admin**. For most collaborators, select Member. 4. Click **Send Invite**. The invited person will receive an email from SendBeam with a join link that is valid for 7 days. 5. The invitation appears under **Pending Invitations** on the Team page until they accept. Once they accept, they appear in the **Team Members** list. If an invitation expires before the person accepts it, or you sent it to the wrong address, revoke it with the cross icon next to the pending invitation and send a new one. Only one pending invitation can exist per email address. > **Check your plan limit before inviting.** If your account is at its seat limit, the invitation is refused with a message telling you how many seats your plan allows. You will need to remove an existing member, revoke a pending invitation, or upgrade your plan before sending a new one. Pending invitations count toward your seat limit. > ## Managing Your Team The Team page lists all active members and any pending invitations in your workspace. For each member you can see their name, email address and role; for each pending invitation, the address, the role it was sent with, and when it expires. You can take the following actions from this list: - **View team members** — See all active users and pending invitations at a glance. - **Change a member's role** — Use the role dropdown on a member's row to switch them between Member and Admin. The change applies immediately. You cannot change your own role. - **Revoke an invitation** — If a pending invitation has not been accepted, revoke it with the cross icon on its row. - **Remove a member** — Take away a user's access to this workspace. **Removing a team member** Removing a member takes away their seat in this workspace. If they belong to other workspaces those are unaffected, and their SendBeam login continues to exist. Their past activity — campaigns they created or sent, contacts they added — remains in the workspace and is not deleted. 1. On the Team page, find the member you want to remove. 2. Click the **Remove member** icon on the right side of their row. 3. Confirm the removal in the confirmation dialog. The member will need to be re-invited if access is needed again. > **Removing a member frees up a seat.** Once a member is removed, their slot in your seat count is freed immediately. You can invite a new member right away without needing to upgrade your plan. > ## Plan Limits on Team Size The number of seats you can use depends on the account's SendBeam plan. Seats are pooled across every workspace on the account: the count includes all admins and members in all of your workspaces, and pending invitations that have not yet been accepted also count toward the limit. Someone who belongs to two of your workspaces uses two seats. - **Free:** 1 user - **Starter:** 2 users - **Pro:** 5 users - **Business:** unlimited users Upgrades take effect within seconds, so you can invite the moment the new plan is active. Compare plans under [Billing & Plans](https://sendbeam.io/docs/admin/billing). Inviting someone who already uses SendBeam adds this workspace to their existing login rather than creating a second account — they pick it from the workspace switcher in the sidebar. See [Workspaces](https://sendbeam.io/docs/admin/settings#workspaces). > **Downgrading affects team access.** If you move to a plan with fewer seats than you are using, existing members keep their access, but you will not be able to invite anyone new until you are back within the limit. Remove members you no longer need before downgrading. > --- # Workspace admin: Billing & Plans Manage your subscription, understand usage limits, and upgrade your plan. ## Billing Overview Plans belong to your **account**, not to a workspace. An account owns every workspace you create — one per site is the usual shape — and is always on one of four tiers: Free, Starter, Pro or Business. The tier sets the features available and the limits, and those limits are **pooled across all the workspaces on the account**: contacts, monthly emails and team seats are added up over every workspace and checked against one cap. Sending domains and live automations belong to a single workspace, so each workspace gets the plan's allowance of its own. The number of workspaces an account may hold is itself a plan limit (Free 2, Starter 5, Pro and Business unlimited). Only the workspace owner can change the plan, cancel the subscription or open the billing portal. Members and admins can open the Billing & Plans page and see the current plan and usage meters, but the upgrade, switch and cancel buttons are disabled for them, with a note saying who to ask. (Ownership can be transferred under Workspace settings → Team.) This page covers everything you need to know about managing your subscription, understanding your usage, and making changes to your plan. > **Owner-only actions.** Upgrading, switching plans, cancelling and managing cards are restricted to the workspace **owner** — an admin alone cannot do them. Promoting someone to admin does not give them billing; the owner seat is separate and is held by one person. Ask the owner, or have them hand the workspace over first. > ## Available Plans SendBeam offers four plan tiers designed to fit teams of different sizes and needs. Each tier builds on the one below it, adding higher limits and more features. All plans include core email marketing functionality — campaign creation, contact management, the email builder, segments, forms and analytics. The differences lie in volume limits, team size, and access to advanced features. Paid plans are billed monthly or yearly — yearly is ten months for twelve. There is no trial. Every tier sends through SendBeam's managed delivery with DKIM and SPF for your own domains, handles bounces, complaints and unsubscribes automatically, and includes campaigns, templates, segments, signup forms and contact forms. ### Free - **Contacts:** 500 · **Emails:** 2,000 a month · **Team:** 1 - **Workspaces:** 2 · **Sending domains:** 1 - **Automations:** 1 live at a time - **API:** read-only - Every list uses double opt-in, and campaign emails carry a small "Sent with SendBeam" footer. ### Starter - **Contacts:** 2,500 · **Emails:** 15,000 a month · **Team:** 2 - **Workspaces:** 5 · **Sending domains:** 3 · **Automations:** 5 live - **API:** read-only - No footer badge; double opt-in is your choice per list; email support. ### Pro - **Contacts:** 10,000 · **Emails:** 60,000 a month · **Team:** 5 - **Workspaces:** unlimited · **Sending domains:** unlimited · **Automations:** unlimited - **API:** full, including sending and writes - Priority email support. ### Business - **Contacts:** 50,000 · **Emails:** 250,000 a month · **Team:** unlimited - Everything in Pro, a dedicated sending IP on request, a data processing agreement, and same-day support. ### Prices, currency and tax Current prices are listed on the [pricing page](https://sendbeam.io/pricing) and shown again on the plan cards on **Account settings > Billing & Plans**, so they are not repeated here — the figure you see there is read from the payment processor, which is the same object checkout charges against. SendBeam bills in US dollars, pounds sterling, Australian dollars and Canadian dollars. Each is its own fixed, rounded price rather than a live conversion of the others, so the plan cards quote the currency you would actually be charged in. **Your account's billing currency is set the first time you check out, and then it never changes.** Stripe fixes it on the first invoice, so every later renewal and every plan change stays in the same currency for the life of the account. Once it is set, the billing page shows every price in that currency and says which one it is. If you need to be billed in a different currency, that means a separate account — get in touch through the [contact form](https://sendbeam.io/contact) and choose the "Plans" subject. The price you see is the price you pay. Tax is included in the displayed amount rather than added on top at checkout, so the figure on the plan card is exactly what is charged to your card, on every renewal. ### How to upgrade, change or cancel On **Account settings > Billing & Plans**, choose a plan and click **Upgrade to …** (or **Switch to …** for a lower paid tier). You are taken to a secure Stripe checkout; cards are handled by Stripe and never touch SendBeam. The plan changes within a few seconds of payment succeeding. To change your card, download invoices or cancel, click **Manage billing**, which opens Stripe's customer portal. A cancelled plan stays active until the end of the period you have paid for, then returns to Free. If a renewal payment fails, the billing page shows a "Payment due" badge and you can update your card in the portal to keep your plan. ## Viewing Your Current Plan and Usage You can check your account's plan and usage at any time from the Billing & Plans page. It shows which plan you are on, how much of each pooled allowance the account has used, and when the plan renews. ### Billing Page To open the billing page, click **Settings** in the left sidebar, then the **Billing & Plans** tab. The top section shows your plan name, its status (Active, Cancelling or Payment due), the renewal or end date for paid plans, and how many workspaces the plan covers. Admins on a paid plan also see the **Manage billing** button here. If you are on the Free plan, the page simply shows "Free" with no payment details. Below the summary, the **Choose a plan** section lists all four tiers side by side with their limits and features, and marks the one you are on as **Current plan**. Upgrading, switching and cancelling are covered in the sections below. ### Usage Meters The billing page includes usage meters that show your consumption of each limited resource against your plan allowance: contacts, emails this month, workspaces, sending domains, live automations and team members. Each meter displays a progress bar along with the exact numbers — for example, "3,200 / 10,000" — and shows "Unlimited" where the plan has no cap. If your account has more than one workspace, a **By workspace** table underneath breaks contacts and emails down per workspace. The meters reflect the current counts each time you load the page. A bar turns red once you pass 90% of the limit; at 100% the corresponding limit enforcement takes effect (see the section on hitting limits below). ## Understanding Usage Limits Each plan has defined limits for contacts, emails sent per month, team members, workspaces, sending domains and live automations. Contacts, emails, team members and workspaces are counted across every workspace on the account and metered under Account settings → Billing & Plans; sending domains and live automations are counted per workspace and metered under Workspace settings → General. Understanding how these limits work and how they are counted helps you plan your usage and avoid unexpected disruptions. ### Contact Limits The contact limit counts **subscribed** contacts across all your workspaces, and it counts a person once: the same email address on two of your sites is one contact against the limit, not two. Unsubscribed and bounced contacts are kept — their history and their suppression matter — but they do not count, because SendBeam never emails them. If you need to free up space within your contact limit, you can permanently delete contacts you no longer need from the contacts page. The contact limit is a standing cap, not a per-month allocation. If your plan allows 10,000 contacts, you can have up to 10,000 contacts on the account at any given time. Deleting contacts frees up space immediately. ### Email Sending Limits The email sending limit is the total number of individual emails the account can send within a calendar month, across campaigns, automations and API sends in every workspace. The counter resets at the start of each month. Every individual email counts as one send — if you send a campaign to 2,000 contacts, that uses 2,000 of your monthly allowance. Only emails that are actually handed to delivery count against the limit. Emails that are sent but subsequently bounce do still count, because the email was dispatched and processed by the delivery infrastructure. Before a campaign starts, SendBeam checks that the whole audience fits within what is left of the allowance; a campaign that would go over is refused rather than sent partially. > **Plan your campaigns around your sending limit.** If you are on the Pro tier with 60,000 emails per month and you have 10,000 contacts, you can send six campaigns per month to your full list. If you send to smaller segments, you can send more frequently without hitting the limit. > ### Team Member Limits The team member limit caps the total number of seats in use across all your workspaces. Every admin and member in every workspace counts, and so do pending invitations. On the Free tier there is a single seat — you. Starter allows 2, Pro allows 5, and on Business the team size is unlimited. If you are at your seat limit and need to invite someone new, you will either need to remove an existing member, revoke a pending invitation, or upgrade your plan. Removed members do not count against the limit. ### Other Limits - **Workspaces** — how many workspaces the account may hold. Free 2, Starter 5, Pro and Business unlimited. See [Workspaces](https://sendbeam.io/docs/admin/settings#workspaces). - **Sending domains** — how many verified sending domains each workspace can have. Free 1, Starter 3, Pro and Business unlimited. - **Live automations** — how many automations can be active at once in each workspace. Free 1, Starter 5, Pro and Business unlimited. Paused and draft automations do not count. - **API writes** — reading through the API works on every plan; creating, updating or sending through the API needs Pro or Business. ## What Happens When You Hit Limits SendBeam enforces its limits as hard caps and never charges overage fees. When a limit is reached, the corresponding action is refused with a message that names the limit and the plan it belongs to: - **Contact limit reached:** You will not be able to add new contacts by hand, by CSV import, through signup forms, or via the API until you delete existing contacts or upgrade your plan. Signup forms tell the visitor the list is not accepting new subscribers right now. Existing contacts and campaigns continue to function normally. - **Email sending limit reached:** Campaigns whose audience does not fit within the remaining allowance are refused with a "Monthly email limit reached" message. Automation emails and API sends are also refused. Sending resumes at the start of the next month or as soon as you upgrade. - **Team member limit reached:** You will not be able to invite new team members until you remove an existing member, revoke a pending invitation, or upgrade your plan. - **Sending domain, automation or workspace limit reached:** Adding another domain, activating another automation or creating another workspace is refused until you remove one or upgrade. > **Scheduled campaigns are affected by the sending limit.** The allowance is checked again when a scheduled campaign becomes due. If the audience no longer fits, the campaign is cancelled rather than sent — the reason is recorded on the campaign page and every workspace admin is emailed — and you would need to duplicate it to send later. Check your usage before scheduling time-sensitive campaigns. > Limits are lifted within seconds when you upgrade to a higher plan or, in the case of the email sending limit, when the month rolls over. There are no warning emails before you reach a limit, so keep an eye on the usage meters as your list grows. ## Upgrading Your Plan Upgrading takes effect within seconds of payment and applies to every workspace on the account. 1. Navigate to **Settings** in the left sidebar, then open the **Billing & Plans** tab. 2. In the **Choose a plan** section, find the plan you want and click **Upgrade to …**. 3. You are taken to Stripe Checkout. Enter your card and billing details — the amount shown is the amount charged, with nothing added at the last step. You can add a company name and tax ID for your invoices. 4. Complete the payment. You are returned to the billing page with a confirmation, and the plan updates within a few seconds — refresh the page if it has not yet. After upgrading, your usage meters update to reflect the new, higher limits. Any features that were locked behind the higher tier become available right away, and any action that was being refused because of a limit works again. ## Downgrading Your Plan To move to a lower paid tier, click **Switch to …** on that plan and complete checkout. To move to Free, cancel your subscription (see [Cancelling Your Subscription](#cancelling-your-subscription)); the cancellation takes effect at the end of the period you have paid for, and until then you keep your current plan's features and limits. ### What Happens to Your Data When you downgrade, no data is deleted. Your contacts, campaigns, templates, and analytics history are all preserved. However, if your current usage exceeds the limits of the lower plan, certain restrictions will apply once the downgrade takes effect: - **Contacts over the limit:** If you have 5,000 contacts and drop to the Free tier (500 contact limit), your existing contacts remain but you will not be able to add new contacts — including through signup forms — until you reduce below 500. You are strongly encouraged to clean your list before the downgrade takes effect. - **Team members over the limit:** Existing members keep their access, but you cannot invite anyone new until the seat count is within the new limit. Remove the members you no longer need. - **Workspaces, domains and automations over the limit:** Existing ones keep working, but you cannot add more until you are within the new limit. - **Feature restrictions:** Features exclusive to the higher tier — such as per-list control of double opt-in on Starter and above — stop being available. On Free, every list behaves as double opt-in and campaign emails carry the "Sent with SendBeam" footer again. > **Prepare before downgrading.** Review your contact count, team member list, and feature usage before initiating a downgrade. Cleaning up in advance prevents disruptions and ensures a smooth transition to the lower tier. > ## Billing Cycle and Invoices Your billing cycle starts on the day you first subscribe to a paid plan. For example, if you upgrade to Pro on 15 March, you are charged on the 15th of each month. The renewal date is shown next to your plan on the billing page. Note that the monthly email allowance is counted per calendar month, not per billing cycle. Invoices are generated automatically by Stripe for every charge. To view or download them, click **Manage billing** on the billing page to open the Stripe customer portal, which lists every invoice with its PDF. Invoices show the plan name, billing period and amount, in your account's billing currency. Your invoice history stays available in the portal for as long as your account exists, even after you cancel and return to Free. > **Invoices for your accountant.** You can add a tax ID and update your billing address in the Stripe customer portal, and they will appear on subsequent invoices. The billing currency is not among them — it is fixed at your first payment and cannot be changed from the portal. > ## Payment Methods SendBeam accepts the cards supported by Stripe Checkout, including Visa, Mastercard, and American Express. Payment processing is handled securely through Stripe — SendBeam never sees or stores your card number. To add or update your payment method: 1. Navigate to **Settings** > **Billing & Plans**. 2. Click **Manage billing** to open the Stripe customer portal. 3. Under payment methods, add your new card and set it as the default. The change applies to your next renewal. If a renewal payment fails, your plan shows as **Payment due** on the billing page and you keep your plan while Stripe retries the card. Update your card in the portal to keep your plan. If the retries fail and the subscription ends, the account drops to the Free tier. Your data is preserved, but the higher limits and paid features are no longer available until you subscribe again. ## Cancelling Your Subscription You can cancel your paid subscription at any time. Cancellation takes effect at the end of your current billing cycle — you retain access to all paid features and limits until then. After cancellation, your account reverts to the Free tier. 1. Navigate to **Settings** > **Billing & Plans**. 2. Click **Cancel subscription** on the Free plan card, or **Manage billing**. Either opens the Stripe customer portal. 3. Cancel the subscription in the portal and confirm. 4. Back on the billing page, your plan shows as **Cancelling** with the date it ends and then returns to Free. After the end date, your account is on the Free tier. All data is preserved — contacts, campaigns, analytics history, and templates remain in your workspaces — and the limit rules described in the downgrading section above apply. If you change your mind before the end date, reopen the customer portal from **Manage billing** and renew the subscription there. Your plan, limits, and billing cycle continue as if the cancellation never happened. > **Your data is safe.** Cancelling your subscription does not delete your workspaces or any of your data. You can return to the Free tier and continue using SendBeam with reduced limits, or re-subscribe to a paid plan at any time in the future. To delete a workspace and its data entirely, see [Your data](https://sendbeam.io/docs/admin/settings#your-data). > ## Frequently Asked Questions **Does my plan cover all of my workspaces?** Yes. One subscription covers every workspace on the account, and the limits are pooled across them. You do not pay per workspace. **What happens if I switch between paid plans mid-cycle?** Switching goes through Stripe Checkout and the new plan applies as soon as payment succeeds. Any proration for the unused part of the old plan is handled by Stripe and shown on your invoice. **Do I get a refund if I cancel mid-cycle?** Cancellations take effect at the end of the current billing cycle, so there is no partial refund. You have already paid for the full month and you retain access to the higher plan's features and limits until that period ends. **What is the refund policy?** SendBeam does not offer automatic refunds for monthly subscription charges. If you believe you were charged in error, or a service outage significantly affected your ability to use the platform, get in touch through the [contact form](https://sendbeam.io/contact) (choose the "Plans" subject) within 14 days of the charge. Refund requests are reviewed on a case-by-case basis. **What happens if I go over my email sending limit?** SendBeam does not charge overage fees. Instead, a hard limit is enforced when you reach 100% of your monthly email allowance. Campaign sending is blocked until the counter resets at the start of the next month or you upgrade to a higher plan. There are no surprise charges on your bill. **Can I pay annually?** Not at the moment. SendBeam uses monthly billing exclusively, which gives you maximum flexibility to adjust or cancel your plan without a long-term commitment. **Is there a free trial of the paid plans?** No. The Free plan has no time limit, so you can use SendBeam for as long as you like within its limits and upgrade when you need more. **Is my payment information secure?** Yes. All payment processing is handled by Stripe, a PCI Level 1 certified payment processor. SendBeam never sees, stores, or has access to your card number. Card details are entered directly into Stripe's hosted checkout, and SendBeam only stores a customer reference so that Stripe can bill the subscription. **I need a bigger plan or a data processing agreement. Is that possible?** The Business plan includes a data processing agreement, same-day support and a dedicated sending IP on request. If you need more than Business offers, get in touch through the [contact form](https://sendbeam.io/contact) and choose the "Sales" subject. > **Need help with billing?** If you have a question not covered here, use the [contact form](https://sendbeam.io/contact) and choose the "Plans" subject. Include the email address you sign in with for the fastest response. > --- # Workspace admin: Settings Sender details, sending domains and workspace preferences. Delivery is managed by SendBeam. ## Settings Overview Settings comes in two scopes, because they own different things. **Workspace settings** — reached from **Settings** in the sidebar — holds what belongs to one site: **General**, **Email & Domains**, **Team**, **API Keys** and **Webhooks**. **Account settings** — reached by clicking the account name at the foot of the sidebar — holds what every workspace shares: **Account** (your login and the workspaces the account owns), **Billing & Plans** and **Security**. Changes take effect immediately. - **Delivery** — a status card on Email & Domains. Delivery is managed by SendBeam; there is nothing to configure. - **Sending domains** — the domains your email may come from, verified with DNS records (Email & Domains). - **Sender details** — the from-name and from-address pre-filled on new campaigns, and a test send (Email & Domains). - **This workspace** — workspace name (admins can rename it there), slug, your role (Owner, Admin or Member) and the plan it inherits (Workspace settings → General). - **Workspaces** — every workspace your login belongs to, and where you create new ones (Account settings → Account). - **Your data** — a full export of the workspace, and self-serve deletion (Workspace settings → General). > **Admin access required for most of it.** Sender details, sending domains, API keys, webhook endpoints, workspace creation, export and deletion are admin-only — over the API as well as in the app. Members can see the General tab and switch between the workspaces they belong to, but the admin-only sections show a note asking them to contact an admin instead of the controls. > ## Delivery Every workspace sends through SendBeam's own delivery infrastructure. Messages are signed with DKIM for your verified domain, use an aligned return-path so SPF passes, and carry one-click unsubscribe headers. Bounces, complaints and unsubscribes flow back automatically: a hard bounce or a complaint changes the contact's status so the address is never sent to again. Sending health is measured against clear complaint and bounce limits, described in the [Acceptable Use Policy](https://sendbeam.io/legal/acceptable-use). Cross the warning level and workspace admins are emailed; cross the pause level and sending stops for that workspace, and admins are emailed when that happens. The warning and pause emails quote your measured rate and the level it crossed, so you always know where you stand. A pause needs a meaningful sample, so a single complaint on a short list will not pause you. If sending is paused, fix the underlying list and [tell us](https://sendbeam.io/contact?s=support) and we will lift it. ### What a pause stops, and what it does not A pause is a sending control, not a suspension of the account. You keep your data and your access throughout, and the workspace tells you it is paused on the dashboard and in Workspace settings. **While a workspace is paused, these do not go out:** - Campaigns — both an immediate send and anything already queued or scheduled - Automation steps that send email - RSS-to-email sends - The transactional API (/api/v1/transactional and /api/v1/send) - Double opt-in confirmations, team invitations and test sends - Contact-form and new-subscriber notifications to the workspace owner **These are deliberately unaffected:** - **Forms keep accepting submissions, and contacts keep arriving.** A pause stops mail leaving; it does not take the customer’s signup forms off their website. The subscriber is stored as usual — only the notification email to the owner is held back, so submissions can arrive unnoticed while a pause is on. - **Scheduled campaigns still come due, and are cancelled rather than sent.** The queue processor refuses a paused workspace at the point of sending, so a campaign scheduled during a pause does not sit and wait for it to lift. - **Platform notices from SendBeam still reach the workspace admins.** Anything sent with system:true bypasses the gate on purpose — including the notice that says the workspace has been paused. Without that exception the pause could not announce itself. - **Signing in, the app, the API and exports all keep working.** A pause is a sending control, not a suspension. The customer can still read their data, fix their list and export everything. - **Other workspaces on the same account are unaffected.** sending_paused is a column on the workspace, so pausing one site never stops another. An account-wide stop means pausing each workspace. > **Watch your forms during a pause.** Signups and contact-form messages are still > accepted and stored — only the notification email to you is held back. Check > [Contacts](https://sendbeam.io/contacts) and your form submissions before you assume it has been quiet. > Each plan also has an hourly sending ceiling as well as its monthly allowance; your plan's ceiling is shown under Billing. Campaign queues simply continue in the next hour; the single-send API returns `429` with a `Retry-After` header telling you when to try again. There are no provider accounts or API keys to manage. Your monthly volume is set by your [plan](https://sendbeam.io/docs/admin/billing), and usage is shown on the Billing page. ## Sending Domains ### Why domain verification matters Mailbox providers judge the domain after the @ in your from-address. Mail that is signed for that domain and comes from an authorised source lands in the inbox; mail that isn't is filtered or rejected. Verification proves to SendBeam, and to Gmail and Outlook, that you control the domain. ### Adding and verifying a domain 1. Open **Settings > Email & Domains** and add the domain (or subdomain) you want to send from. 2. Publish every CNAME record shown — a DKIM signing record, a return-path record and a provider record, three in total today — at your DNS provider, DNS-only rather than proxied. The card names the provider it detected for that domain. Where a provider has enabled Domain Connect for SendBeam it also offers **Connect with provider**, which takes you to that provider's own login to approve the same two records; SendBeam never receives a password or a token. 3. Click **Verify**, or simply wait: SendBeam re-checks the records automatically about every minute, and the status moves from **Add DNS records** through **Verifying** to **Verified**, usually within minutes. 4. Choose the from-address on that domain with **Set as default**. New campaigns use it by default. Until a domain is verified, your workspace can send from a shared platform address. It is authenticated and works, but recipients see the shared domain rather than yours. ### DMARC Publish a DMARC record for your domain (`_dmarc.yourdomain.com`, for example `v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com`). Because SendBeam aligns both DKIM and SPF to your domain, your mail passes DMARC, and the policy stops others forging your address. ## Sender Details The **from-name** is what recipients see as the sender. Use a name they will recognise, such as your brand or "Sarah at Acme". The **from-address** must be the workspace's own shared platform address (`ws-@mail.sendbeam.io`; workspaces created earlier keep `@mail.sendbeam.io`) or an address on a sending domain *this workspace* has verified. SendBeam checks the address when you save it and again on every send; a domain verified in another workspace does not qualify. The from-name is cleaned of line breaks, quotes and angle brackets and kept to 80 characters. Every campaign, automation and API send uses the sender details saved here. Use **Send test email** on the same page to send a test to your own address from the current sender details. ## Account The **General** tab of Workspace settings shows the workspace's name, slug, your role in it and the plan it inherits from the account. An admin can rename the workspace there at any time — type the new name and click **Save name**. The slug never changes, so links, exports and the delete confirmation keep working, and the sender name under Email & Domains is unaffected; the rename is recorded in the audit log. Your own name and email are under Account settings → **Account**. Set up or remove an authenticator app for sign-in under Account settings → **Security**; removing one requires a current code from the app. Your own sign-in — your password, resetting it, and adding an authenticator app as a second step — is covered on [Sign-in and security](https://sendbeam.io/docs/admin/security). ## Workspaces One login can belong to several workspaces — one per site you run is the usual shape. Each workspace keeps its own contacts, lists, tags, segments, campaigns, templates, automations, forms, sending domains, API keys and team. What they share is the account's plan: contacts, monthly emails and seats are pooled across every workspace on the account and checked against one cap, while sending domains and live automations are allowed per workspace. A person who is an admin in one workspace can be a plain member in another. Switch between workspaces from the switcher at the top of the sidebar, or from the **Workspaces** list under Account settings → Account. The workspace you are in is remembered on your login, so every tab and device follows the switch. ### Creating a workspace Under **Account settings → Account → Workspaces**, name the new workspace (after the site, usually) and click **Create workspace**. You become its owner and are taken straight there. The new workspace shares your account's plan and limits from the start; only workspace admins can create workspaces. How many workspaces an account may hold is a plan limit: Free 2, Starter 5, Pro and Business unlimited. The current count and allowance are shown under the form. ### Joining a workspace Accept a team invitation with the email you already sign in with and the workspace is added to your login — no second account. Pick it from the switcher next time you sign in. Being removed from a workspace's team takes that workspace away from your login; your other workspaces are untouched. See [Team management](https://sendbeam.io/docs/admin/team). ## Your data ### Export Admins can download everything the workspace owns as one JSON file from **Workspace settings → General → Your data**: contacts with their tags and lists, lists, tags, segments, templates, campaigns, automations with their steps, forms, sending domains and the team. Credentials, API keys, billing identifiers and per-message delivery logs are never included. Click **Download export**; the file is generated on request and downloads immediately. ### Deleting a workspace The workspace owner can delete it from the same section by typing its slug to confirm and clicking **Delete workspace**; other admins see a note saying who to ask. The account's paid subscription, if any, is cancelled first — if that fails, nothing is deleted and you are asked to cancel billing from Account settings → Billing or try again. Access ends straight away: the workspace disappears from the switcher, its sending domains are released along with any DNS records SendBeam added for them, team members are moved to another workspace they belong to, and a login that belonged to no other workspace is closed. Nothing inside it is destroyed for **14 days**, though. For that fortnight it sits in the bin at the bottom of **Account settings → Workspaces**, where the person who deleted it, the owner or an account admin can **Restore** it — contacts, campaigns, automations, forms and history intact. The bin shows how many days each one has left. After the 14 days it is purged and gone for good: contacts, campaigns, automations, forms, API keys and delivery history are all removed. When it was the last workspace on the account, the account and its billing customer are closed too. Take an export first if you might need the data in a form you can read elsewhere. **Retention.** Individual engagement events (each open and click) are kept for 180 days and contact-form submissions for 90 days. Send records — recipient, subject, delivery status and first open and click — are kept for the life of the workspace, with the address of a deleted contact replaced by an anonymous placeholder. > **The window is the undo.** Restore it within the 14 days and everything comes > back. Once it has been purged, nothing — not support, not a backup — brings it > back, so take the export if there is any doubt. > ## Blueprints A blueprint is a reusable starting point for the next site you set up: one workspace's **custom fields, automations and email templates**, saved under a name that every workspace on your account can then start from. It belongs to the account rather than to the workspace it came from, so an agency sets one site up carefully and the next five begin where that one finished. What a blueprint deliberately does *not* carry is customer data and anything that is proved per workspace: contacts, lists and sending domains are never captured. A domain is verified against its own DNS records in the workspace that sends from it, and cannot be cloned. **Saving one.** Under **Workspace settings → General → Blueprints**, give it a name and an optional description of what it is for, and click **Save as blueprint**. The list below then shows it with what it holds — so many fields, so many automations, so many templates. **Applying one.** Pick it from the same panel and click **Apply to this workspace**, or choose it when you create a workspace, which is the better moment: the destination is empty and there is nothing to reconcile. Applying it into a workspace that is already running is allowed, and the page warns you when this one has live automations. What happens when it is applied: - A custom field whose key already exists here is **left alone**, never overwritten, and reported as skipped. Everything else is created. - Automations arrive with the tags, lists and fields they need, created under their saved names if they are missing. - Templates arrive with their images, copied into this workspace so the result does not depend on the workspace the blueprint came from still existing. Saving and applying are admin-only, and deleting a blueprint never touches a workspace it was already applied to — what it created is that workspace's own from the moment it lands. ## Troubleshooting - **Campaign refuses to send with "Monthly email limit reached".** The account has used its plan's quota across all its workspaces. Upgrade on the Billing & Plans page, or wait for the reset at the start of next month. - **"Sending health warning" email.** The workspace's complaint or bounce rate has reached the warning level; the email quotes the measured rate. Clean the list and send only to people who asked to hear from you before it reaches the pause line. - **"Sending is paused".** The complaint or bounce rate exceeded the threshold. Review recent campaigns and list sources, then get in touch through the [contact form](https://sendbeam.io/contact) to have sending resumed. - **"Hourly send limit reached" (API).** The plan's hourly ceiling is used up. Wait for the `Retry-After` period and retry; campaign queues resume by themselves. - **Domain stuck on "Add DNS records".** Check the CNAME records match exactly, with no extra characters, and that they are not proxied through a CDN (on Cloudflare, set them to DNS-only). The table under the domain shows which records have been found. DNS changes can take up to an hour to be seen. - **Emails going to spam.** Send from a verified domain rather than the shared address, publish DMARC, keep lists to people who opted in, and make unsubscribing easy. --- # Workspace admin: Sign-in and security How you sign in to SendBeam, how to reset a password, and how to protect the account with an authenticator app. One SendBeam login can hold several workspaces, so your sign-in details belong to *you* rather than to any one workspace. This page covers signing in, resetting a forgotten password, and adding a second step with an authenticator app. All of it lives under **Account settings → Security**, reached by clicking the account name at the foot of the sidebar. ## Signing in Go to the sign-in page and enter the email address you registered with. SendBeam then offers two ways to prove it is you: - **Your password** — type it and click **Sign in**. - **A one-time link by email** — click **Email me a one-click sign-in link** and SendBeam sends a sign-in link to that address. Open it and you are signed in, without typing a password. Useful on a machine where you do not want to type one, and the way back in if your password manager is on the other device. Signing out is a button in the sidebar, under your account name. ### If you forget your password 1. On the sign-in page, click **Forgot password?**. 2. Enter your email address and click the button. SendBeam emails you a six-digit code. 3. Type that code on the page that is waiting for it, along with the new password you want (at least 8 characters), and save. 4. You are told the password has been updated, and can sign in with it straight away. The code is sent to the address on the account and nowhere else, so keep access to that mailbox. If the email does not arrive, check the spam folder and ask for another one. ## Passkeys A passkey signs you in with your face, fingerprint or device PIN. There is no password to type or leak and no code to copy, and a passkey only ever works on sendbeam.io, so a look-alike site gets nothing. It is the sign-in we recommend. Add one under Settings → Security → Passkeys. Your device offers to save it in iCloud Keychain, Google Password Manager, Windows Hello or a password manager, and from then on the sign-in page offers it in the email field, or through "Sign in with a passkey". A passkey counts as two factors, so a sign-in with one is never asked for an authenticator code. On a device that does not hold the passkey, your phone can present it by QR code, or a sign-in link to your email still works. Rename or remove passkeys on the same page. ## Two-factor authentication Two-factor authentication (2FA) asks for a six-digit code from an app on your phone in addition to your password, so that knowing the password is not enough to get into the account. SendBeam uses the standard authenticator (TOTP) method, which works with Google Authenticator, 1Password, Authy, or any other app that shows a rolling six-digit code. It is optional, and we recommend it for anyone who can send email to your audience. ### Setting up an authenticator app 1. Open **Account settings → Security** and click **Set up authenticator**. 2. Scan the QR code with your authenticator app. If you cannot scan — the app is on the same device, say — use **Can't scan?** and type the key shown beside the code into the app by hand. 3. The app starts showing a six-digit code for SendBeam. Enter the current one and click **Verify and switch on**. 4. The Security page now shows **On**. If the code was not accepted, a fresh QR code is offered so you can try again — an authenticator that is a little out of step usually just needs its clock set to update automatically. ### Signing in once it is on After your password, SendBeam asks for the code from the app before it lets you in. Open the app, read the current six digits and enter them. The same screen offers **Sign in as a different user** if you reached it with the wrong account. ### Turning it off On the Security page, enter the code your app is showing *now* and click **Remove**. The current code is required on purpose: taking the second step off is exactly what someone who had stolen a session would want to do, and asking for the code means they cannot. You are asked to confirm, and can enrol again at any time. ### If you lose the device Get in touch through the [contact form](https://sendbeam.io/contact?s=support) and we will help you back into the account; then you can set the authenticator up again on the new device. ### Trusted devices When you enter a code, the challenge page offers to trust that browser for 30 days. Tick it on your own phone or laptop and that browser is not asked for a code again, even after you sign out and back in. Leave it unticked on a shared or public computer. Every remembered browser is listed under Settings → Security → Trusted devices, with when it was added and last used. Forget any one of them, or all of them, at any time. They are all forgotten automatically when you change your password or add or remove an authenticator, and a browser is asked for a fresh code if it turns up from a different country. Trust runs out 30 days after the day it was given, whether or not the browser is used in between. The platform console for SendBeam staff never uses trusted devices; it asks for a code on every session. ## Whose settings these are Security is part of **account** settings, not workspace settings, and it only ever shows your own sign-in. A workspace admin cannot see, change or switch off another person's password or authenticator — not from the Team page and not from anywhere else. What an admin can do is take someone's access to the workspace away: see [Team management](https://sendbeam.io/docs/admin/team#managing-your-team). ## Roles and what they can change Each workspace has three roles: **owner**, **admin** and **member**. Admins can do everything members can, plus manage the team, sender details and sending domains, API keys, webhook endpoints and the workspace export. The owner — exactly one per workspace, holding an admin seat — is additionally the only person who can delete the workspace, change the account's plan or open the billing portal, and can transfer ownership to another member. Roles are per workspace, so the same login can be an admin in one workspace and a member in another. None of those roles reaches your sign-in. Your password and your authenticator are yours in every workspace you belong to, and they follow your login when you are invited to another one. See [Team management](https://sendbeam.io/docs/admin/team) for what each role can do, and [Settings and workspaces](https://sendbeam.io/docs/admin/settings) for the rest of the settings. --- # Workspace admin: Single sign-on and GitHub sign-in How SendBeam signs people in through GitHub or a company identity provider (SAML 2.0), what an admin sets up once, and what happens on first sign-in. SendBeam has three ways in: an email address with a password or a one-click link, a GitHub account, and single sign-on through a company identity provider. All three land the same person in the same account and workspaces; which one you use is a matter of convenience and policy, not of what you can do once inside. ## Sign in with GitHub On the sign-in and sign-up pages, **Continue with GitHub** sends you to GitHub to approve SendBeam once, then straight back. SendBeam reads only your primary email address and display name. If that address already has a SendBeam login, GitHub signs you into it; if not, an account and a first workspace are created for you, named after your GitHub profile, and you can rename the workspace under Settings. GitHub does not replace a password: an account that has one keeps it, and you can use either. Two-factor authentication set on your SendBeam account still applies after a GitHub sign-in. ## Single sign-on (SAML 2.0) Single sign-on lets a company sign its people into SendBeam through the identity provider it already runs (Okta, Microsoft Entra ID, Google Workspace, JumpCloud, OneLogin and any other SAML 2.0 provider). Leavers lose access when they are removed from the provider, and the provider's own policies for MFA and device trust apply. It is available on the [Business plan](https://sendbeam.io/pricing). Setting it up is a one-time exchange with us: 1. Your identity provider admin creates a SAML application for SendBeam and sends us its **metadata URL** (or the metadata XML) and the **email domains** the company signs in with, through [the contact form](https://sendbeam.io/contact?s=sales). 2. We register the provider against those domains and reply with SendBeam's service-provider details for the application: the ACS URL and entity ID. 3. From then on, anyone with an address on those domains chooses **Use single sign-on** on the sign-in page, enters their work email, and is sent to your provider. The attribute mapping we need is the standard one: the email address as the NameID (or an `email` attribute), and optionally `name`. A first sign-in through SSO creates the person's SendBeam login and, if they have not been invited to a workspace yet, a workspace of their own; invite them to the company workspace first under Settings → Team and they land there instead. ## Policies an admin can set Under Settings → Account → Security policy, a workspace admin sets rules for everyone on the account: - **Require two-factor authentication.** Applies to password, GitHub and passkey sign-ins; SSO sign-ins are governed by the identity provider's own policy. - **IP allow-list.** The app may only be used from the listed addresses or ranges. A list that would exclude the person saving it is refused. - **Require single sign-on.** Every member must come through the identity provider; any other session is signed out and sent to the SSO page. It can only be switched on from a session that itself used SSO, so nobody can lock the account out of its own settings. - **Session lifetime and idle limit.** A session ends a set number of hours after sign-in, or after a set number of minutes without a request, whichever comes first. Both are off until set. --- # Workspace admin: SCIM provisioning Let your identity provider create, update and deactivate the people on your SendBeam account through SCIM 2.0. SCIM (System for Cross-domain Identity Management) is how an identity provider such as Okta, Microsoft Entra ID or Google Workspace keeps the people in an application in step with its own directory. Assign someone the SendBeam app in the provider and they get a seat; remove them and the seat goes, the same day they leave. ## What SCIM does here - A SCIM user is a person on your **account**. Provisioning gives them a member seat in **every workspace on the account**; a workspace admin can promote them in the app afterwards. - Deactivating or deleting a user takes every seat on the account away. Their other SendBeam accounts, if any, are untouched. A person who owns a workspace cannot be deprovisioned until ownership is transferred. - Names are kept in step. The login address is the SCIM `userName` and cannot be changed through SCIM. - Groups are not offered. Roles are set in the app. ## Setting it up 1. Under **Settings → Account → SCIM provisioning**, a workspace admin generates a token. It is shown once. 2. In the identity provider, add SendBeam as a SCIM application with the base URL `https://sendbeam.io/scim/v2` and the token as the bearer token. Choose the *Users* resource only. 3. Assign the app to the people or groups who should have access. The provider creates each person within minutes and keeps them updated. 4. Generate a new token to rotate; revoke it to stop provisioning. Existing seats stay until the provider or an admin removes them. Provisioned people sign in through single sign-on, a passkey they add, or a sign-in link to their address; no password is set. ## What each operation means | Request | Effect | | --- | --- | | `GET /Users`, with `filter=userName eq "…"` | Lists the people on the account, one page at a time. | | `POST /Users` | Creates the login if the address is new, and seats the person in every workspace on the account. | | `PATCH /Users/{id}` with `active: false` | Removes every seat on the account. With `active: true`, seats them again. | | `PATCH` or `PUT` with a name | Updates the display name. | | `DELETE /Users/{id}` | The same as deactivating. | ## Limits - Pages hold up to 200 users; use `startIndex` and `count` for more. - Filtering supports equality on `userName` or an email value, which is what providers send to check whether a person already exists. - Too many wrong tokens from one address pause that address for a quarter of an hour. --- # Workspace admin: Audit log Who did what in a workspace: sign-ins, API keys, team changes, sending, webhooks, connections, exports and the account security policy. Under **Settings → Audit log**, a workspace admin can see who did what, when, and from which network address. It answers the questions a compliance review or an incident asks: who created that key, who invited that person, who changed the sender, when did this address last sign in. ## What is recorded - **Sign-ins.** Each sign-in (with the method: password, link, passkey, GitHub or single sign-on), each sign-out, and a session ended or refused by the account's security policy. - **Security.** Two-factor turned on or off, a password change, a passkey added or removed, a browser remembered or forgotten. - **API keys.** Created (with its permissions) and revoked, including keys a connected site creates and retires. - **Team.** Invitations sent, withdrawn and accepted; roles changed; members removed; ownership transferred. - **Workspace.** Renamed, scheduled for deletion, restored, exported. Contact and suppression exports. - **Sending.** The sender name or address changed; a domain added or removed. - **Webhooks and connections.** Endpoints added, changed, removed or given a new secret; a store or CRM connected, reconfigured or disconnected; the store signing secret set or cleared. - **Account.** The security policy changed; a SCIM token created or revoked; people provisioned, renamed or deprovisioned by the identity provider; a personal-data erasure run. Each entry names the actor (a person by email address, an API key by its name, the identity provider, or the system), what was acted on, the network address the request came from, and a few details specific to the action: a role, a permission list, a before-and-after pair. Credentials are never recorded: no key, secret, token or password appears in the log. Failed sign-in attempts are not recorded. They are throttled, but a log that anyone with an address can fill from outside is not the log an admin wants to read. ## Reading it The page shows the last 30 days, newest first; choose dates to look further back, or filter by action or by who. Entries cannot be edited or deleted by anyone, including SendBeam staff. A person who administers every workspace on the account can switch to **Whole account** and see every workspace's entries together, with a workspace column. Account-level events (the security policy, SCIM) appear under every workspace on the account. Members cannot see the audit log. It names who signed in from where and who changed keys, team and sending, which is admin business in the same way a webhook's URL is. ## Through the API A key with the `audit:read` permission can read the same entries from `GET /api/v1/account/audit-log`, which is how a SIEM or a compliance tool keeps its own copy. The permission is read-only and off by default when you create a key; it is listed under Integrations. See the [API reference](https://sendbeam.io/docs/api#tag/Audit-log) for the filters and the cursor paging. ## How long entries are kept Entries are kept for 365 days and then removed automatically. Deleting a workspace removes its entries with it once the 14-day grace period ends, and a person's erasure request removes every entry that names their address. --- # API reference: API reference Every endpoint of the SendBeam HTTP API at https://sendbeam.io, with authentication, request and response schemas and copy-paste examples. Also available as OpenAPI 3.1 at /openapi.json. > Machine-readable version: [/openapi.json](https://sendbeam.io/openapi.json) (OpenAPI 3.1, CORS-open). This page is generated from it. Version 1.27.0. > SendBeam is one email-marketing account for every site you run: contacts, tags, lists, segments, campaigns, templates, automations and forms, all behind one JSON API. ## Base URL All endpoints are relative to `https://sendbeam.io`. Requests and responses use JSON (`Content-Type: application/json`) unless an operation says otherwise (CSV import/export, form-encoded unsubscribe, HTML subscriber pages). ## Authentication Send your API key in the `x-api-key` header on every `/api/v1/*` request. Keys look like `sb_live_XXXXXXXX_YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY` (the prefix `sb_live_`, 8 lookup characters, an underscore, then 32 more characters). Create keys under **Settings → API keys** (`/settings/api-keys`); only workspace admins can create or revoke them, and the full key is shown once at creation. Keys are stored hashed and can be revoked at any time. A missing, malformed or revoked key returns `401 {"error":"Unauthorized"}`. A key is scoped to one workspace; it can never read or write another workspace's data. ## Permissions Each key carries a set of permissions chosen when it is created. The API checks the exact strings below and returns `403 {"error":"Forbidden: permission required"}` when the key lacks one: `contacts:read`, `contacts:write`, `contacts:export`, `tags:read`, `tags:write`, `lists:read`, `lists:write`, `campaigns:read`, `campaigns:write`, `campaigns:send`, `templates:read`, `templates:write`, `automations:read`, `automations:write`, `forms:read`, `forms:write`, `segments:read`, `segments:write`, `webhooks:read`, `webhooks:write`, `transactional:send`, `ecommerce:read`, `ecommerce:write`, `audit:read`. Each operation lists the permission it requires. Form management (`/api/v1/forms`) uses `forms:read` / `forms:write`; keys created before these existed that carry `automations:write` are still accepted there. Image hosting (`/api/v1/media`) uses `campaigns:write`. **Authoring and sending are separate.** `campaigns:write` creates and edits a campaign; `campaigns:send` is what actually mails the audience, and is also required to send a single email (`/api/v1/send`) or run an RSS feed. A key granted the first and not the second can prepare everything and send nothing, which is the grant to give anything that runs unattended. **Reading and exporting are separate.** `contacts:read` looks contacts up and pages through them; `contacts:export` is what downloads the list as a file — `GET /api/v1/contacts/export`, the bulk `export` action and `GET /api/v1/suppressions/export`. A key without it is refused by those three endpoints and nothing else: it can still look contacts up and page through them with `contacts:read`, but cannot download them as a file. Keys that held `contacts:read` before the two were split were granted `contacts:export` so nothing that worked stopped; keys created since ask for it separately (**Export contacts**, under Audience). ## Write throughput With an API key, reads (`GET`) are unmetered on every plan. Writes (`POST`, `PUT`, `PATCH`, `DELETE`) also work on every plan and are metered per hour per workspace — Free 120, Starter 600, Pro and Business unlimited. Spending the hour answers `429` with a `Retry-After` header and refills on its own; it is a throughput limit, not a plan gate. Segment previews (`POST /api/v1/segments/preview`, `GET /api/v1/segments/{id}/preview`) carry their rules in the body but are reads, so they are never counted. ## Plan limits Plans cap contacts (Free 500, Starter 2,500, Pro 10,000, Business 50,000 — pooled across the workspaces on one account), emails per month (2,000 / 15,000 / 60,000 / 250,000) and live automations per workspace (1 / 5 / unlimited / unlimited). When a write would exceed a cap the API refuses it with a plain-English message, for example `Contact limit reached (10,000 on the pro plan). Upgrade to add more.` (contact creation and import), `Monthly email limit reached (60,000 on the pro plan).` (campaign and single sends) or `Your plan allows 5 live automations across your workspaces. Pause one or upgrade.` (automation activation). Every plan-cap refusal is a `403`. A workspace whose sending has been paused for abuse receives `Sending is paused for this account…` (`403`) on send attempts. Each plan also has an **hourly sending ceiling** on top of its monthly allowance; your plan's ceiling is shown in the app under Billing. `POST /api/v1/send` returns `429` with a `Retry-After` header saying when to try again once it is used up; campaign queues simply continue in the next hour. On the **Free** plan every list is double opt-in and campaigns — to `all`, a list or a segment — reach only contacts who have confirmed a subscription, so imported or API-created contacts who never confirmed are not mailed. ## Field limits Contact `email` is at most 254 characters, `first_name` / `last_name` at most 100. `custom_fields` must be a flat object of at most 50 keys, each key 1–64 characters, each value a string of at most 200 characters, a finite number or a boolean (`null` values are dropped; arrays and nested objects are refused). Campaign and template `html_content` is at most 500,000 characters and `text_content` 200,000. CSV uploads are at most 5 MB. Over a limit the API answers `400` (contact fields, campaign update) or `413` (campaign/template creation, CSV upload). ## Suppression list An address that unsubscribes, bounces, complains or is deleted stays on the workspace's suppression list after the contact row is gone (a hash of the address, plus a masked display form and the domain on rows written since 2026-09-14). Creating it again returns `409`; a CSV import brings it in as `unsubscribed` whatever `default_status` says. The person can return by subscribing again through a signup form; a workspace admin can also lift the block a deleted contact left (`POST /api/v1/suppressions/lift`) — never a bounce, a complaint or an erasure. To take a contact out of the working list WITHOUT blocking the address, archive it (`action: "archive"` on the bulk endpoint, or `status: "archived"` on PATCH). ## Pagination List endpoints that can grow large (`/api/v1/contacts`, `/api/v1/campaigns`, `/api/v1/lists/{id}/contacts`, `/api/v1/webhooks/{id}/deliveries`) take `page` (default 1) and `limit` (default 50, maximum 100) query parameters and return a `pagination` object: `{ "page": 1, "limit": 50, "total": 1234, "total_pages": 25 }`. Other list endpoints return the full collection. The audit log (`/api/v1/account/audit-log`) pages by cursor instead: pass the `next_cursor` a page returns as `cursor` to get the next one. ## Errors Every error is a JSON object with a single `error` string: `{ "error": "Contact not found" }`. `400` is a validation problem with your request, `401` authentication, `403` permission or plan (including plan caps), `404` a resource this workspace does not own, `409` a conflict (duplicate name, wrong state, suppressed address), `413` a body or upload over its size limit (see Field limits), `422` something about the target that must change first (unsubscribed contact, empty CSV, sending not configured), `429` rate limiting (public forms, and the hourly ceiling on `/api/v1/send`, which carries `Retry-After`), `500` an unexpected failure, `503` the delivery provider or an upstream service failed. ## Subscriber-facing endpoints The unsubscribe and opt-in confirmation endpoints are opened by your subscribers from links in emails. They require no API key, return `text/html`, and are listed here so you know what your subscribers see. ## Contacts People in your workspace. A contact is unique per email address. ### GET /api/v1/contacts **List contacts.** Returns the workspace's contacts, newest first by default, with pagination. Optionally search by email or name; filter by status, tag, list membership, source and the date added — the same filters the contacts page, the CSV export and the bulk endpoint's `filter` take, combinable; and sort. Archived contacts are left out unless `status=archived` asks for them. Requires `contacts:read`. | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `page` | query | integer | no | Page number, starting at 1. | | `limit` | query | integer | no | Items per page (1–100). | | `sort` | query | "created" \| "name" \| "email" \| "status" | no | Column to sort by. `name` is first name then last name. Every sort is stable across pages (ties fall back to newest first, then id). | | `dir` | query | "asc" \| "desc" | no | Defaults to `desc` for `created`, `asc` otherwise. | | `q` | query | string | no | Case-insensitive substring match against email, first_name or last_name. | | `status` | query | ContactStatus | no | Only contacts with this status. | | `tag` | query | string | no | Only contacts carrying this tag (tag id). | | `list` | query | string | no | Only contacts on this list (list id). With `tag` as well, a contact must satisfy both. | | `source` | query | string | no | Only contacts whose `source` is exactly this, e.g. `form`, `import` or `api`. | | `from` | query | string | no | Only contacts added on or after this day (YYYY-MM-DD). | | `to` | query | string | no | Only contacts added on or before this day (YYYY-MM-DD; the whole day counts). A date that is not real, such as `2026-02-31`, is ignored rather than rounded. | #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/contacts?page=1&limit=50" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` A page of contacts. ``` { "contacts": [ { "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "email": "jane@example.com", "first_name": "Jane", "last_name": "Doe", "status": "subscribed", "custom_fields": { "plan": "pro" }, "source": "api", "language": "en", "subscribed_at": "2026-09-01T10:00:00.000Z", "unsubscribed_at": null, "created_at": "2026-09-01T10:00:00.000Z" } ], "pagination": { "page": 1, "limit": 50, "total": 1234, "total_pages": 25 } } ``` `401` No body. `403` No body. `500` No body. ### POST /api/v1/contacts **Create a contact.** Creates a subscribed contact. The email is trimmed, lowercased, validated (at most 254 characters) and must be unique in the workspace; names are at most 100 characters and `custom_fields` must satisfy the CustomFields limits, otherwise `400`. An address on the workspace's suppression list is refused with `409`: one that **unsubscribed** can be re-added by sending `resubscribe: true` (you are asserting the person gave you new consent; the suppression entry is removed once the contact is created), one that **bounced** or **complained** never can, and one that was **deleted** only returns when the person signs up again through a form. Counts against the plan's contact cap. Enrols the contact in every active `contact_created` automation. Requires `contacts:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `email` | string | yes | | | `first_name` | string | no | | | `last_name` | string | no | | | `source` | string | no | Where the contact came from. Defaults to `api`. | | `language` | string \| null | no | The language the person reads in, as an ISO 639-1 code (`fr`, `de`). A campaign sent in more than one language picks the matching version. `fr-FR` and `pt_BR` are accepted and reduced to the primary code; an unknown code is a 400. | | `custom_fields` | CustomFields | no | A flat object of at most 50 keys. Keys are 1–64 characters (`__proto__`, `constructor` and `prototype` are refused); values are strings of at most 200 characters, finite numbers or booleans. `null` values are dropped; arrays and nested objects are rejected with `400`. **Typed fields.** A key declared under Custom fields (`GET /custom-fields`) takes only a value of its type — a `number` field a number or a numeric string, a `boolean` field a boolean or `yes`/`no`/`true`/`false`, a `date` field a real calendar date (`YYYY-MM-DD`, an ISO date-time, or `DD/MM/YYYY`; stored as `YYYY-MM-DD`), a `dropdown` field one of its options — otherwise `400` `custom_fields. must be …`; an empty string clears a typed field. A key no field names is registered as a `text` field when it is first written. Available in emails as `{{custom_fields.}}` and in segment and automation rules as `custom_fields.`. Signup forms cap each value at 200 characters. | | `resubscribe` | boolean | no | Set `true` when the person has given you new consent after unsubscribing: the address's `unsubscribed` suppression entry is removed once the contact is created. Has no effect on an address that bounced, complained or was deleted (still `409`). | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/contacts" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "email": "jane@example.com", "first_name": "Jane", "last_name": "Doe", "source": "website", "language": "fr", "custom_fields": { "plan": "pro", "region": "London", "seats": 3, "trial": false }, "resubscribe": false }' ``` #### Responses `201` Created. ``` { "contact": { "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "email": "jane@example.com", "first_name": "Jane", "last_name": "Doe", "status": "subscribed", "custom_fields": { "plan": "pro" }, "source": "api", "language": "en", "subscribed_at": "2026-09-01T10:00:00.000Z", "unsubscribed_at": null, "created_at": "2026-09-01T10:00:00.000Z" } } ``` `400` Invalid JSON body, `email is required`, `Invalid email address`, `email must be 254 characters or fewer`, `first_name must be 100 characters or fewer` (likewise `last_name`), ` must be a string`, `custom_fields must be an object`, `custom_fields may have at most 50 keys`, `custom_fields key "" is not allowed (1–64 characters)`, `custom_fields. must be 200 characters or fewer`, `custom_fields. must be a string, number or boolean`, or `resubscribe must be true or false`. ``` { "error": "Contact not found" } ``` `401` No body. `403` Missing permission, plan write gate, or the plan's contact cap is reached. ``` { "error": "Contact not found" } ``` `409` A contact with this email already exists (with `resubscribe: true` the message points at `PATCH /api/v1/contacts/{id}`), or the address is on the workspace's suppression list: `unsubscribed` without `resubscribe: true`, or `bounced`, `complained` or `deleted` regardless. ``` { "error": "Contact not found" } ``` `500` No body. ### GET /api/v1/contacts/{id} **Get a contact.** Returns one contact including its tags. Requires `contacts:read`. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/contacts/id" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` The contact. ``` { "contact": null } ``` `401` No body. `403` No body. `404` No body. `500` No body. ### PATCH /api/v1/contacts/{id} **Update a contact.** Partially updates a contact. Only `email`, `first_name`, `last_name`, `status`, `source` and `custom_fields` may be sent, plus the flags `resubscribe`, `suppress` and `replace_custom_fields`; any other key is rejected with `400`. An updated email is lowercased and validated. **Custom fields merge.** `custom_fields` sets the keys you send and keeps every other key the contact already has; a key sent as `null` is removed. Send `replace_custom_fields: true` to replace the whole object instead (the behaviour before 2026-09-14). `{}` without the flag changes nothing. **Unsubscribing.** Setting `status` to `unsubscribed` records `unsubscribed_at` — and only that: the address stays off campaigns but is not blocked, so a later import or signup form can bring it back as a subscriber. Add `suppress: true` to also put the address on the workspace's suppression list as `unsubscribed`, which no import, API call or form can undo until the person opts in again. The list is written after the update succeeds; a bounce or complaint already on file is never downgraded. **Re-subscribing.** The suppression list is checked whenever the update would make a suppressed address a subscriber — renaming the contact to it, or setting `status` back to `subscribed` — and refuses with `409`. An `unsubscribed` entry can be lifted by sending `resubscribe: true` (you are asserting the person gave you new consent): the contact becomes `subscribed` with `subscribed_at` set to now, `unsubscribed_at` cleared and `source` unchanged, and the suppression entry is removed once the update succeeds. Bounced, complained and deleted addresses cannot be re-subscribed this way. Requires `contacts:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `email` | string | no | | | `first_name` | string | no | | | `last_name` | string | no | | | `status` | ContactStatus | no | `archived` is out of the working list (hidden from listings unless asked for, never mailed, not counted) but not blocked. | | `custom_fields` | object | no | Merged into the contact's custom fields: keys you send are set, a key sent as `null` is removed, every other key is kept. Values follow the CustomFields limits. Send `replace_custom_fields: true` to replace the whole object instead. | | `source` | string | no | | | `language` | string \| null | no | ISO 639-1 code; `null` or an empty string clears it. An unknown code is a 400. | | `replace_custom_fields` | boolean | no | With `true`, `custom_fields` replaces the contact's whole custom-field object (keys not sent are dropped) instead of merging. Requires `custom_fields` in the same request. | | `resubscribe` | boolean | no | Set `true` when the person has given you new consent after unsubscribing. Sets `status` to `subscribed` (cannot be combined with another `status`), `subscribed_at` to now, clears `unsubscribed_at`, leaves `source` as it is, and removes the address's `unsubscribed` suppression entry. A bounced, complained or deleted address is still refused with `409`. | | `suppress` | boolean | no | With `status: "unsubscribed"`, also block the address: it goes on the workspace's suppression list as `unsubscribed`, so no later import, API call or signup form can re-add it as a subscriber until the person opts in again. Without it an unsubscribe is a status only. Cannot be combined with `resubscribe`. | #### Example request ``` curl -X PATCH "https://sendbeam.io/api/v1/contacts/id" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "first_name": "Janet", "custom_fields": { "plan": "business", "trial_ends": null } }' ``` #### Responses `200` Updated. With `suppress: true` the response also carries `suppressed` — whether the address is now on the suppression list. ``` { "contact": { "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "email": "jane@example.com", "first_name": "Jane", "last_name": "Doe", "status": "subscribed", "custom_fields": { "plan": "pro" }, "source": "api", "language": "en", "subscribed_at": "2026-09-01T10:00:00.000Z", "unsubscribed_at": null, "created_at": "2026-09-01T10:00:00.000Z" }, "suppressed": true } ``` `400` Invalid JSON body, `Unknown field(s): . Updatable fields: email, first_name, last_name, status, source, custom_fields, language`, `status must be one of subscribed, unsubscribed, bounced, complained`, `custom_fields must be an object`, `Invalid email address`, `No updatable fields provided`, `resubscribe must be true or false`, `resubscribe: true cannot be combined with status `, `suppress must be true or false`, `suppress: true requires status: unsubscribed`, `suppress: true cannot be combined with resubscribe: true`, `replace_custom_fields must be true or false`, or `replace_custom_fields: true requires custom_fields`. ``` { "error": "Unknown field(s): tenant_id. Updatable fields: email, first_name, last_name, status, source, custom_fields" } ``` `401` No body. `403` No body. `404` No body. `409` Another contact already uses this email, or the address is on the workspace's suppression list: `unsubscribed` without `resubscribe: true`, or `bounced`, `complained` or `deleted` regardless. ``` { "error": "Contact not found" } ``` `500` No body. ### DELETE /api/v1/contacts/{id} **Delete a contact.** Permanently deletes a contact and its tag and list memberships. The address is added to the workspace's suppression list as `deleted` (so it cannot be re-created through the API or re-imported as subscribed) and replaced in the send log by an anonymous placeholder. A plain delete keeps a masked form of the address on that row and a workspace admin can lift the block later; `?mode=erase` is the request to be forgotten — the suppression row keeps the hash alone and is never lifted from the app. To remove a contact from the working list WITHOUT blocking the address, set `status: "archived"` with PATCH instead. Requires `contacts:write`. | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `mode` | query | "delete" \| "erase" | no | `erase` for a data-subject erasure: hash-only, permanent block. | #### Example request ``` curl -X DELETE "https://sendbeam.io/api/v1/contacts/id?mode=delete" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` No body. `401` No body. `403` No body. `404` No body. `500` No body. ### GET /api/v1/contacts/{id}/tags **List a contact's tags.** Returns the tags attached to a contact with the time each was assigned. Requires `tags:read`. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/contacts/id/tags" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` Tags on the contact. ``` { "tags": [ { "id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33", "name": "Customer", "color": "#2563EB", "assigned_at": "2026-09-01T10:05:00.000Z" } ] } ``` `401` No body. `403` No body. `404` No body. `500` No body. ### POST /api/v1/contacts/{id}/tags **Add a tag to a contact.** Attaches an existing tag to the contact and enrols the contact in active `tag_added` automations whose `trigger_config.tag_id` matches. Requires `tags:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `tag_id` | string | yes | | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/contacts/id/tags" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "tag_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33" }' ``` #### Responses `201` Tag attached. ``` { "contact_tag": { "contact_id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "tag_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33", "created_at": "2026-09-01T10:05:00.000Z" } } ``` `400` Invalid JSON body or `tag_id is required`. ``` { "error": "tag_id is required" } ``` `401` No body. `403` No body. `404` `Contact not found` or `Tag not found`. ``` { "error": "Tag not found" } ``` `409` Contact already has this tag. ``` { "error": "Contact already has this tag" } ``` `500` No body. ### DELETE /api/v1/contacts/{id}/tags/{tagId} **Remove a tag from a contact.** Detaches the tag from the contact. Requires `tags:write`. #### Example request ``` curl -X DELETE "https://sendbeam.io/api/v1/contacts/id/tags/tagId" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` No body. `401` No body. `403` No body. `404` `Contact not found`, `Tag not found`, or `This tag is not associated with the contact`. ``` { "error": "This tag is not associated with the contact" } ``` `500` No body. ### POST /api/v1/contacts/bulk **Bulk action on contacts.** Runs one action over a set of contact IDs: `unsubscribe`, `archive`, `restore`, `delete`, `erase`, `add_tag`, `remove_tag`, `add_to_list`, `remove_from_list` or `export`. `archive` takes contacts out of the working list without touching their addresses (hidden from listings, never mailed, not counted; the status they had is kept for `restore`, which puts it back — a formerly subscribed contact whose address was blocked meanwhile comes back `unsubscribed`); it answers `503` until the workspace's database has the archive columns. `erase` is the data-subject request: like `delete`, but the suppression row keeps the hash alone and can never be lifted. Non-string IDs are dropped; IDs from other workspaces are ignored; `tag_id` / `list_id` must belong to this workspace. `export` responds with a CSV file instead of JSON. `unsubscribe` sets every selected `subscribed` contact to `unsubscribed` (`unsubscribed_at` now, one `contact.unsubscribed` event each) and leaves contacts already opted out alone; with `suppress: true` every selected address is also put on the suppression list as `unsubscribed` — blocked, not just marked — so no import, API call or signup form can re-add it until the person opts in again (a bounce or complaint already on file is never downgraded). Its response carries `unsubscribed`, `already_unsubscribed`, `suppressed` and `blocked`. `delete` also puts each address on the suppression list (reason `deleted`, source `delete`, masked form kept — a workspace admin can lift it) and pseudonymises it in the send log, exactly like deleting one contact. `add_to_list` adds only subscribed contacts, confirmed on a single opt-in list (they enrol in `list_joined` automations and fire `contact.list_joined`) or unconfirmed on a double opt-in list and on every Free-plan list — a bulk add never sends confirmation email; its response says how many were `added`, were `already_member`, or were `skipped_not_subscribed`. `remove_from_list` fires `contact.list_left` for each membership removed. Tag counts (`tagged`, `untagged`, `deleted`) are the number of IDs submitted; list counts are the number actually affected. Requires `contacts:write`; `export` additionally requires `contacts:export`, the same grant as `GET /api/v1/contacts/export`, and is refused before any contact is read without it. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `action` | "unsubscribe" \| "archive" \| "restore" \| "delete" \| "erase" \| "add_tag" \| "remove_tag" \| "add_to_list" \| "remove_from_list" \| "export" | yes | | | `contact_ids` | array of string | no | The contacts to act on. Either this or `filter`. | | `filter` | object | no | Alternative to `contact_ids`: act on every contact matching the filter (up to 50,000) — the same `q` / `status` / `tag` / `list` / `source` / `from` / `to` the contacts page and `GET /api/v1/contacts` take. Archived contacts are included only when `status` is `archived`. | | `tag_id` | string | no | Required for `add_tag` and `remove_tag`. | | `list_id` | string | no | Required for `add_to_list` and `remove_from_list`. | | `suppress` | boolean | no | `unsubscribe` only: also block every selected address on the suppression list. | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/contacts/bulk" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "action": "add_tag", "contact_ids": [ "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f" ], "tag_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33" }' ``` #### Responses `200` Action applied. JSON for `delete` / `add_tag` / `remove_tag`; a CSV attachment (`contacts-export.csv`, columns email,first_name,last_name,status,source,language,tags,created_at, then one `custom.` column per custom field) for `export`. ``` { "success": true, "deleted": 1, "erased": true, "archived": 1, "already_archived": 1, "restored": 1, "not_archived": 1, "tagged": 1, "untagged": 1, "added": 1, "already_member": 1, "skipped_not_subscribed": 1, "membership": "confirmed", "removed": 1, "unsubscribed": 1, "already_unsubscribed": 1, "suppressed": 1, "blocked": true } ``` `400` Invalid JSON body, `action is required`, `contact_ids array or filter is required`, `No valid contact IDs provided`, `suppress must be true or false`, `tag_id is required for add_tag action`, `tag_id is required for remove_tag action`, `list_id is required for add_to_list action`, `list_id is required for remove_from_list action`, or `Unknown action: `. ``` { "error": "contact_ids array or filter is required" } ``` `401` No body. `403` No body. `404` Tag not found in this workspace (`add_tag` and `remove_tag`), list not found (`add_to_list` and `remove_from_list`), or `No contacts match that filter` when `filter` was given and matched nobody. ``` { "error": "Tag not found" } ``` `500` Bulk action failed. ``` { "error": "Bulk action failed" } ``` `503` `archive` / `restore`: the workspace's database does not have the archive columns yet. Nothing was changed. ``` { "error": "Archiving is not available on this workspace yet: a database update is still to be applied. Nothing was changed." } ``` ### GET /api/v1/contacts/export **Export contacts as CSV.** Every contact in the workspace (or those matching the same `q` / `status` / `tag` / `list` / `source` / `from` / `to` filters as `GET /api/v1/contacts`) as a CSV download with tags, lists and custom fields. Large audiences are exported in full; there is no page limit. Every cell is quoted, and a cell beginning with `=`, `+`, `-` or `@` is prefixed with a single quote so spreadsheets show it as text rather than evaluating it as a formula (the bulk `export` action does the same). Requires `contacts:export` — not `contacts:read`, which reads contacts a page at a time and cannot download the list. | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `q` | query | string | no | Match against email, first name or last name. | | `status` | query | ContactStatus | no | Without it, archived contacts are left out. | | `tag` | query | string | no | Only contacts carrying this tag (tag id). | | `list` | query | string | no | Only contacts on this list (list id). With `tag` as well, a contact must satisfy both. | | `source` | query | string | no | Only contacts whose `source` is exactly this, e.g. `form`, `import` or `api`. | | `from` | query | string | no | Only contacts added on or after this day (YYYY-MM-DD). | | `to` | query | string | no | Only contacts added on or before this day (YYYY-MM-DD; the whole day counts). A date that is not real, such as `2026-02-31`, is ignored rather than rounded. | #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/contacts/export?q=string&status=subscribed" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` CSV file. Columns: email, first_name, last_name, status, source, language, tags, lists, created_at, subscribed_at, unsubscribed_at, then one `custom.` column per custom field in use. No body. `401` No body. `403` The key lacks `contacts:export`. A key holding only `contacts:read` is refused here. ``` { "error": "Forbidden: contacts:export permission required" } ``` ### POST /api/v1/contacts/import **Import contacts from CSV.** Uploads a CSV (multipart field `file`, at most 5 MB — roughly 50,000 rows) and creates contacts from it. Headers are case-insensitive with spaces and punctuation folded to underscores. Recognised columns: `email` (required), `first_name`, `last_name`, `status` (`subscribed`, `unsubscribed`, `bounced`, `complained`, `pending`; aliases `active`→subscribed, `cleaned`→bounced, `cancelled`/`canceled`→unsubscribed, `junk`/`spam`→complained, `unconfirmed`→pending; blank → `default_status`), `tags` (`;` or `|` separated, up to 20 per row, names up to 60 characters), `subscribed_at` (aliases `opted_in_at`, `signup_at`, `optin_time`, `created_at`; ISO 8601, `YYYY-MM-DD HH:MM:SS`, `YYYY-MM-DD` or `DD/MM/YYYY`; unparseable → now, future → now), `unsubscribed_at` (aliases `opted_out_at`, `unsub_time`), `source` (per row, up to 40 characters, overrides the request field), and the consent columns `consent_ip`, `consent_at` (aliases `confirm_time`, `opt_in_confirmed_at`) and `consent_source`, stored under `custom_fields` as `consent_ip`, `consent_confirmed_at` (normalised ISO 8601) and `consent_source`. Every other column becomes a custom field keyed by its snake_cased header (values up to 200 characters; keys that fail validation or exceed the 50-key limit are listed in `dropped_columns`; empty cells store nothing). Rows with `pending` status are never imported (`skipped_unconfirmed`). `unsubscribed`, `bounced` and `complained` rows are created with that status, `unsubscribed_at` set from the file or now, and recorded on the workspace suppression list (`suppressed`); they do not count towards the plan's contact cap — only rows that will be `subscribed` do, and the file is read only up to that allowance (plus one), so a `403` means the subscribed rows exceed the slots left. Addresses already on the suppression list are imported as `unsubscribed` whatever the file says. Repeated addresses within the file are merged (strongest status wins; tags and fields union). Existing contacts: with `skip_duplicates=true` (default) their names and status are left alone but the file's tags are attached, its custom fields merged in (file values win for the keys it carries), and a `subscribed` contact is escalated to the file's `unsubscribed`/`bounced`/`complained` status (never the reverse) — these are counted in `updated`; `skip_duplicates=false` also refreshes their names and source. Tags are matched case-insensitively against existing names and created when missing (at most 200 distinct names per import). `list_id` adds every subscribed contact in the file to that list: confirmed on a single opt-in list (`list_added`), unconfirmed on a double opt-in list or in a Free-plan workspace (`list_pending_confirmation`) — the importer never sends confirmation emails. **No automation runs for an imported contact unless `run_automations=true`**: an import brings people in, which is not the same decision as greeting them, and a list moved from another provider is full of people who were welcomed long ago. With it, newly created subscribed contacts enter active `contact_created` automations and contacts confirmed onto a list enter its `list_joined` automations; `automations_enrolled` reports how many enrolments that produced. Rows whose email is missing, malformed or over 254 characters, whose name is over 100 characters, or whose status is unrecognised are counted as `invalid`. Requires `contacts:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `file` | string | yes | UTF-8 CSV with an `email` column; see the operation description for every recognised column. Comma, semicolon and tab delimiters are detected from the header row. | | `default_status` | "subscribed" \| "unsubscribed" | no | Status for rows whose `status` cell is blank or absent. | | `source` | string | no | Source label for rows without a `source` cell. | | `skip_duplicates` | "true" \| "false" | no | `true` keeps existing contacts' names (tags, custom fields and any opt-out status from the file are still applied); `false` also refreshes their names and source from the file. | | `run_automations` | "true" \| "false" | no | Whether this import starts the workspace's automations for the contacts it creates — `contact_created`, and `list_joined` where `list_id` adds them to a list. **Defaults to `false`**, so an import never emails anybody by itself. Before 2026-09-22 these always ran and could not be switched off. | | `check_domains` | "true" \| "false" | no | Skip rows whose domain cannot receive mail (does not exist, publishes a null MX, or has neither MX nor A/AAAA records), reported in `skipped_undeliverable` and `undeliverable_domains`. Only a definite DNS answer skips a row; well-known providers are not looked up and at most 500 distinct domains are checked per import. `false` skips the DNS lookups. Placeholder addresses — `example.com` / `.net` / `.org` and their subdomains, the reserved `.test`, `.invalid`, `.localhost` and `.example` domains, and the `abuse@` / `postmaster@` role mailboxes — are always skipped and named in `placeholder_addresses`, whatever this option says. | | `tags` | string | no | Tags to attach to every contact in the file, `;` or `\|` separated (same rules as the `tags` column). Created when missing. | | `list_id` | string | no | Add every subscribed contact in the file to this list. Unconfirmed on a double opt-in list (and every list in a Free-plan workspace); no confirmation email is sent by the import. | | `mapping` | string | no | Optional JSON array describing what each column becomes — what the import page's mapping screen sends. Each entry is `{ column, target }` with `column` the 0-based index in the header row and `target` one of `email`, `first_name`, `last_name`, `status`, `tags`, `source`, `subscribed_at`, `unsubscribed_at`, `consent_ip`, `consent_at`, `consent_source`, `ignore`, or `custom` with a `key` (letters, digits, underscores). A column not listed is ignored. Exactly one column must be `email`; a single-value target may be mapped from one column only (the three date targets may take several — the first usable date wins); a custom key may be used once and cannot be a consent key. Without `mapping`, the headers are read as described above. | | `new_fields` | string | no | Optional JSON array of custom fields to declare in the registry before the rows are typed against it: `{ key, type, label?, options? }` with `type` one of `text`, `number`, `boolean`, `date`, `dropdown` (a dropdown needs `options`). Each key must be one the `mapping` maps a column to. A field that already exists is used as it is; a type or options problem stops the import with `400` (or `409` when stored values would not fit the type — see the custom-fields API). While the registry migration is pending the keys import as untyped text and `fields_registry` is `unavailable`. | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/contacts/import" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` Import finished. ``` { "imported": 118, "updated": 6, "skipped": 6, "skipped_duplicates": 6, "suppressed": 23, "skipped_unconfirmed": 4, "invalid": 2, "skipped_undeliverable": 4, "undeliverable_domains": [ { "domain": "gmial.com", "rows": 2 }, { "domain": "example.com", "rows": 1, "reason": "example domain — reserved for documentation, never a real mailbox" }, { "domain": "oldcompany.co.uk", "rows": 1 } ], "skipped_placeholder": 1, "placeholder_addresses": [ { "email": "test@example.com", "reason": "example domain — reserved for documentation, never a real mailbox" } ], "tags_created": 2, "tags_attached": 140, "custom_field_keys": [ "company", "consent_ip", "consent_confirmed_at", "consent_source" ], "dropped_columns": [ "Notes (long)" ], "list_added": 0, "list_pending_confirmation": 95, "truncated": false } ``` `400` `Request must be multipart/form-data`, `Failed to parse form data`, `A file field named "file" is required`, `default_status must be subscribed or unsubscribed`, an invalid `mapping` (`mapping must map one column to email`, `mapping[1].target: "Email" is mapped from two columns`, …) or an invalid `new_fields` entry. ``` { "error": "A file field named \"file\" is required" } ``` `401` No body. `403` Missing permission, plan write gate, or the file's subscribed rows exceed the plan's remaining contact slots (suppressed rows never count). ``` { "error": "Contact not found" } ``` `404` `list_id` does not match a list in this workspace. ``` { "error": "List not found" } ``` `413` The upload is larger than 5 MB (judged from `Content-Length` and again from the file itself). ``` { "error": "The file is too large. Uploads are limited to 5 MB — split the CSV and import it in parts." } ``` `422` No importable rows in the file — no `email` header, every row invalid, or every row `pending`. The body also carries the `invalid` and `skipped_unconfirmed` counts. ``` { "error": "No valid contacts found. The CSV must have an \"email\" column header and at least one data row.", "invalid": 3, "skipped_unconfirmed": 0 } ``` `500` A batch failed to insert. ``` { "error": "Failed to import contacts", "details": "duplicate key value violates unique constraint" } ``` ## Custom fields The registry of custom fields contacts carry: each has an immutable key, a label, a type (`text`, `number`, `boolean`, `date`, `dropdown`) and, for a dropdown, its options. Values written anywhere are checked against the type (`contacts:read` / `contacts:write`). ### GET /api/v1/custom-fields **List custom fields.** Every custom field declared in the workspace, in display order, with how many contacts carry each key (`uses`, null when usage counts are unavailable). Also lists `unregistered` keys — keys contacts carry that no field names yet (register them with `POST /custom-fields/scan`) — and `unaddressable` keys, which contain characters a merge tag cannot use and are never registered. Requires `contacts:read`. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/custom-fields" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` The registry. ``` { "fields": [ { "id": "5e2b8c1a-4d3f-4a6b-9c8d-1e2f3a4b5c6d", "key": "plan", "label": "Plan", "type": "dropdown", "options": [ "free", "pro", "business" ], "position": 0, "uses": 1180, "created_at": "2026-09-14T09:00:00.000Z", "updated_at": "2026-09-14T09:00:00.000Z" }, { "id": "6f3c9d2b-5e4a-4b7c-8d9e-2f3a4b5c6d7e", "key": "renewal_date", "label": "Renewal date", "type": "date", "options": [], "position": 1, "uses": 312, "created_at": "2026-09-14T09:00:00.000Z", "updated_at": "2026-09-14T09:00:00.000Z" } ], "unregistered": [ { "key": "legacy_score", "uses": 4 } ], "unaddressable": [], "usage_available": true } ``` `401` No body. `403` No body. `503` The registry is not available yet (its database update has not been applied); contact fields keep working untyped. ``` { "error": "Contact not found" } ``` ### POST /api/v1/custom-fields **Create a custom field.** Declares a field. `key` is 1–64 characters of letters, digits and underscores, unique in the workspace and **cannot be changed afterwards** (change the `label`, or delete the field and add another). `type` defaults to `text`; a `dropdown` needs at least one option. A key contacts already carry may be declared — if the chosen type does not fit some stored values the request is refused with `409` and the count (`violations`), until `confirm_violations: true` is sent; stored values are never rewritten. At most 100 fields per workspace. Requires `contacts:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `key` | string | yes | | | `label` | string | no | Defaults to the key with underscores as spaces, capitalised. | | `type` | "text" \| "number" \| "boolean" \| "date" \| "dropdown" | no | | | `options` | array of string | no | Required for `dropdown`; ignored otherwise. | | `confirm_violations` | boolean | no | Declare the field even though some stored values do not fit the type (they are kept as they are). | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/custom-fields" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "key": "string", "label": "string", "type": "text", "options": [ "string" ], "confirm_violations": false }' ``` #### Responses `201` Created. ``` { "field": { "id": "5e2b8c1a-4d3f-4a6b-9c8d-1e2f3a4b5c6d", "key": "plan", "label": "Plan", "type": "dropdown", "options": [ "free", "pro", "business" ], "position": 0, "created_at": "2026-09-14T09:00:00.000Z", "updated_at": "2026-09-14T09:00:00.000Z" } } ``` `400` `key is required`, `key must be 1–64 characters of letters, digits and underscores…`, `label must be 80 characters or fewer`, `type must be one of text, number, boolean, date, dropdown`, `a dropdown field needs at least one option`, `a dropdown may have at most 100 options`, or `A workspace may have at most 100 custom fields.` ``` { "error": "Contact not found" } ``` `401` No body. `403` No body. `409` The key exists, or stored values do not fit the chosen type (then `violations` is present: `count`, `examples`, `partial`). ``` { "error": "3 contacts hold a value for \"renewal_date\" that is not a valid date (for example \"soon\"). Those values are kept exactly as they are and will be flagged on each contact until someone corrects them. Send confirm_violations: true to change the type anyway.", "violations": { "count": 3, "examples": [ "soon" ], "partial": false } } ``` `503` The registry is not available yet. ``` { "error": "Contact not found" } ``` ### PATCH /api/v1/custom-fields/{key} **Update a custom field.** Changes `label`, `type` and/or `options`. The key cannot be changed (`400`). A type or option change that stored values would violate is refused with `409` and the count until `confirm_violations: true` is sent; even then no stored value is altered — each is flagged on its contact until someone corrects it, and `violations` in the `200` body says how many. Requires `contacts:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `label` | string | no | | | `type` | "text" \| "number" \| "boolean" \| "date" \| "dropdown" | no | | | `options` | array of string | no | | | `confirm_violations` | boolean | no | | #### Example request ``` curl -X PATCH "https://sendbeam.io/api/v1/custom-fields/key" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "label": "string", "type": "text", "options": [ "string" ], "confirm_violations": false }' ``` #### Responses `200` Updated. ``` { "field": { "id": "5e2b8c1a-4d3f-4a6b-9c8d-1e2f3a4b5c6d", "key": "plan", "label": "Plan", "type": "dropdown", "options": [ "free", "pro", "business" ], "position": 0, "created_at": "2026-09-14T09:00:00.000Z", "updated_at": "2026-09-14T09:00:00.000Z" }, "violations": { "count": 1, "examples": [ "string" ], "partial": true } } ``` `400` `A field's key cannot be changed…`, `Nothing to update: send label, type or options.`, or a label/type/options validation message. ``` { "error": "Contact not found" } ``` `401` No body. `403` No body. `404` No body. `409` Stored values do not fit the new type or options; `violations` is present. ``` { "error": "string", "violations": { "count": 1, "examples": [ "string" ], "partial": true } } ``` `503` The registry is not available yet. ``` { "error": "Contact not found" } ``` ### DELETE /api/v1/custom-fields/{key} **Delete a custom field.** Removes the field **and its value from every contact** in the workspace, and takes it off every signup form that collected it. Segment rules, automation rules and `{{custom_fields.}}` merge tags that name it are left as they are and stop matching or resolving (the pre-send check reports them). Cannot be undone. A key that an integration keeps sending is registered again, as Text, the next time it arrives. Requires `contacts:write`. #### Example request ``` curl -X DELETE "https://sendbeam.io/api/v1/custom-fields/key" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` Deleted. ``` { "ok": true, "key": "legacy_score", "contacts_cleared": 4, "forms_updated": 0 } ``` `401` No body. `403` No body. `404` No body. `503` The registry is not available yet. ``` { "error": "Contact not found" } ``` ### POST /api/v1/custom-fields/scan **Register keys already in use.** Scans the workspace's contacts for keys that have no field yet and declares each one. The type is inferred only where every stored value agrees (all booleans → `boolean`, all numbers → `number`, all `YYYY-MM-DD` strings → `date`), otherwise `text`, so a scan never declares a type a stored value violates. Keys with characters a merge tag cannot use are reported in `unaddressable` and left alone. Requires `contacts:write`. #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/custom-fields/scan" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` What was registered. ``` { "ok": true, "added": [ { "id": "7a4d0e3c-6f5b-4c8d-9e0f-3a4b5c6d7e8f", "key": "legacy_score", "label": "Legacy score", "type": "number", "options": [], "position": 2 } ], "unaddressable": [ "Plan Name" ] } ``` `401` No body. `403` No body. `503` The registry is not available yet. ``` { "error": "Contact not found" } ``` ## Suppressions Addresses that are never emailed: unsubscribes, bounces, complaints and deleted contacts. Import your old platform's lists here before your first send. ### GET /api/v1/suppressions **Suppression counts, or check one address.** Without parameters: how many addresses are on the workspace's suppression list, split by reason. With `?email=`: whether that one address is suppressed, with its reason and when it was added. Addresses are stored as one-way hashes; the list itself is paged by `GET /api/v1/suppressions/list` and downloaded by `GET /api/v1/suppressions/export`. Requires `contacts:read`. | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `email` | query | string | no | Check this address (case-insensitive; surrounding whitespace ignored). | #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/suppressions?email=jane%40example.com" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` Counts by reason (no `email`), or the result for one address. ``` { "count": 1204, "by_reason": { "unsubscribed": 1130, "bounced": 61, "complained": 9, "deleted": 4 } } ``` `400` `email` is not a valid address. ``` { "error": "email must be a valid email address" } ``` `401` No body. `403` No body. `500` No body. ### POST /api/v1/suppressions **Import suppressions (JSON or CSV).** Puts addresses on the workspace's suppression list so they are never emailed. Use it first when migrating: load the old platform's unsubscribes, bounces and complaints before importing contacts or sending. Send either a JSON body (`entries`, at most 5,000 per request) or `multipart/form-data` with a CSV in `file` (at most 5 MB; header row with `email` and optional `reason`, or one bare address per line). Each entry's `reason` is `unsubscribed`, `bounced` or `complained`; blank means the request-level default `reason` (itself defaulting to `unsubscribed`). Common spellings from other platforms are accepted: `cleaned`, `hard bounce`, `invalid` → `bounced`; `cancelled`, `canceled`, `opted out` → `unsubscribed`; `junk`, `spam`, `abuse`, `complaint` → `complained`. `deleted` cannot be imported. A stronger reason (complained > bounced > unsubscribed > deleted) replaces a weaker one; a weaker one never downgrades. Repeated addresses in one request are folded together and the extra rows counted as `unchanged`, so `received = added + upgraded + unchanged + invalid`. Rows with a malformed address (or over 254 characters) or an unknown reason are counted as `invalid` and skipped. Existing contacts with a matching address whose status is still `subscribed` are switched to the imported status with `unsubscribed_at` set to now (`contacts_updated`); contacts that are already unsubscribed, bounced or complained are left as they are. No contact is ever created. Work is batched 200 addresses at a time; a database failure part-way returns `500` and the earlier batches stay written, so the request is safe to repeat. Requires `contacts:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `reason` | SuppressionImportReason | no | Reasons an import may set (`deleted` is reserved for contact deletion). Aliases such as `cleaned`, `cancelled`, `junk` and `spam` are accepted and mapped. | | `entries` | array of string \| object | yes | | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/suppressions" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "reason": "unsubscribed", "entries": [ "jane@example.com" ] }' ``` #### Responses `200` Import finished (also when every row was invalid). ``` { "received": 3, "added": 2, "upgraded": 1, "unchanged": 0, "invalid": 0, "contacts_updated": 1, "by_reason": { "unsubscribed": 1, "bounced": 1, "complained": 1 } } ``` `400` `Invalid JSON body`, `entries array is required`, `entries must not be empty`, `entries: at most 5,000 per request (send several requests, or upload a CSV)`, `reason must be unsubscribed, bounced or complained`, `Failed to parse form data`, or `A file field named "file" is required`. ``` { "error": "entries array is required" } ``` `401` No body. `403` No body. `413` The upload is larger than 5 MB. ``` { "error": "The file is too large. Uploads are limited to 5 MB — split the CSV and import it in parts." } ``` `422` The CSV has no data rows (or no `email` header). ``` { "error": "No rows found. The CSV needs an \"email\" column header (optionally \"reason\") and at least one data row.", "invalid": 0 } ``` `500` A batch failed to write. `details` carries the database message; repeat the request once the cause is fixed. ``` { "error": "Failed to import suppressions", "details": "connection reset" } ``` ### GET /api/v1/suppressions/list **Browse the suppression list.** A page of the suppression list, newest first. Each row carries the reason, when it was added, where it came from (`source`), a masked display form of the address (`j***@example.com`) and its domain — stored on rows written since 2026-09-14, derived at read time from a contact row that still carries the address for older rows, and null for the rest (older hash-only rows and erasures; those are never back-filled). `contact` is that still-existing contact, when there is one: the only place the full address appears. `liftable` says whether `POST /api/v1/suppressions/lift` would accept the row. `details_available` is false while the workspace's database is missing the display columns (every detail is then null and `domain` is ignored). Requires `contacts:read`. | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `page` | query | integer | no | Page number, starting at 1. | | `limit` | query | integer | no | | | `reason` | query | SuppressionReason | no | | | `domain` | query | string | no | Case-insensitive substring of the stored domain. | #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/suppressions/list?page=1&limit=50" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` A page of rows. ``` { "suppressions": [ { "email_hash": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8", "reason": "bounced", "source": "provider", "created_at": "2026-09-14T09:12:41.000Z", "email_masked": "j***@example.com", "email_domain": "example.com", "contact": { "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "email": "jane@example.com", "status": "bounced" }, "liftable": false } ], "pagination": { "page": 1, "limit": 50, "total": 1, "total_pages": 1 }, "details_available": true } ``` `400` `reason` is not one of the four reasons. ``` { "error": "reason must be unsubscribed, bounced, complained or deleted" } ``` `401` No body. `403` No body. `500` No body. ### GET /api/v1/suppressions/export **Export the suppression list as CSV.** The whole list (or one reason / domain) as a CSV download, newest first. Columns: `email` (filled only where a contact row still carries the address), `email_masked`, `domain`, `reason`, `source`, `added_at`, `contact_id`. Every cell is quoted and formula-safe, like the contacts export. Requires `contacts:export`, the same grant as the contacts export; the paged list and the counts stay on `contacts:read`. | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `reason` | query | SuppressionReason | no | | | `domain` | query | string | no | | #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/suppressions/export?reason=unsubscribed&domain=string" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` CSV file (`suppressions.csv`, or `suppressions-.csv`). No body. `400` `reason` is not one of the four reasons. ``` { "error": "Contact not found" } ``` `401` No body. `403` The key lacks `contacts:export`. ``` { "error": "Forbidden: contacts:export permission required" } ``` `500` No body. ### POST /api/v1/suppressions/lift **Lift the block a deleted contact left.** Takes a `deleted` suppression off the list so the address can be re-added by an import, the API or a signup form. Refused with `409` for a bounce or a complaint (permanent), an unsubscribe (that needs the person's new consent — re-subscribe the contact, or `resubscribe: true`), and an erasure (the person asked to be forgotten; only they can return, through a form). Identify the row by `email` or by the `email_hash` from the list. Session callers must hold the workspace admin role (`403` otherwise); an API key needs `contacts:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `email` | string | no | | | `email_hash` | string | no | | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/suppressions/lift" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "email": "jane@example.com", "email_hash": "string" }' ``` #### Responses `200` Lifted. ``` { "lifted": true, "reason": "deleted" } ``` `400` Invalid JSON body, `email or email_hash is required`, or a malformed value. ``` { "error": "email or email_hash is required" } ``` `401` No body. `403` Missing `contacts:write`, or a session caller who is not a workspace admin. ``` { "error": "Only a workspace admin can lift a block. Ask a workspace admin, or ask to be given the admin role under Settings → Team." } ``` `404` The address is not on the suppression list. ``` { "error": "This address is not on the suppression list." } ``` `409` The row cannot be lifted; `reason` says why. ``` { "error": "This address previously bounced and cannot be re-added.", "reason": "bounced" } ``` `500` No body. ## Imports Pull an audience straight from Mailchimp, MailerLite, Kit, Brevo or EmailOctopus with an API key the customer supplies. Keys are used for the one request and never stored. ### POST /api/v1/imports/connect **Check a platform API key and list what it can import.** Validates a Mailchimp, MailerLite, Kit, Brevo or EmailOctopus API key with one cheap call and returns the account, the lists it can see (Mailchimp audiences, MailerLite groups, Kit tags, Brevo lists, EmailOctopus lists — MailerLite and Kit also offer an "All subscribers" entry with id `""`) with subscriber counts, and the source's capabilities (what comes across: opt-in dates, consent evidence, bounces, complaints, tags, custom fields; plus notes and limitations). Credentials are used for this request only: never stored, never logged, never echoed. For Mailchimp the data centre is read from the key's `-usNN` suffix (pass `credentials.dc` if your key has none). Requires `contacts:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `source` | "mailchimp" \| "mailerlite" \| "kit" \| "brevo" \| "emailoctopus" | yes | | | `credentials` | object | yes | | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/imports/connect" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "source": "mailchimp", "credentials": { "api_key": "string", "dc": "string" } }' ``` #### Responses `200` The key works. ``` { "ok": true, "source": "mailchimp", "label": "Mailchimp", "account": { "name": "Acme Newsletter", "email": "jane@acme.example" }, "lists": [ { "id": "a1b2c3d4e5", "name": "Newsletter", "member_count": 1180, "suppressed_count": 42, "kind": "audience" } ], "requires_list": true, "capabilities": { "platform": "mailchimp", "label": "Mailchimp", "provides": { "subscribed_at": true, "consent_ip": true, "consent_timestamp": true, "consent_source": true, "bounces": true, "complaints": false, "unsubscribes": true, "tags": true, "custom_fields": true }, "notes": [ "\"cleaned\" members are imported as bounced." ], "limitations": [ "No complaint (spam report) status is exposed on members." ] }, "subrequests": 2 } ``` `400` Bad request, or the platform rejected the key (`ok: false`). ``` { "error": "Contact not found" } ``` `401` No body. `403` No body. `503` The platform could not be reached or returned an error other than an auth failure. The message never contains the key. ``` { "ok": false, "error": "Kit request failed: GET https://api.kit.com/v4/account: HTTP 503" } ``` ### POST /api/v1/imports/run **Import contacts from a platform.** Streams the platform's subscribers into the workspace: each source page is normalised, its unsubscribed / bounced / complained addresses are written to the suppression list **first**, then the page goes through the same write path as the CSV importer (contacts, tags, custom fields, optional list membership, `contact_created` and `list_joined` automations). Status mapping — Mailchimp `cleaned` → bounced, `pending` → not imported, `archived`/`transactional` → skipped (`skipped_other`); MailerLite `junk` → complained, `unconfirmed` → not imported; Kit `cancelled` → unsubscribed, `inactive` → not imported. Consent evidence becomes the custom fields `consent_ip`, `consent_confirmed_at`, `consent_source` where the platform exposes it. Caps per run: 25,000 contacts written (rounded up to the end of the source page in progress), plus a request budget and a time budget. When a cap is hit, or the plan has no room for the next page of subscribed contacts, the run stops between pages and returns `truncated: true`, `stop_reason` (`max_contacts`, `subrequests`, `time`, `contact_limit`) and `next_hint` with a `cursor`; pass it back as `options.cursor` (with the same `source` and `list_ids`) to continue from that page. Re-runs are idempotent: existing contacts are skipped (`skipped_duplicates`), tags and fields are additive, a suppressed status always wins over subscribed and a stronger suppression reason never downgrades. Kit: tags and signup attribution arrive with each subscriber (`include=tags,attribution`), so any number of tags is mapped in one pass. Mailchimp: members with more than 50 tags have their full tag set fetched separately (a bounded number of such lookups per run; the rest are counted in `tags_truncated`). Brevo: a blacklisted contact costs one extra call to tell a bounce, a complaint and an unsubscribe apart (a bounded number of such lookups per run; the rest are recorded as unsubscribed). `list_ids` are audience ids (Mailchimp, required), group ids (MailerLite), tag ids (Kit), list ids (Brevo), or list ids (EmailOctopus, required); an empty id or no `list_ids` means all subscribers for MailerLite, Kit and Brevo. Credentials are used for this request only and never stored or logged. Requires `contacts:write`. #### Request body See the OpenAPI document for the body schema. #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/imports/run" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d 'null' ``` #### Responses `200` The run finished or stopped cleanly at a cap (`truncated`). Totals cover this run only. ``` { "ok": true, "source": "mailchimp", "imported": 1102, "updated": 3, "skipped_duplicates": 3, "suppressed": 42, "skipped_unconfirmed": 11, "skipped_other": 2, "skipped_suppressed": 0, "invalid": 1, "tags_created": 4, "tags_attached": 1580, "tags_skipped": false, "tags_truncated": 0, "custom_field_keys": [ "company", "consent_ip", "consent_confirmed_at", "consent_source" ], "list_added": 1102, "list_pending_confirmation": 0, "contacts_read": 1161, "contacts_written": 1147, "pages": 2, "subrequests": 2, "db_requests": 31, "duration_ms": 6120, "truncated": false, "stop_reason": null, "next_hint": null, "capabilities": { "platform": "mailchimp" } } ``` `400` Bad request (`source`, `credentials`, `list_ids`, `options.max_contacts`, `options.cursor`), or the platform rejected the key. ``` { "error": "list_ids is required for Mailchimp: pass at least one list id from /api/v1/imports/connect" } ``` `401` No body. `403` No body. `404` `options.list_id` does not match a list in this workspace. ``` { "error": "List not found" } ``` `500` A database write failed part-way. The body carries the totals written so far; earlier pages stay written, so run again. ``` { "error": "Failed to import contacts", "details": "connection reset" } ``` `503` The platform failed part-way (network error or a non-auth error). The body carries the totals written so far. ``` { "error": "MailerLite request failed: GET https://connect.mailerlite.com/api/subscribers: HTTP 500" } ``` ## Tags Labels you attach to contacts. Adding a tag can trigger automations. ### GET /api/v1/tags **List tags.** Returns all tags in the workspace sorted by name. Requires `tags:read`. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/tags" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` All tags. ``` { "tags": [ { "id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Customer", "color": "#2563EB", "created_at": "2026-08-20T09:00:00.000Z" } ] } ``` `401` No body. `403` No body. `500` No body. ### POST /api/v1/tags **Create a tag.** Creates a tag. Names are unique per workspace. `color` defaults to `#6B7280`. Requires `tags:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | | | `color` | string | no | CSS colour, typically a hex code. | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/tags" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "name": "Customer", "color": "#2563EB" }' ``` #### Responses `201` Created. ``` { "tag": { "id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Customer", "color": "#2563EB", "created_at": "2026-08-20T09:00:00.000Z" } } ``` `400` Invalid JSON body or `name is required`. ``` { "error": "name is required" } ``` `401` No body. `403` No body. `409` A tag with this name already exists. ``` { "error": "A tag with this name already exists" } ``` `500` No body. ### GET /api/v1/tags/{id} **Get a tag.** Returns one tag with `contact_count`, the number of contacts carrying it. Requires `tags:read`. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/tags/id" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` The tag. ``` { "tag": null } ``` `401` No body. `403` No body. `404` Tag not found in this workspace. ``` { "error": "Tag not found" } ``` ### PATCH /api/v1/tags/{id} **Update a tag.** Renames or recolours a tag. Empty strings are ignored. Requires `tags:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | | | `color` | string | no | | #### Example request ``` curl -X PATCH "https://sendbeam.io/api/v1/tags/id" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "name": "VIP customer" }' ``` #### Responses `200` Updated. ``` { "tag": { "id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Customer", "color": "#2563EB", "created_at": "2026-08-20T09:00:00.000Z" } } ``` `400` No body. `401` No body. `403` No body. `404` Tag not found. ``` { "error": "Tag not found" } ``` `409` A tag with this name already exists. ``` { "error": "A tag with this name already exists" } ``` `500` No body. ### DELETE /api/v1/tags/{id} **Delete a tag.** Deletes the tag and removes it from every contact. Requires `tags:write`. #### Example request ``` curl -X DELETE "https://sendbeam.io/api/v1/tags/id" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` No body. `401` No body. `403` No body. `404` Tag not found. ``` { "error": "Tag not found" } ``` `500` No body. ## Lists Named groups of contacts. Lists can require double opt-in. ### GET /api/v1/lists **List lists.** Returns every list in the workspace, newest first. Requires `lists:read`. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/lists" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` All lists. ``` { "lists": [ { "id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Newsletter", "description": "Weekly product updates", "double_optin": false, "offerable_across_account": false, "created_at": "2026-08-20T09:00:00.000Z" } ] } ``` `401` No body. `403` No body. `500` No body. ### POST /api/v1/lists **Create a list.** Creates a list. Names are unique per workspace. Set `double_optin: true` to require email confirmation before members are mailed (Free-plan workspaces are double opt-in on every list regardless). Requires `lists:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | | | `description` | string | no | | | `double_optin` | boolean | no | Require members to confirm by email before campaigns reach them. | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/lists" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "name": "Newsletter", "description": "Weekly product updates", "double_optin": false }' ``` #### Responses `201` Created. ``` { "list": { "id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Newsletter", "description": "Weekly product updates", "double_optin": false, "offerable_across_account": false, "created_at": "2026-08-20T09:00:00.000Z" } } ``` `400` Invalid JSON body or `name is required`. ``` { "error": "name is required" } ``` `401` No body. `403` No body. `409` A list with this name already exists. ``` { "error": "A list with this name already exists" } ``` `500` No body. ### GET /api/v1/lists/{id} **Get a list.** Returns one list with its member count. Requires `lists:read`. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/lists/id" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` The list. ``` { "list": null } ``` `401` No body. `403` No body. `404` No body. `500` No body. ### PATCH /api/v1/lists/{id} **Update a list.** Renames a list, changes its description (an empty `description` clears it; an empty `name` is ignored), switches double opt-in on or off (a change to `double_optin` only affects people who join from then on), or marks the list offerable to people who subscribed on your other workspaces (`offerable_across_account` — see the List schema for what that does and does not do). Requires `lists:write`. `offerable_across_account` needs more than the scope: only a signed-in workspace admin of a workspace that belongs to an account may change it, so a request carrying it from an API key, a member seat or a workspace with no account is refused with `403` and nothing else in the body is applied. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | | | `description` | string | no | | | `double_optin` | boolean | no | | | `offerable_across_account` | boolean | no | Offer this list on the preference pages of your other workspaces. Signed-in workspace admin of a workspace on an account only; `403` otherwise. | #### Example request ``` curl -X PATCH "https://sendbeam.io/api/v1/lists/id" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "description": "Monthly digest", "double_optin": true }' ``` #### Responses `200` Updated. ``` { "list": { "id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Newsletter", "description": "Weekly product updates", "double_optin": false, "offerable_across_account": false, "created_at": "2026-08-20T09:00:00.000Z" } } ``` `400` No body. `401` No body. `403` No body. `404` No body. `409` A list with this name already exists. ``` { "error": "A list with this name already exists" } ``` `500` No body. ### DELETE /api/v1/lists/{id} **Delete a list.** Deletes the list and its memberships. Contacts themselves are kept. Requires `lists:write`. #### Example request ``` curl -X DELETE "https://sendbeam.io/api/v1/lists/id" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` No body. `401` No body. `403` No body. `404` No body. `500` No body. ### GET /api/v1/lists/{id}/contacts **List members of a list.** Returns the contacts in a list, most recently added first, with pagination. Each contact carries `added_at`. Requires `lists:read`. | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `page` | query | integer | no | Page number, starting at 1. | | `limit` | query | integer | no | Items per page (1–100). | #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/lists/id/contacts?page=1&limit=50" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` A page of members. ``` { "contacts": [ null ], "pagination": { "page": 1, "limit": 50, "total": 1234, "total_pages": 25 } } ``` `401` No body. `403` No body. `404` No body. `500` No body. ### POST /api/v1/lists/{id}/contacts **Add a contact to a list.** Adds an existing `subscribed` contact to the list. On a double opt-in list — or in a Free-plan workspace, where every list is double opt-in — the membership is created unconfirmed (`membership: "pending_confirmation"`, `confirmed: false`) and the contact is emailed a confirmation link; campaigns to the list skip them until they click it, and `list_joined` automations enrol them at that point. Otherwise the membership is confirmed immediately (`membership: "confirmed"`) and `list_joined` automations targeting this list (or any list) enrol the contact now. `double_optin_sent` says whether a confirmation email went out on this request: confirmations to one address are throttled, so a pending membership can come back with `double_optin_sent: false`. Posting the same contact again while they are still unconfirmed sends the confirmation again (subject to that throttle) and returns `200` with `already_member: true`; a contact who is already a confirmed member is a `409`. A contact whose status is not `subscribed` is refused with `422`. Requires `lists:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `contact_id` | string | yes | | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/lists/id/contacts" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "contact_id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f" }' ``` #### Responses `200` The contact was already an unconfirmed member of this double opt-in list; the confirmation email was sent again (unless one went to this address recently). ``` { "list_contact": { "list_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "contact_id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "created_at": "2026-09-01T10:10:00.000Z", "confirmed": false }, "double_optin_sent": true, "membership": "pending_confirmation", "already_member": true } ``` `201` Added — confirmed at once, or pending the contact's confirmation on a double opt-in list. ``` { "list_contact": { "list_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "contact_id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "created_at": "2026-09-01T10:10:00.000Z", "confirmed": true }, "double_optin_sent": true, "membership": "confirmed", "already_member": true } ``` `400` Invalid JSON body or `contact_id is required`. ``` { "error": "contact_id is required" } ``` `401` No body. `403` No body. `404` `List not found` or `Contact not found`. ``` { "error": "Contact not found" } ``` `409` Contact is already a confirmed member of this list. ``` { "error": "Contact is already in this list" } ``` `422` The contact is not `subscribed` (unsubscribed, bounced or complained contacts cannot join a list). ``` { "error": "Cannot add a contact with status: unsubscribed. Only subscribed contacts can be added to a list." } ``` `500` No body. ### DELETE /api/v1/lists/{id}/contacts **Remove a contact from a list.** Removes the contact from the list. The contact ID is sent in the JSON body. Requires `lists:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `contact_id` | string | yes | | #### Example request ``` curl -X DELETE "https://sendbeam.io/api/v1/lists/id/contacts" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "contact_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" }' ``` #### Responses `200` No body. `400` Invalid JSON body or `contact_id is required`. ``` { "error": "contact_id is required" } ``` `401` No body. `403` No body. `404` `List not found` or `Contact is not in this list`. ``` { "error": "Contact is not in this list" } ``` `500` No body. ### POST /api/v1/lists/{id}/confirmations **Send confirmation emails to pending members.** Sends (or re-sends) the double opt-in confirmation email to members of the list who have not confirmed yet — the deliberate counterpart of a bulk add or an import, which never send one. Only unconfirmed memberships are read, and the token is written only while the row is still unconfirmed, so a member who has already clicked can never be mailed by this. A member emailed anything in the last 24 hours is skipped (`skipped_recent`), a member no longer `subscribed` is skipped (`skipped_not_subscribed`), at most 500 are mailed per request (`remaining` says how many are left; `stopped: "cap"`), and five consecutive provider refusals stop the run (`stopped: "send_failures"` — plan quota, paused workspace, sender problem). Pass `contact_ids` to limit the run to some members. Each email counts against the monthly allowance. Requires `lists:write`. #### Request body (optional) | Field | Type | Required | Description | | --- | --- | --- | --- | | `contact_ids` | array of string | no | Only these members (still only the unconfirmed ones among them). | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/lists/id/confirmations" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "contact_ids": [ "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" ] }' ``` #### Responses `200` What was done. ``` { "list_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "pending": 12, "sent": 10, "skipped_recent": 2, "skipped_not_subscribed": 0, "failed": 0, "remaining": 0, "cap": 500, "stopped": null } ``` `400` Invalid JSON body or `contact_ids` malformed. ``` { "error": "contact_ids must be an array of contact ids" } ``` `401` No body. `403` No body. `404` No body. `422` The list is single opt-in (and the plan does not force double opt-in): there is nothing to confirm. ``` { "error": "This list does not use double opt-in, so there is nothing to confirm." } ``` `500` No body. ## Segments Saved rule sets that select contacts dynamically. ### GET /api/v1/segments **List segments.** Returns every segment, newest first, each with a live `contact_count` (raw rule match) and `reachable_count` (the subscribed subset a campaign could actually reach). Requires `segments:read`. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/segments" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` All segments. ``` { "segments": [ null ] } ``` `401` No body. `403` No body. `500` No body. ### POST /api/v1/segments **Create a segment.** Saves a named rule set. All rules must match (AND). Requires `segments:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | | | `rules` | array of SegmentRule | yes | | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/segments" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "name": "London customers", "rules": [ { "field": "custom_fields.region", "operator": "equals", "value": "London" }, { "field": "status", "operator": "equals", "value": "subscribed" } ] }' ``` #### Responses `201` Created. ``` { "segment": { "id": "2d3e4f5a-6b7c-4d8e-9f0a-1b2c3d4e5f6a", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "London customers", "rules": [ { "field": "custom_fields.region", "operator": "equals", "value": "London" } ], "created_at": "2026-08-25T12:00:00.000Z" } } ``` `400` No body. `401` No body. `403` No body. `500` No body. ### GET /api/v1/segments/{id} **Get a segment.** Returns one segment with the same live `contact_count` and `reachable_count` the list endpoint computes. Requires `segments:read`. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/segments/id" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` The segment. ``` { "segment": null } ``` `401` No body. `403` No body. `404` Segment not found in this workspace. ``` { "error": "Segment not found" } ``` ### PATCH /api/v1/segments/{id} **Update a segment.** Renames a segment and/or replaces its rules. An empty `rules` array is ignored. Requires `segments:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | | | `rules` | array of SegmentRule | no | | #### Example request ``` curl -X PATCH "https://sendbeam.io/api/v1/segments/id" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "name": "string", "rules": [ { "field": "custom_fields.region", "operator": "equals", "value": "London" } ] }' ``` #### Responses `200` Updated. ``` { "segment": { "id": "2d3e4f5a-6b7c-4d8e-9f0a-1b2c3d4e5f6a", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "London customers", "rules": [ { "field": "custom_fields.region", "operator": "equals", "value": "London" } ], "created_at": "2026-08-25T12:00:00.000Z" } } ``` `401` No body. `403` No body. `404` Segment not found. ``` { "error": "Segment not found" } ``` `500` No body. ### DELETE /api/v1/segments/{id} **Delete a segment.** Deletes the segment. Returns `204` with no body. Requires `segments:write`. #### Example request ``` curl -X DELETE "https://sendbeam.io/api/v1/segments/id" \ -H "x-api-key: sb_live_…" ``` #### Responses `204` Deleted. No body. `401` No body. `403` No body. `404` Segment not found. ``` { "error": "Segment not found" } ``` `500` No body. ### POST /api/v1/segments/preview **Preview rules before saving.** Counts the contacts matching an ad-hoc rule set and returns up to 5 sample contacts. A read: it is exempt from the Pro-plan write gate. Requires `segments:read`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `rules` | array of SegmentRule | yes | | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/segments/preview" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "rules": [ { "field": "email", "operator": "contains", "value": "@example.com" } ] }' ``` #### Responses `200` No body. `400` No body. `401` No body. `403` No body. `500` No body. ### GET /api/v1/segments/{id}/preview **Preview a saved segment.** Counts the contacts matching a saved segment and returns up to 5 sample contacts. Requires `segments:read`. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/segments/id/preview" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` No body. `401` No body. `403` No body. `404` Segment not found. ``` { "error": "Segment not found" } ``` `500` No body. ## Campaigns One-off email sends to a list, a segment or everyone. ### GET /api/v1/campaigns **List campaigns.** Returns campaigns, newest first, with pagination. Optionally filter by status. Requires `campaigns:read`. | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `page` | query | integer | no | Page number, starting at 1. | | `limit` | query | integer | no | Items per page (1–100). | | `status` | query | CampaignStatus | no | | #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/campaigns?page=1&limit=50" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` A page of campaigns. ``` { "campaigns": [ { "id": "c3d4e5f6-7a8b-4c9d-8e0f-1a2b3c4d5e6f", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "September newsletter", "subject": "What's new this month", "from_name": "Acme", "from_email": "hello@acme.com", "html_content": "

Hello {{first_name}}

", "text_content": "Hello {{first_name}}", "status": "draft", "send_to_type": "list", "send_to_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "scheduled_at": null, "sent_at": null, "stats_sent": 0, "stats_delivered": 0, "stats_opened": 0, "stats_clicked": 0, "stats_bounced": 0, "stats_unsubscribed": 0, "created_at": "2026-09-01T11:00:00.000Z" } ], "pagination": { "page": 1, "limit": 50, "total": 1234, "total_pages": 25 } } ``` `401` No body. `403` No body. `500` No body. ### POST /api/v1/campaigns **Create a campaign.** Creates a draft campaign. `send_to_type` chooses the audience: `all` (every subscribed contact), `list` or `segment` (then `send_to_id` must reference one this workspace owns). A draft may be created without a `subject` (the campaign wizard saves as soon as there is a name); the send endpoint refuses a campaign whose subject is still empty. `html_content` is limited to 500,000 characters and `text_content` to 200,000 (`413`). Send a `draft_key` of your own (a UUID) to make the create idempotent: a second create with the same key returns the existing draft with `200` instead of making another. Nothing is sent until you call the send endpoint. Requires `campaigns:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `draft_key` | string | no | Optional idempotency key (a UUID, or up to 64 letters, digits, `-`, `_`), unique per workspace. A repeat create with the same key returns the existing draft (`200`). | | `language` | string \| null | no | ISO 639-1 code of the language the campaign's own subject and body are written in (#80). Null clears it. | | `languages` | object \| null | no | Translations keyed by ISO 639-1 code, up to five, each with its own `subject` and `html_content` (optional `text_content`, `blocks`). A contact whose `language` matches a key receives that version; everyone else the campaign's own. Keys must differ from `language`. Cannot be combined with `ab_test` (`400`). Null clears them. | | `subject_b` | string | no | A second subject line to test against `subject` — the two-version shape. Setting it (with `ab_test`) arms a subject-line test; `null` removes a subject test. Ignored when `ab_test.variants` is given. | | `send_pace_hours` | integer \| null | no | Spread the send over this many hours (see Campaign); null for the workspace default. | | `send_at_best_time` | boolean | no | Send each contact at their usual open hour (see Campaign). Not with `ab_test`. | | `ab_test` | object | no | A/B test settings. Either `subject_b` plus these settings (a two-version subject test), or `test_on` plus `variants` — up to four extra versions (B–E) of the subject line, the from name or the email content; version A is the campaign itself. On send, `sample_pct` of the audience is split evenly between the versions, and after `wait_minutes` the version with the better rate on `metric` goes to everyone else. An audience under two recipients per version sends plain with version A (state `skipped`). `null` removes the test. | | `name` | string | yes | | | `subject` | string | no | May be omitted or empty on a draft; required to send. | | `from_name` | string | no | | | `from_email` | string | yes | Stored with the campaign. Delivery uses the workspace's saved sender details, which must be the workspace's own shared address or an address on a sending domain it has verified; that check runs on every send. | | `html_content` | string | no | Supports `{{first_name}}`, `{{last_name}}`, `{{email}}`, `{{custom_fields.}}`, `{{workspace_name}}` (the workspace's name — the sender's business), `{{sender_name}}` (the From name), `{{unsubscribe_url}}` and `{{web_version_url}}` merge tags, each with an optional fallback after a pipe — `{{first_name\|there}}` — used when the contact has no value. Write `\\|` for a literal pipe in the fallback. Conditional content: `{{#if custom_fields.plan is "pro"}}…{{else}}…{{/if}}`, with the operators is, is not, contains, does not contain, starts with, ends with, is set, is not set, is greater than and is less than, nesting one level. A tag or condition that does not resolve is sent exactly as typed. At most 500,000 characters. | | `text_content` | string | no | The same merge tags and conditional blocks as `html_content`, inserted unescaped. At most 200,000 characters. | | `blocks` | array of BuilderBlock | no | The visual builder's block JSON, when `html_content` was rendered from it. Optional; `null` or absent means HTML only. | | `send_to_type` | SendToType | yes | | | `send_to_id` | string \| null | no | Required when `send_to_type` is `list` or `segment`. | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/campaigns" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "draft_key": "string", "language": "en", "languages": { "fr": { "subject": "Nouveautés de septembre", "html_content": "

Bonjour…

" } }, "subject_b": "string", "send_pace_hours": null, "send_at_best_time": true, "ab_test": { "sample_pct": 20, "wait_minutes": 120, "metric": "opens", "test_on": "subject", "variants": [ { "subject": "string", "from_name": "string", "html_content": "string", "text_content": "string", "blocks": [], "send_offset_minutes": 60 } ] }, "name": "September newsletter", "subject": "What'\''s new this month", "from_name": "Acme", "from_email": "hello@acme.com", "html_content": "

Hello {{first_name}}

", "text_content": "Hello {{first_name}}", "blocks": [ { "id": "block_1", "type": "text", "props": { "heading": "This month", "text": "Hi {{first_name|there}},", "padding": 24 } } ], "send_to_type": "all", "send_to_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d" }' ``` #### Responses `200` A draft with this `draft_key` already exists in the workspace; it is returned unchanged. ``` { "campaign": { "id": "c3d4e5f6-7a8b-4c9d-8e0f-1a2b3c4d5e6f", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "September newsletter", "subject": "What's new this month", "from_name": "Acme", "from_email": "hello@acme.com", "html_content": "

Hello {{first_name}}

", "text_content": "Hello {{first_name}}", "status": "draft", "send_to_type": "list", "send_to_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "scheduled_at": null, "sent_at": null, "stats_sent": 0, "stats_delivered": 0, "stats_opened": 0, "stats_clicked": 0, "stats_bounced": 0, "stats_unsubscribed": 0, "created_at": "2026-09-01T11:00:00.000Z" }, "existing": true } ``` `201` Created draft. ``` { "campaign": { "id": "c3d4e5f6-7a8b-4c9d-8e0f-1a2b3c4d5e6f", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "September newsletter", "subject": "What's new this month", "from_name": "Acme", "from_email": "hello@acme.com", "html_content": "

Hello {{first_name}}

", "text_content": "Hello {{first_name}}", "status": "draft", "send_to_type": "list", "send_to_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "scheduled_at": null, "sent_at": null, "stats_sent": 0, "stats_delivered": 0, "stats_opened": 0, "stats_clicked": 0, "stats_bounced": 0, "stats_unsubscribed": 0, "created_at": "2026-09-01T11:00:00.000Z" } } ``` `400` Invalid JSON body, `Campaign name is required.`, `From email is required.`, `Invalid send_to_type. Must be all, list, or segment.`, `A list must be selected.` / `A segment must be selected.`, or a malformed `draft_key`. ``` { "error": "Campaign name is required." } ``` `401` No body. `403` No body. `404` No body. `413` No body. `500` Failed to create campaign. ``` { "error": "Failed to create campaign." } ``` ### GET /api/v1/campaigns/{id} **Get a campaign.** Returns one campaign with a `stats` object computed from its per-recipient send records. Requires `campaigns:read`. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/campaigns/id" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` The campaign. ``` { "campaign": null } ``` `401` No body. `403` No body. `404` No body. `500` No body. ### PATCH /api/v1/campaigns/{id} **Update a campaign.** Updates a `draft` or `scheduled` campaign. Only the listed fields are accepted; others are ignored. `name` may not be emptied; `subject` may be empty on a draft but not on a scheduled campaign. When `send_to_type` is `list` or `segment`, `send_to_id` must be supplied in the same request and belong to this workspace. `blocks` (the visual builder's block JSON) is stored when sent; sending new `html_content` without `blocks` clears them. To avoid overwriting a concurrent edit, send `expected_updated_at` — the `updated_at` you last read; if the campaign has changed since, nothing is written and the answer is `409` with `code: "stale"` and the current `updated_at`. Requires `campaigns:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `expected_updated_at` | string \| null | no | The `updated_at` you last read. When the campaign has changed since, the update is refused with `409` `code: "stale"`. Omit or send `null` to skip the check. | | `name` | string | no | May not be emptied. | | `subject` | string | no | May be empty on a draft, not on a scheduled campaign. | | `from_name` | string | no | | | `from_email` | string | no | | | `html_content` | string | no | At most 500,000 characters (`400` otherwise). | | `text_content` | string | no | At most 200,000 characters (`400` otherwise). | | `send_to_type` | SendToType | no | | | `send_to_id` | string \| null | no | | #### Example request ``` curl -X PATCH "https://sendbeam.io/api/v1/campaigns/id" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "subject": "What'\''s new in September", "send_to_type": "all", "send_to_id": null }' ``` #### Responses `200` Updated. ``` { "campaign": { "id": "c3d4e5f6-7a8b-4c9d-8e0f-1a2b3c4d5e6f", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "September newsletter", "subject": "What's new this month", "from_name": "Acme", "from_email": "hello@acme.com", "html_content": "

Hello {{first_name}}

", "text_content": "Hello {{first_name}}", "status": "draft", "send_to_type": "list", "send_to_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "scheduled_at": null, "sent_at": null, "stats_sent": 0, "stats_delivered": 0, "stats_opened": 0, "stats_clicked": 0, "stats_bounced": 0, "stats_unsubscribed": 0, "created_at": "2026-09-01T11:00:00.000Z" } } ``` `400` Invalid JSON body, `No valid fields to update.`, `Campaign name is required.`, `A scheduled campaign needs a subject line.`, `Invalid send_to_type.`, `A list must be selected.` / `A segment must be selected.`, `html_content is too large (max 500,000 characters).`, or `text_content is too large (max 200,000 characters).` ``` { "error": "Contact not found" } ``` `401` No body. `403` No body. `404` `Campaign not found.` or `The selected list or segment was not found.` ``` { "error": "Campaign not found." } ``` `409` Campaign is not editable, or (`code: "stale"`) it changed since the `expected_updated_at` you sent — re-read it and try again. ``` { "error": "string", "code": "stale", "updated_at": "2026-09-02T12:00:00Z" } ``` `500` Failed to update campaign. ``` { "error": "Failed to update campaign." } ``` ### DELETE /api/v1/campaigns/{id} **Delete a draft or cancelled campaign.** Removes a campaign that never went out. Sent and sending campaigns are kept as records (`409`); cancel a scheduled campaign first. Requires `campaigns:write`. #### Example request ``` curl -X DELETE "https://sendbeam.io/api/v1/campaigns/id" \ -H "x-api-key: sb_live_…" ``` #### Responses `204` Deleted. No body. `401` No body. `403` No body. `404` Campaign not found. ``` { "error": "Campaign not found" } ``` `409` The campaign is scheduled, sending or sent. ``` { "error": "Only draft or cancelled campaigns can be deleted; this one is sent." } ``` ### POST /api/v1/campaigns/{id}/send **Send or schedule a campaign.** With a `scheduled_at` in the future the campaign becomes `scheduled` and is sent by the scheduler at that time (200). Without a body (or without `scheduled_at`) the audience is resolved now — subscribed contacts only; on a double opt-in list only confirmed members; on the Free plan only contacts who have confirmed a subscription, whatever the audience — the plan's monthly email quota is checked for the whole audience, recipients are queued and the campaign becomes `sending` (202). Delivery drains in the background, fairly across every sending campaign and within the plan's hourly ceiling (what does not fit waits for the next hour); the workspace's sender address is re-validated on every send, rows that cannot be sent are recorded as failed with the reason, and the campaign flips to `sent` when the queue is empty. Only `draft` or `scheduled` campaigns can be sent. `GET /api/v1/campaigns/{id}/audience` returns the recipient count and the plan check the send will apply, without sending. Requires `campaigns:send` — authoring a campaign (`campaigns:write`) does not permit mailing its audience. #### Request body (optional) | Field | Type | Required | Description | | --- | --- | --- | --- | | `scheduled_at` | string | no | ISO 8601 timestamp in the future. Omit to send immediately. | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/campaigns/id/send" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "scheduled_at": "2026-09-10T09:00:00Z" }' ``` #### Responses `200` Scheduled. ``` { "id": "c3d4e5f6-7a8b-4c9d-8e0f-1a2b3c4d5e6f", "status": "scheduled", "scheduled_at": "2026-09-10T09:00:00.000Z" } ``` `202` Queued for immediate delivery. ``` { "id": "c3d4e5f6-7a8b-4c9d-8e0f-1a2b3c4d5e6f", "status": "sending", "queued": 1834, "skipped_placeholder": 0 } ``` `400` `scheduled_at must be a valid future date.` or `No subscribed contacts found for this audience.` ``` { "error": "No subscribed contacts found for this audience." } ``` `401` No body. `403` Missing permission, plan write gate, sending paused for this workspace, or the audience would exceed the plan's monthly email quota. ``` { "error": "Contact not found" } ``` `404` No body. `409` Campaign is not in `draft` or `scheduled` state. ``` { "error": "Cannot send a campaign with status 'sent'." } ``` `500` `Failed to schedule campaign.` or `Failed to create send queue.` ``` { "error": "Failed to create send queue." } ``` ### GET /api/v1/campaigns/{id}/audience **Preview a campaign's audience.** Returns how many contacts the campaign would reach if it were sent now, computed by exactly the resolver the send endpoint and the scheduler use: `subscribed` contacts only; on a double opt-in list only confirmed members; on the Free plan only contacts who have confirmed a subscription, whatever the audience. `can_send` says whether `POST /api/v1/campaigns/{id}/send` would be accepted right now (campaign is `draft` or `scheduled`, at least one recipient, sending not paused, and the audience fits the plan's remaining monthly email quota); when it is false, `reason` is the message the send would fail with. `placeholders` names subscribed contacts that can never receive mail — addresses on `example.com` / `.net` / `.org`, on a reserved `.test`, `.invalid`, `.localhost` or `.example` domain, or `abuse@` / `postmaster@`. The send leaves them out (the same rule the import applies), so they are not in `recipients`; they are named so they can be archived or deleted. `placeholder_count` is the total when more than twenty are named. Nothing is changed and nothing is sent. The audience is resolved again at send time, so a count taken earlier can differ if contacts join, leave, unsubscribe or confirm in between. Requires `campaigns:read`. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/campaigns/id/audience" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` The audience as it stands now. ``` { "audience": { "send_to_type": "all", "send_to_id": null, "recipients": 1 }, "can_send": true, "reason": "string", "placeholders": [ { "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "email": "string", "kind": "example_domain", "reason": "string" } ], "placeholder_count": 1 } ``` `401` No body. `403` No body. `404` No body. `500` No body. ### POST /api/v1/campaigns/{id}/test **Send a test copy.** Sends the campaign as a test — to the signed-in person by default, or to `to` (a list of addresses, or one comma-separated string), each copy with `[Test]` in front of the subject, the same From, merge tags and footer as the real send, and a working view-in-browser link. With `contact_id` (a contact of this workspace) the merge tags are rendered with that contact's name, email and custom fields while the copies still go to `to`; the unsubscribe link in a test is bound to the test, never to a real contact. Test copies are not written to the sends ledger, do not count against the plan and do not appear in the campaign's stats. The number of addresses per test is capped per plan, and there is an hourly allowance of test emails per workspace. Session only: the default recipient is the signed-in person, so an API key gets `400`. Requires `campaigns:write` (and `contacts:read` with `contact_id`). #### Request body (optional) | Field | Type | Required | Description | | --- | --- | --- | --- | | `to` | array of string \| string | no | Recipients. Defaults to the signed-in person. | | `contact_id` | string \| null | no | Render the copy as this contact. | | `language` | string \| null | no | Send this language version (a code the campaign is written in, or one of its `languages`); a code the campaign has no version for is a 400. Default: the chosen contact's language, else the campaign's own. | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/campaigns/id/test" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "to": [ "jane@example.com" ], "contact_id": null, "language": null }' ``` #### Responses `200` What went out. ``` { "ok": true, "to": [ "me@acme.com", "colleague@acme.com" ], "failed": [], "subject": "[Test] What's new this month", "rendered_as": { "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "email": "jane@client.com" }, "warnings": [] } ``` `400` Not a session, an address that is not one, more addresses than the plan allows, a malformed `contact_id`, a campaign with no content yet, or a workspace whose sender address is not one it may send as (the campaign's own from address is used when it is allowed, otherwise the workspace sender — the same rule as the real send). ``` { "error": "colleague@ is not a valid email address" } ``` `401` No body. `403` No body. `404` `Campaign not found.` or `Contact not found.` ``` { "error": "Contact not found." } ``` `429` The workspace's hourly test-email allowance is used up. ``` { "error": "Contact not found" } ``` `503` The provider accepted no copy; `failed` names each address and reason. ``` { "error": "Contact not found" } ``` ### POST /api/v1/campaigns/preview **Render an email for one contact.** Resolves the merge tags in `subject`, `html` and `text` exactly as a send would, for the contact named by `contact_id` (one of this workspace's) or, without one, for a stand-in built from the signed-in person (empty fields for an API key). `{{web_version_url}}` becomes the campaign's plain web-version page when `campaign_id` is given; `{{unsubscribe_url}}` is a dead link — a preview can never act on the contact. Conditional blocks (`{{#if custom_fields.plan is "pro"}}…{{else}}…{{/if}}`) in `html` and `text` are evaluated for that contact; a subject line never carries one and is returned with its block tags as typed. Nothing is stored or sent. Same size caps as campaign creation (`413`). Requires `campaigns:read`, plus `contacts:read` with `contact_id`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `subject` | string | no | | | `html` | string | no | | | `text` | string | no | | | `contact_id` | string \| null | no | | | `campaign_id` | string \| null | no | | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/campaigns/preview" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "subject": "string", "html": "string", "text": "string", "contact_id": null, "campaign_id": null }' ``` #### Responses `200` The rendered parts. ``` { "subject": "Hi Jane", "html": "

Your plan: Gold

", "text": "", "rendered_as": { "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "email": "jane@client.com", "name": "Jane Doe" } } ``` `400` Invalid JSON body, nothing to render, or a malformed `contact_id` / `campaign_id`. ``` { "error": "Contact not found" } ``` `401` No body. `403` No body. `404` `Contact not found.` or `Campaign not found.` ``` { "error": "Contact not found." } ``` `413` No body. ### POST /api/v1/campaigns/audience **Preview an audience before saving a campaign.** The same answer as `GET /api/v1/campaigns/{id}/audience` for an audience that is not saved yet: send `send_to_type` (`all`, `list` or `segment`) and, for a list or segment, its `send_to_id`. The campaign wizard's Review step calls this before the draft exists. `can_send` is evaluated as for a draft. Nothing is changed and nothing is sent. Requires `campaigns:read`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `send_to_type` | SendToType | yes | | | `send_to_id` | string \| null | no | Required for `list` and `segment`; ignored for `all`. | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/campaigns/audience" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "send_to_type": "list", "send_to_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d" }' ``` #### Responses `200` The audience as it stands now. ``` { "audience": { "send_to_type": "all", "send_to_id": null, "recipients": 1 }, "can_send": true, "reason": "string", "placeholders": [ { "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "email": "string", "kind": "example_domain", "reason": "string" } ], "placeholder_count": 1 } ``` `400` Invalid JSON body, `send_to_type must be one of all, list, segment`, or `send_to_id is required for a audience`. ``` { "error": "send_to_id is required for a list audience" } ``` `401` No body. `403` No body. `500` No body. ### POST /api/v1/campaigns/{id}/cancel **Cancel a scheduled campaign, or stop one that is sending.** Moves a `scheduled` campaign to `cancelled` so it will not be sent. A `sending` campaign — one going out over a day at each contact's best time, or a send-time test with a version still to come — is stopped instead: the recipients not yet sent are removed, `status_reason` records how many, and the campaign completes as `sent` within the minute (the response carries `status: sending` and `stopped`). Campaigns in any other state answer `409`. Requires `campaigns:write`. #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/campaigns/id/cancel" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` Cancelled. ``` { "id": "c3d4e5f6-7a8b-4c9d-8e0f-1a2b3c4d5e6f", "status": "cancelled" } ``` `401` No body. `403` No body. `404` No body. `409` Not scheduled. ``` { "error": "Only scheduled campaigns can be cancelled. This campaign has status 'draft'." } ``` `500` Failed to cancel campaign. ``` { "error": "Failed to cancel campaign." } ``` ### POST /api/v1/campaigns/subject-ideas **Suggest subject lines.** Five alternative subject lines written from the current subject and the email's text (the first ~1,500 characters, merge tags removed). Nothing is stored. There is a daily allowance per workspace. `503` when the installation has no suggestion service configured. Requires `campaigns:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `subject` | string | no | | | `html_content` | string | no | | | `audience` | string | no | A label for who receives it, e.g. the list name. | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/campaigns/subject-ideas" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "subject": "string", "html_content": "string", "audience": "string" }' ``` #### Responses `200` Suggestions. ``` { "ideas": [ { "subject": "Three things you asked for, now live", "why": "direct, names the payoff" } ] } ``` `400` Neither a subject nor content was given. ``` { "error": "Give a subject or some email content to work from." } ``` `401` No body. `403` No body. `429` The plan's daily allowance of drafts is used. ``` { "error": "Contact not found" } ``` `503` Not enabled on this installation, the service is busy, or no usable suggestions came back. ``` { "error": "Contact not found" } ``` ### GET /api/v1/campaigns/{id}/report **Links clicked, email clients and poll answers.** Per-link click counts (`clicks` = every click, `unique` = distinct recipients), the email clients and devices behind the human opens, and the answers to any poll block in the email, all from the tracked open and click events. Machine opens and clicks (link scanners, mail-client prefetchers) are excluded. Gmail and Yahoo proxy images, so their device is reported as `Unknown`. `polls` holds one entry per poll block, in the email's order — its `id` is the block's, `question` and the option `label`s come from the campaign's block JSON (a blank question and `Option n` labels when the campaign has none) — with a `count` per option and `answers`, the number of recipients who answered. One answer per recipient: a person who clicks twice is counted once, for their latest click. Poll answers count in the top-level `clicks` but are not listed under `links`. `revenue` — the orders your store sent to SendBeam that were attributed to this campaign (see /docs/ecommerce/revenue) — is present only when there is at least one such order: a campaign that earned nothing and a campaign whose store sends no orders are different answers. Requires `campaigns:read`. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/campaigns/id/report" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` The report. ``` { "campaign_id": "2f9c1b1e-3f9e-4a3b-9c2f-1d1e2f3a4b5c", "links": [ { "url": "https://example.com/post", "clicks": 41, "unique": 33 } ], "clients": [ { "name": "Gmail", "count": 120 }, { "name": "Apple Mail", "count": 64 } ], "devices": [ { "name": "Unknown", "count": 120 }, { "name": "Mobile", "count": 50 }, { "name": "Desktop", "count": 14 } ], "opens": 184, "clicks": 63, "polls": [ { "id": "block_1758540000000_k3j9x2", "question": "How useful was this email?", "answers": 22, "options": [ { "index": 1, "label": "Very useful", "count": 14 }, { "index": 2, "label": "Somewhat", "count": 6 }, { "index": 3, "label": "Not really", "count": 2 } ] } ], "revenue": { "orders": 12, "total": 1240.5, "currency": "GBP", "by_currency": [ { "currency": "GBP", "total": 1240.5, "orders": 12 } ] } } ``` `401` No body. `403` No body. `404` Campaign not found. ``` { "error": "Campaign not found" } ``` ### GET /api/v1/campaigns/{id}/ab-test **A/B test state and live counts.** The test settings and state stored on the campaign, what it varies (`test_on`), each version (`versions[]`: `letter`, `label`, and the `subject`, `from_name`, or `send_offset_minutes` + `release_at` it tests) and live per-version counts from the send queue (`live.a`, `live.b`, … : `sent`, `opened`, `clicked`, and — once an order your store sent in has been attributed to that version — `revenue`, `orders`, `currency`). Requires `campaigns:read`. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/campaigns/id/ab-test" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` Test state. ``` { "ab_test": {}, "test_on": "subject", "subject": "string", "subject_b": "string", "versions": [ { "letter": "a", "label": "string", "subject": "string", "from_name": "string" } ], "live": {} } ``` `401` No body. `403` No body. `404` Campaign not found, or it has no A/B test. ``` { "error": "This campaign has no A/B test" } ``` ### POST /api/v1/campaigns/{id}/ab-test **End an A/B test now.** Settles a running test straight away: with `{"winner": "a" | "b" | … }` that version goes to the remaining recipients (it must be one of the versions the test sent); with no body the version with the better rate on the test's metric wins — opens or clicks per recipient, or for a `revenue` test the most attributed revenue per recipient (ties, including no orders at all, go to the earlier letter). The queue worker does the same automatically once `decide_at` passes. Requires `campaigns:write`. #### Request body (optional) | Field | Type | Required | Description | | --- | --- | --- | --- | | `winner` | "a" \| "b" \| "c" \| "d" \| "e" | no | | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/campaigns/id/ab-test" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "winner": "a" }' ``` #### Responses `200` The decided state. ``` { "ab_test": {} } ``` `400` `winner` is not a version letter, or not one of this test's versions, or `Invalid JSON body`. ``` { "error": "winner must be one of \"a\", \"b\", \"c\", \"d\", \"e\"" } ``` `401` No body. `403` No body. `404` Campaign not found. ``` { "error": "Campaign not found" } ``` `409` The campaign is not running a test (not sent yet, already decided, skipped, or no test) — or it is a send-time test whose later versions have not all gone out yet and no `winner` was named (`Not every send time has gone out yet — wait, or choose a version yourself`). ``` { "error": "This campaign is not running an A/B test" } ``` ### POST /api/v1/campaigns/{id}/duplicate **Duplicate a campaign.** Creates a new draft copying the content and audience of an existing campaign. The copy is named ` (Copy)`. With the optional body `{"audience": "non_openers"}` on a sent campaign, the copy is named ` (Send again to non-openers)` and its audience is a segment called `Did not open: ` with the single rule `campaign not_opened ` — the people the original reached who have not opened it, re-evaluated at send time; the segment is reused when the same campaign is sent again later. Requires `campaigns:write`. #### Request body (optional) | Field | Type | Required | Description | | --- | --- | --- | --- | | `audience` | "non_openers" | no | Aim the copy at the people who received the original and did not open it. Only valid on a sent campaign. | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/campaigns/id/duplicate" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "audience": "non_openers" }' ``` #### Responses `201` The new draft. ``` { "campaign": { "id": "c3d4e5f6-7a8b-4c9d-8e0f-1a2b3c4d5e6f", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "September newsletter", "subject": "What's new this month", "from_name": "Acme", "from_email": "hello@acme.com", "html_content": "

Hello {{first_name}}

", "text_content": "Hello {{first_name}}", "status": "draft", "send_to_type": "list", "send_to_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "scheduled_at": null, "sent_at": null, "stats_sent": 0, "stats_delivered": 0, "stats_opened": 0, "stats_clicked": 0, "stats_bounced": 0, "stats_unsubscribed": 0, "created_at": "2026-09-01T11:00:00.000Z" } } ``` `400` `Invalid JSON body`, `audience must be "non_openers" when given`, or `Only a sent campaign has non-openers to send to again`. ``` { "error": "Only a sent campaign has non-openers to send to again" } ``` `401` No body. `403` No body. `404` Campaign not found. ``` { "error": "Campaign not found" } ``` `500` Failed to duplicate campaign. ``` { "error": "Failed to duplicate campaign" } ``` ## Assistants Drafts written from a brief for a person to review. Behind a feature flag; nothing is sent or saved by an assistant. ### POST /api/v1/ai/email-draft **Draft an email from a brief.** A subject line and a list of builder blocks written from the brief — the same `blocks` shape a campaign or template takes — for the editor to load and a person to edit. The prompt carries the brief and the workspace's custom-field names only; nothing is sent or saved. Behind the ai-assist flag (`404` when it is off for the workspace). A daily allowance per workspace is shared by the assistants (`429`). Requires `campaigns:write` or `templates:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `brief` | string | yes | Who it is for, what it says, what the reader should do. | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/ai/email-draft" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "brief": "string" }' ``` #### Responses `200` The draft. ``` { "subject": "string", "name": "string", "blocks": [ { "id": "block_1", "type": "text", "props": { "heading": "This month", "text": "Hi {{first_name|there}},", "padding": 24 } } ], "warnings": [ "string" ], "model": "string" } ``` `400` No brief, or too long a one. ``` { "error": "Describe the email you want: who it is for, what it says, and what the reader should do." } ``` `401` No body. `403` No body. `429` The plan's daily allowance of drafts is used. ``` { "error": "Contact not found" } ``` `503` No model could answer, or the draft came back unusable. ``` { "error": "Contact not found" } ``` ### POST /api/v1/ai/automation-draft **Draft an automation from a brief.** A recipe — the same shape the recipe gallery uses: a trigger, a linear sequence of send_email / wait / add_tag / remove_tag steps, and a placeholder for every list, form or tag the draft names — for a person to resolve and import. Nothing is created here. Behind the ai-assist flag (`404` when off); the assistants' shared daily allowance (`429`). Requires `automations:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `brief` | string | yes | | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/ai/automation-draft" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "brief": "string" }' ``` #### Responses `200` The drafted recipe, with its outline. ``` { "recipe": {}, "model": "string" } ``` `400` No brief. ``` { "error": "Contact not found" } ``` `401` No body. `403` No body. `429` The plan's daily allowance of drafts is used. ``` { "error": "Contact not found" } ``` `503` No model could answer, or the draft came back unusable. ``` { "error": "Contact not found" } ``` ### POST /api/v1/ai/automation-draft/import **Import a drafted automation.** Imports a recipe returned by the draft route as a draft automation, the way a gallery recipe is imported: `choices` names, per placeholder, an existing id (`{ "existing": id }`), a name to create (`{ "create": name }`) or `{ "later": true }`. The recipe is rebuilt from a whitelist before it is trusted. Requires `automations:write`, plus the write permission for anything a choice would create. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `recipe` | object | yes | | | `choices` | object | no | | | `name` | string | no | | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/ai/automation-draft/import" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "recipe": {}, "choices": {}, "name": "string" }' ``` #### Responses `201` The draft automation, what was created on the way, and the placeholders left for the builder. ``` { "automation": { "id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Welcome series", "description": "Three emails over a week", "trigger_type": "contact_created", "trigger_config": {}, "status": "active", "created_at": "2026-08-26T08:00:00.000Z" }, "created": {}, "unresolved": [ "string" ] } ``` `400` The draft is not in an importable shape, or a choice is wrong. ``` { "error": "Contact not found" } ``` `401` No body. `403` No body. `500` No body. ## Templates Reusable email designs. ### GET /api/v1/templates **List templates.** Returns templates, newest first, optionally filtered by category. Requires `templates:read`. | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `category` | query | TemplateCategory | no | | #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/templates?category=welcome" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` Templates. ``` { "templates": [ { "id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Welcome email", "subject": "Welcome, {{first_name}}!", "html_content": "

Hi {{first_name}}

", "category": "welcome", "created_at": "2026-08-20T09:00:00.000Z" } ] } ``` `401` No body. `403` No body. `500` No body. ### POST /api/v1/templates **Create a template.** Creates a template. An unknown or missing `category` becomes `custom`. `html_content` is limited to 500,000 characters (`413`). Send `blocks` — the visual builder's block JSON — alongside `html_content` rendered from it and the template reopens in the visual builder; leave it out or send `null` for an HTML-only template. At most 200 blocks / 200,000 characters of block JSON (`400`). Requires `templates:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | | | `subject` | string | yes | | | `html_content` | string | yes | | | `category` | TemplateCategory | no | | | `blocks` | array of BuilderBlock | no | | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/templates" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "name": "Welcome email", "subject": "Welcome to {{first_name}}!", "html_content": "

Hi {{first_name}}

Thanks for joining.

", "category": "welcome", "blocks": [ { "id": "block_1", "type": "text", "props": { "heading": "This month", "text": "Hi {{first_name|there}},", "padding": 24 } } ] }' ``` #### Responses `201` Created. ``` { "template": { "id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Welcome email", "subject": "Welcome, {{first_name}}!", "html_content": "

Hi {{first_name}}

", "category": "welcome", "created_at": "2026-08-20T09:00:00.000Z" } } ``` `400` Invalid JSON body, `name is required`, `subject is required`, or `html_content is required`. ``` { "error": "html_content is required" } ``` `401` No body. `403` No body. `413` No body. `500` No body. ### GET /api/v1/custom-blocks **List custom blocks.** The workspace's own email blocks, by name. Requires `templates:read`. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/custom-blocks" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` Custom blocks. ``` { "custom_blocks": [ { "id": "b7c8d9e0-f1a2-4b3c-8d4e-5f6a7b8c9d0e", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Hero", "description": "Headline, one line and a button on the brand colour.", "mjml": "[[headline]][[cta_label]]", "html": "
…[[headline]]…
", "head_html": "", "slots": [ { "key": "headline", "type": "text", "label": "Headline", "default": "Big news" }, { "key": "cta", "type": "url", "label": "Button link", "default": "https://acme.test" }, { "key": "cta_label", "type": "text", "label": "Button text", "default": "Read more" } ], "created_at": "2026-09-26T09:00:00.000Z", "updated_at": "2026-09-26T09:00:00.000Z" } ] } ``` `401` No body. `403` No body. `500` No body. ### POST /api/v1/custom-blocks **Create a custom block.** Compiles the MJML once and stores the block. Mark what a marketer may change as slots: `[[headline]]` for text, `[[cta:url]]` for a link, `[[hero:image]]` for an image. `slots` may set a label and a default per key; keys and types always come from the markup. `400` names the problem when the MJML does not compile; soft findings (an attribute MJML ignored) come back as `warnings` on a `201`. `mjml` is limited to 100,000 characters (`413`). Requires `templates:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | | | `description` | string | no | | | `mjml` | string | yes | | | `slots` | array of object | no | | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/custom-blocks" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "name": "Hero", "description": "string", "mjml": "string", "slots": [ { "key": "string", "label": "string", "default": "string" } ] }' ``` #### Responses `201` Created. ``` { "custom_block": { "id": "b7c8d9e0-f1a2-4b3c-8d4e-5f6a7b8c9d0e", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Hero", "description": "Headline, one line and a button on the brand colour.", "mjml": "[[headline]][[cta_label]]", "html": "
…[[headline]]…
", "head_html": "", "slots": [ { "key": "headline", "type": "text", "label": "Headline", "default": "Big news" }, { "key": "cta", "type": "url", "label": "Button link", "default": "https://acme.test" }, { "key": "cta_label", "type": "text", "label": "Button text", "default": "Read more" } ], "created_at": "2026-09-26T09:00:00.000Z", "updated_at": "2026-09-26T09:00:00.000Z" }, "warnings": [ "string" ] } ``` `400` Invalid JSON body, `name is required`, `mjml is required`, or the MJML did not compile (the message says why). ``` { "error": "mjml must be a whole document: start with and put the block inside ." } ``` `401` No body. `403` No body. `413` No body. `500` No body. ### POST /api/v1/custom-blocks/preview **Compile MJML without saving.** What the block editor shows while you type: the compiled fragment and head, the slots the markup declares, and `document`, a whole email with the slots filled from `values` (or their defaults) for a preview frame. Nothing is stored. Requires `templates:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `mjml` | string | yes | | | `slots` | array of object | no | | | `values` | object | no | | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/custom-blocks/preview" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "mjml": "string", "slots": [ { "key": "string", "label": "string", "default": "string" } ], "values": {} }' ``` #### Responses `200` Compiled. ``` { "html": "string", "head_html": "string", "slots": [ { "key": "headline", "type": "text", "label": "Headline", "default": "Big news" } ], "warnings": [ "string" ], "document": "string" } ``` `400` Invalid JSON body, `mjml is required`, or the MJML did not compile. ``` { "error": "The MJML compiled to an empty body. Put the block inside ." } ``` `401` No body. `403` No body. `413` No body. ### GET /api/v1/custom-blocks/{id} **Get a custom block.** One block, source and compiled output. Requires `templates:read`. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/custom-blocks/id" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` The block. ``` { "custom_block": { "id": "b7c8d9e0-f1a2-4b3c-8d4e-5f6a7b8c9d0e", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Hero", "description": "Headline, one line and a button on the brand colour.", "mjml": "[[headline]][[cta_label]]", "html": "
…[[headline]]…
", "head_html": "", "slots": [ { "key": "headline", "type": "text", "label": "Headline", "default": "Big news" }, { "key": "cta", "type": "url", "label": "Button link", "default": "https://acme.test" }, { "key": "cta_label", "type": "text", "label": "Button text", "default": "Read more" } ], "created_at": "2026-09-26T09:00:00.000Z", "updated_at": "2026-09-26T09:00:00.000Z" } } ``` `401` No body. `403` No body. `404` No body. `500` No body. ### PATCH /api/v1/custom-blocks/{id} **Update a custom block.** Any of `name`, `description`, `mjml` and `slots`. New MJML is compiled; labels and defaults already set survive a markup change, and a slot the markup no longer names is dropped. Emails that already placed the block keep the copy they took; the builder picks up the new design when they are next opened. Requires `templates:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | | | `description` | string | no | | | `mjml` | string | no | | | `slots` | array of object | no | | #### Example request ``` curl -X PATCH "https://sendbeam.io/api/v1/custom-blocks/id" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "name": "string", "description": "string", "mjml": "string", "slots": [ { "key": "string", "label": "string", "default": "string" } ] }' ``` #### Responses `200` Updated. ``` { "custom_block": { "id": "b7c8d9e0-f1a2-4b3c-8d4e-5f6a7b8c9d0e", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Hero", "description": "Headline, one line and a button on the brand colour.", "mjml": "[[headline]][[cta_label]]", "html": "
…[[headline]]…
", "head_html": "", "slots": [ { "key": "headline", "type": "text", "label": "Headline", "default": "Big news" }, { "key": "cta", "type": "url", "label": "Button link", "default": "https://acme.test" }, { "key": "cta_label", "type": "text", "label": "Button text", "default": "Read more" } ], "created_at": "2026-09-26T09:00:00.000Z", "updated_at": "2026-09-26T09:00:00.000Z" }, "warnings": [ "string" ] } ``` `400` Invalid JSON body, `Nothing to update`, or the MJML did not compile. ``` { "error": "Nothing to update" } ``` `401` No body. `403` No body. `404` No body. `413` No body. `500` No body. ### DELETE /api/v1/custom-blocks/{id} **Delete a custom block.** Removes the block from the library. Emails that placed it keep their copy. Requires `templates:write`. #### Example request ``` curl -X DELETE "https://sendbeam.io/api/v1/custom-blocks/id" \ -H "x-api-key: sb_live_…" ``` #### Responses `204` Deleted. No body. `401` No body. `403` No body. `404` No body. `500` No body. ### GET /api/v1/templates/{id} **Get a template.** Returns one template. Requires `templates:read`. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/templates/id" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` The template. ``` { "template": { "id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Welcome email", "subject": "Welcome, {{first_name}}!", "html_content": "

Hi {{first_name}}

", "category": "welcome", "created_at": "2026-08-20T09:00:00.000Z" } } ``` `401` No body. `403` No body. `404` No body. `500` No body. ### PATCH /api/v1/templates/{id} **Update a template.** Updates any of the template fields. Empty `name`/`subject` and invalid `category` values are ignored. `html_content` is limited to 500,000 characters (`413`). `blocks` (the visual builder's block JSON) is stored when sent; sending new `html_content` without `blocks` clears them, so stale block JSON never masquerades as the current design. Requires `templates:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | | | `subject` | string | no | | | `html_content` | string | no | | | `category` | TemplateCategory | no | | #### Example request ``` curl -X PATCH "https://sendbeam.io/api/v1/templates/id" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "subject": "Welcome aboard, {{first_name}}" }' ``` #### Responses `200` Updated. ``` { "template": { "id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Welcome email", "subject": "Welcome, {{first_name}}!", "html_content": "

Hi {{first_name}}

", "category": "welcome", "created_at": "2026-08-20T09:00:00.000Z" } } ``` `400` No body. `401` No body. `403` No body. `404` No body. `413` No body. `500` No body. ### DELETE /api/v1/templates/{id} **Delete a template.** Deletes the template. Returns `204` with no body. Requires `templates:write`. #### Example request ``` curl -X DELETE "https://sendbeam.io/api/v1/templates/id" \ -H "x-api-key: sb_live_…" ``` #### Responses `204` Deleted. No body. `401` No body. `403` No body. `404` No body. `500` No body. ## Media Images hosted for the email builder: upload a PNG, JPEG, GIF or WebP and get a public URL to put in an email; list and remove what the workspace has uploaded (`campaigns:read` / `campaigns:write`). Storage is capped per plan. ### GET /api/v1/media **List hosted images.** Every image the workspace has uploaded, newest first, with the storage used and the plan's cap. Always `200`: when image hosting is not enabled on the installation the body says `configured: false` with an empty list, so a client can decide whether to offer an upload at all. Requires `campaigns:read`. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/media" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` The library. ``` { "configured": true, "files": [ { "id": "mfp3k2x0a1b2c3d4e5f6.jpg", "url": "https://media.sendbeam.io/m/7c9e6679742a4b1d9a3f0e2c/mfp3k2x0a1b2c3d4e5f6.jpg", "name": "hero.jpg", "size": 184320, "type": "image/jpeg", "uploaded": "2026-09-14T09:00:00.000Z" } ], "count": 1, "used_bytes": 184320, "quota_bytes": 26214400, "max_bytes": 2097152, "types": [ "image/png", "image/jpeg", "image/gif", "image/webp" ], "truncated": false } ``` `401` No body. `403` No body. `503` The library could not be read; try again. ``` { "error": "Contact not found" } ``` ### POST /api/v1/media **Upload an image.** Hosts one image and returns its public URL for use in an email. Send `multipart/form-data` with the file in a field called `file`. The bytes are inspected: PNG, JPEG, GIF and WebP are accepted by their signature, whatever the declared type or file name; anything else is `415` (SVG included — email clients do not render it and it can carry script). At most 2 MB (`413`). Storage is capped per plan (`403` with `used_bytes` and `quota_bytes` when the file would not fit) and uploads at 120 an hour per workspace (`429`). The URL is permanent for as long as the file exists; a later delete leaves emails that used it with a broken picture. Requires `campaigns:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `file` | string | yes | The image (PNG, JPEG, GIF or WebP, at most 2 MB). | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/media" \ -H "x-api-key: sb_live_…" ``` #### Responses `201` Stored. ``` { "file": { "id": "mfp3k2x0a1b2c3d4e5f6.jpg", "url": "https://media.sendbeam.io/m/7c9e6679742a4b1d9a3f0e2c/mfp3k2x0a1b2c3d4e5f6.jpg", "name": "hero.jpg", "size": 184320, "type": "image/jpeg", "uploaded": "2026-09-14T09:00:00.000Z" }, "used_bytes": 184320, "quota_bytes": 26214400 } ``` `400` No `file` field, or the request is not multipart. ``` { "error": "Contact not found" } ``` `401` No body. `403` Missing permission, or the plan's image storage is full (then `used_bytes` and `quota_bytes` are present). ``` { "error": "string", "used_bytes": 1, "quota_bytes": 1 } ``` `413` Larger than 2 MB. ``` { "error": "Contact not found" } ``` `415` Not a PNG, JPEG, GIF or WebP. ``` { "error": "Contact not found" } ``` `429` More than 120 uploads in an hour. ``` { "error": "Contact not found" } ``` `503` Image hosting is not enabled on this installation (`configured: false`), or the store did not answer. ``` { "error": "string", "configured": true } ``` ### DELETE /api/v1/media/{id} **Remove a hosted image.** Deletes the file. Ids are resolved inside the caller's own workspace, so another workspace's id is simply `404`. Emails that already reference the URL keep the reference and show a broken picture. Requires `campaigns:write`. #### Example request ``` curl -X DELETE "https://sendbeam.io/api/v1/media/id" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` Removed. ``` { "ok": true, "id": "string", "note": "string" } ``` `401` No body. `403` No body. `404` No such image in this workspace. ``` { "error": "Contact not found" } ``` `503` Image hosting is not enabled on this installation, or the store did not answer. ``` { "error": "Contact not found" } ``` ## RSS to email Feeds that turn new posts into campaigns on a schedule (`campaigns:read` / `campaigns:write`). ### GET /api/v1/rss-feeds **List feeds.** #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/rss-feeds" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` The workspace's feeds. ``` { "feeds": [ null ] } ``` `401` No body. `403` No body. ### POST /api/v1/rss-feeds **Add a feed.** Watches an RSS 2.0 or Atom feed and sends new posts to the audience as a campaign. The feed is fetched and parsed before it is saved: a URL that is not a working feed is refused with `422` and the reason, and a successful create answers with `check` (how many posts the feed holds and the newest title) and stores the feed's title. Checked continuously afterwards: `immediate` feeds every 15 minutes, `daily`/`weekly` feeds hourly with the send in `send_hour` (UTC). Requires `campaigns:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | | | `feed_url` | string | no | Public http(s) URL of an RSS 2.0 or Atom feed. | | `send_to_type` | "all" \| "list" \| "segment" | no | | | `send_to_id` | string | no | | | `template_id` | string | no | A template with the `{{rss_items}}` tag (the "Latest posts" block); null = built-in digest layout. | | `subject_template` | string | no | Placeholders: `{{item_title}}`, `{{feed_title}}`, `{{item_count}}`. | | `intro` | string | no | | | `frequency` | "immediate" \| "daily" \| "weekly" | no | | | `send_hour` | integer | no | UTC. | | `send_weekday` | integer | no | 0 = Sunday. | | `max_items` | integer | no | | | `status` | "active" \| "paused" | no | | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/rss-feeds" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "name": "string", "feed_url": "string", "send_to_type": "all", "send_to_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "template_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "subject_template": "{{item_title}}", "intro": "string", "frequency": "daily", "send_hour": 9, "send_weekday": 1, "max_items": 5, "status": "active" }' ``` #### Responses `201` Created. `check` says what the fetch found. ``` { "feed": null, "check": { "total_items": 12, "newest_title": "What shipped in September" } } ``` `400` `name is required`, `feed_url must be a valid http(s) URL`, `feed_url must be a public hostname`, `send_to_id is required for a list audience`, `frequency must be immediate, daily or weekly`, `send_hour must be 0–23 (UTC)`, `max_items must be 1–10`. ``` { "error": "feed_url must be a public hostname" } ``` `401` No body. `403` No body. `404` The list, segment or template is not this workspace's. ``` { "error": "List not found" } ``` `422` The feed could not be fetched or parsed: `That URL is not an RSS or Atom feed.`, `The feed answered HTTP 403.`, `The feed is larger than 2 MB.` ``` { "error": "That URL is not an RSS or Atom feed." } ``` ### GET /api/v1/rss-feeds/{id} **Get a feed and its recent campaigns.** #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/rss-feeds/id" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` The feed and up to 20 campaigns it created. ``` { "feed": null, "campaigns": [ {} ] } ``` `401` No body. `403` No body. `404` Feed not found. ``` { "error": "Feed not found" } ``` ### PATCH /api/v1/rss-feeds/{id} **Update a feed.** Any subset of the create fields, plus `status` (`active` / `paused`). A new `feed_url` is fetched and parsed before it replaces the old one (`422` with the reason if it is not a working feed); on success the response carries `check` and any earlier `last_error` is cleared. Requires `campaigns:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | | | `feed_url` | string | no | Public http(s) URL of an RSS 2.0 or Atom feed. | | `send_to_type` | "all" \| "list" \| "segment" | no | | | `send_to_id` | string | no | | | `template_id` | string | no | A template with the `{{rss_items}}` tag (the "Latest posts" block); null = built-in digest layout. | | `subject_template` | string | no | Placeholders: `{{item_title}}`, `{{feed_title}}`, `{{item_count}}`. | | `intro` | string | no | | | `frequency` | "immediate" \| "daily" \| "weekly" | no | | | `send_hour` | integer | no | UTC. | | `send_weekday` | integer | no | 0 = Sunday. | | `max_items` | integer | no | | | `status` | "active" \| "paused" | no | | #### Example request ``` curl -X PATCH "https://sendbeam.io/api/v1/rss-feeds/id" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "name": "string", "feed_url": "string", "send_to_type": "all", "send_to_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "template_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "subject_template": "{{item_title}}", "intro": "string", "frequency": "daily", "send_hour": 9, "send_weekday": 1, "max_items": 5, "status": "active" }' ``` #### Responses `200` Updated. `check` is present only when `feed_url` was sent. ``` { "feed": null, "check": { "total_items": 12, "newest_title": "What shipped in September" } } ``` `400` Validation error, or `No valid fields to update`. ``` { "error": "status must be active or paused" } ``` `401` No body. `403` No body. `404` Feed, list, segment or template not found. ``` { "error": "Feed not found" } ``` `422` The new feed URL could not be fetched or parsed. ``` { "error": "The feed answered HTTP 404." } ``` ### DELETE /api/v1/rss-feeds/{id} **Delete a feed.** Campaigns already sent from it are kept (their `rss_feed_id` becomes null). Requires `campaigns:write`. #### Example request ``` curl -X DELETE "https://sendbeam.io/api/v1/rss-feeds/id" \ -H "x-api-key: sb_live_…" ``` #### Responses `204` Deleted. No body. `401` No body. `403` No body. `404` Feed not found. ``` { "error": "Feed not found" } ``` ### POST /api/v1/rss-feeds/{id}/preview **Fetch the feed and render the email.** Fetches the feed now and returns what a send would contain — the new items (or, when nothing is new, the newest ones as a sample), the subject and the rendered HTML. Nothing is sent or recorded. Requires `campaigns:read`. #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/rss-feeds/id/preview" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` Preview. ``` { "feed_title": "string", "total_items": 1, "new_items": 1, "items": [ { "key": "string", "title": "string", "link": "string", "summary": "string", "date": "2026-09-02T12:00:00Z" } ], "subject": "string", "html": "string" } ``` `401` No body. `403` No body. `404` Feed not found. ``` { "error": "Feed not found" } ``` `422` The feed could not be fetched or parsed: `That URL is not an RSS or Atom feed.`, `The feed answered HTTP 403.`, `The feed is larger than 2 MB.` ``` { "error": "That URL is not an RSS or Atom feed." } ``` ### POST /api/v1/rss-feeds/{id}/run **Send now.** Sends the posts that are new since the last send as a campaign. With `{"force": true}` the newest posts are sent even if nothing is new. "Nothing new" is a `200` with `sent: false`. Requires `campaigns:send` — running a feed sends a campaign, unlike editing the feed itself (`campaigns:write`). #### Request body (optional) | Field | Type | Required | Description | | --- | --- | --- | --- | | `force` | boolean | no | | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/rss-feeds/id/run" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "force": false }' ``` #### Responses `200` Nothing was sent; `reason` says why (nothing new, feed error, empty audience, plan gate). ``` { "sent": false, "reason": "string", "items": 1 } ``` `202` A campaign was created and is sending. ``` { "sent": true, "campaign_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "items": 1, "subject": "string" } ``` `400` `Invalid JSON body`. ``` { "error": "Invalid JSON body" } ``` `401` No body. `403` No body. `404` Feed not found. ``` { "error": "Feed not found" } ``` ## Automations Trigger-driven step sequences (send email, wait, condition, add/remove tag). ### GET /api/v1/automations **List automations.** Returns automations, newest first, without their steps. Optionally filter by status. Requires `automations:read`. | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `status` | query | AutomationStatus | no | | #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/automations?status=active" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` Automations. ``` { "automations": [ { "id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Welcome series", "description": "Three emails over a week", "trigger_type": "contact_created", "trigger_config": {}, "status": "active", "created_at": "2026-08-26T08:00:00.000Z" } ] } ``` `401` No body. `403` No body. `500` No body. ### POST /api/v1/automations **Create an automation.** Creates an automation in `draft` status with its steps. `steps` is required but may be empty. Every template, tag, list or form referenced in `trigger_config` or a step's `config` must belong to this workspace; otherwise `400` names the offending field. Activate it separately. The response contains the automation without its steps. Requires `automations:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | | | `description` | string | no | | | `trigger_type` | TriggerType | yes | | | `trigger_config` | TriggerConfig | no | Trigger-specific settings. `tag_added` / `tag_removed` use `tag_id` (omit for any tag). `list_joined` accepts `list_id`; `form_submitted` accepts `form_id`; `automation` accepts `automation_id`. `field_updated` requires `field`. `date_anniversary` and `date_specific` require `date_field` — a custom field holding `YYYY-MM-DD`, or `created_at` for an anniversary — plus optional `offset_days` (0–365) and `direction` (`before`, `on`, `after`). `contact_created`, `api`, `cart_abandoned`, `product_viewed` and `order_placed` take no settings — they fire for the matching event from any store or webhook that posts it (see `POST /api/v1/ecommerce/events`). `event_received` takes `event_name` — the name another system will post to `POST /api/v1/events`; omit it to run for every event the workspace is sent. Any id given must belong to this workspace, or the request is refused with `400`. | | `steps` | array of AutomationStepInput | yes | | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/automations" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "name": "Welcome series", "trigger_type": "contact_created", "trigger_config": {}, "steps": [ { "type": "send_email", "config": { "template_id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b" } }, { "type": "wait", "config": { "duration_minutes": 1440 } }, { "type": "add_tag", "config": { "tag_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33" } } ] }' ``` #### Responses `201` Created draft. ``` { "automation": { "id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Welcome series", "description": "Three emails over a week", "trigger_type": "contact_created", "trigger_config": {}, "status": "active", "created_at": "2026-08-26T08:00:00.000Z" } } ``` `400` Invalid JSON body, `name is required`, `trigger_type must be one of: contact_created, tag_added, list_joined, form_submitted`, `steps must be an array`, `Each step must be an object`, `Invalid step type: . Must be one of: send_email, wait, condition, add_tag, remove_tag`, or `: does not belong to this workspace` where `` is `trigger_config.` or `steps[].config.`. ``` { "error": "Contact not found" } ``` `401` No body. `403` No body. `500` `Failed to create automation` or `Failed to save automation steps` (the automation is rolled back). ``` { "error": "Failed to save automation steps" } ``` ### GET /api/v1/automations/{id} **Get an automation.** Returns one automation with its steps in order. Requires `automations:read`. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/automations/id" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` The automation. ``` { "automation": null } ``` `401` No body. `403` No body. `404` No body. `500` No body. ### PATCH /api/v1/automations/{id} **Update an automation.** Updates any of `name`, `description`, `trigger_type`, `trigger_config`; if `steps` is present every existing step is replaced by the new array. The whole body is validated before anything is written — including that every referenced template, tag, list or form belongs to this workspace — so a rejected request changes nothing. Contacts already enrolled continue from their current step index. The response contains the automation without its steps. Requires `automations:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | | | `description` | string | no | | | `trigger_type` | TriggerType | no | | | `trigger_config` | TriggerConfig | no | Trigger-specific settings. `tag_added` / `tag_removed` use `tag_id` (omit for any tag). `list_joined` accepts `list_id`; `form_submitted` accepts `form_id`; `automation` accepts `automation_id`. `field_updated` requires `field`. `date_anniversary` and `date_specific` require `date_field` — a custom field holding `YYYY-MM-DD`, or `created_at` for an anniversary — plus optional `offset_days` (0–365) and `direction` (`before`, `on`, `after`). `contact_created`, `api`, `cart_abandoned`, `product_viewed` and `order_placed` take no settings — they fire for the matching event from any store or webhook that posts it (see `POST /api/v1/ecommerce/events`). `event_received` takes `event_name` — the name another system will post to `POST /api/v1/events`; omit it to run for every event the workspace is sent. Any id given must belong to this workspace, or the request is refused with `400`. | | `steps` | array of AutomationStepInput | no | Replaces all existing steps. | #### Example request ``` curl -X PATCH "https://sendbeam.io/api/v1/automations/id" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "name": "Welcome series v2" }' ``` #### Responses `200` Updated. ``` { "automation": { "id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Welcome series", "description": "Three emails over a week", "trigger_type": "contact_created", "trigger_config": {}, "status": "active", "created_at": "2026-08-26T08:00:00.000Z" } } ``` `400` Invalid JSON body, `name must be a non-empty string`, `trigger_type must be one of: …`, `steps must be an array`, `Each step must be an object`, `Invalid step type: `, or `: does not belong to this workspace` (`trigger_config.` or `steps[].config.`). ``` { "error": "Contact not found" } ``` `401` No body. `403` No body. `404` No body. `500` `Failed to update automation`, `Failed to replace automation steps`, or `Failed to save updated steps`. ``` { "error": "Failed to update automation" } ``` ### DELETE /api/v1/automations/{id} **Delete an automation.** Deletes the automation together with its steps and every enrolment (active or finished). Requires `automations:write`. | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | yes | Automation ID. | #### Example request ``` curl -X DELETE "https://sendbeam.io/api/v1/automations/9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" \ -H "x-api-key: sb_live_…" ``` #### Responses `204` Deleted. No body. `401` No body. `403` No body. `404` No automation with that id in this workspace. ``` { "error": "Automation not found" } ``` `500` No body. ### POST /api/v1/automations/{id}/activate **Activate an automation.** Runs the pre-flight checks and, if they pass, sets the automation to `active` so new trigger events enrol contacts. Refused with `422` and a `blockers` list when it could not do what it says: no steps or trigger, an email step with nothing to send (a template that no longer exists, or no subject/content), a step or trigger pointing at a tag, list, form or automation that no longer exists, a wait with no length, or a workspace that cannot send. Non-blocking `warnings` (no email step, an unreachable step) come back with the `200`. Counts against the plan's live-automation cap, which is per workspace. Requires `automations:write`. #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/automations/id/activate" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` Now active. ``` { "automation": { "id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Welcome series", "description": "Three emails over a week", "trigger_type": "contact_created", "trigger_config": {}, "status": "active", "created_at": "2026-08-26T08:00:00.000Z" }, "warnings": [ { "code": "template_missing", "message": "string", "step_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" } ] } ``` `401` No body. `403` Missing permission, plan write gate, or the plan's live-automation cap is reached. ``` { "error": "Your plan allows 5 live automations in this workspace. Pause one or upgrade." } ``` `404` No body. `409` Already active. ``` { "error": "Automation is already active" } ``` `422` Pre-flight failed; `error` summarises, `blockers` lists each problem with the step it concerns. ``` { "error": "This automation cannot be activated yet: step 1 sends a template that no longer exists — choose another or write the email in the step.", "blockers": [ { "code": "template_missing", "step_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "message": "step 1 sends a template that no longer exists — choose another or write the email in the step." } ], "warnings": [] } ``` `500` Failed to activate automation. ``` { "error": "Failed to activate automation" } ``` ### POST /api/v1/automations/{id}/pause **Pause an automation.** Sets the automation to `paused`; no new enrolments happen and pending steps stop advancing. Works from `active` or `draft`. Requires `automations:write`. #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/automations/id/pause" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` Now paused. ``` { "automation": { "id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Welcome series", "description": "Three emails over a week", "trigger_type": "contact_created", "trigger_config": {}, "status": "active", "created_at": "2026-08-26T08:00:00.000Z" } } ``` `401` No body. `403` No body. `404` No body. `409` Already paused. ``` { "error": "Automation is already paused" } ``` `500` Failed to pause automation. ``` { "error": "Failed to pause automation" } ``` ### POST /api/v1/automations/{id}/trigger **Start an automation for one contact.** Enrols a single contact, bypassing trigger matching, and runs the first steps straight away in the background of this request (a wait, or anything the request could not finish, is picked up by the scheduler within about a minute). The automation must be `active` and must carry a trigger of type `api` — otherwise nothing on the outside could be allowed to inject contacts into a sequence whose author never meant it to be driven that way. Every other rule still applies: subscribed contacts only, never enrolled twice at once, and repeats obey `repeat_enabled` and `repeat_cooldown_hours`. Requires `automations:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `contact_id` | string | no | | | `email` | string | no | | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/automations/id/trigger" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "contact_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "email": "jane@example.com" }' ``` #### Responses `202` The contact was enrolled and will reach the first step on the next processing pass. ``` { "enrolled": true, "automation_id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c", "contact_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d" } ``` `400` Neither contact_id nor email was given. ``` { "error": "contact_id or email is required" } ``` `401` No body. `403` No body. `404` No such automation, or no such contact in this workspace. ``` { "error": "Contact not found" } ``` `409` Understood, and deliberately did nothing: the automation is not active or has no `api` trigger, or the contact is unsubscribed, already in it, or inside its repeat cooldown. ``` { "enrolled": false, "reason": "The contact was not enrolled. The automation needs a trigger of type \"api\", and the contact must be subscribed, not already in this automation, and past its repeat cooldown." } ``` `500` Failed to start the automation. ``` { "error": "Internal server error" } ``` ### GET /api/v1/automation-recipes **List automation recipes.** The gallery behind "New automation": ready-made automations (welcome series, tag hand-off, birthday, renewal reminder, re-engagement, post-purchase, form → sales, signup anniversary), each with its trigger, its steps in order and the placeholders — a list, a tag, a form, a Date field — an import has to resolve. Requires `automations:read`. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/automation-recipes" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` Every recipe. ``` { "recipes": [ { "slug": "welcome-series", "name": "Welcome series", "tagline": "Three emails over a week for everyone who joins a list.", "description": "…", "outline": { "triggers": [ "Contact joins list \"List to watch\"" ], "steps": [ { "text": "Send “Welcome — here is what to expect”" }, { "text": "Wait 2 days" } ], "repeats": "Each contact goes through once" }, "placeholders": [ { "id": "list", "kind": "list", "label": "List to watch", "hint": "Joining this list starts the series.", "suggested": "Newsletter" } ] } ] } ``` `401` No body. `403` No body. ### GET /api/v1/automation-recipes/{slug} **Get one recipe.** One recipe with its outline and placeholders — what a `POST` here will ask about. Requires `automations:read`. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/automation-recipes/slug" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` The recipe. ``` { "recipe": { "slug": "welcome-series", "name": "Welcome series", "tagline": "Three emails over a week for everyone who joins a list.", "description": "…", "outline": { "triggers": [ "Contact joins list \"List to watch\"" ], "steps": [ { "text": "Send “Welcome — here is what to expect”" }, { "text": "Wait 2 days" } ], "repeats": "Each contact goes through once" }, "placeholders": [ { "id": "list", "kind": "list", "label": "List to watch", "hint": "Joining this list starts the series.", "suggested": "Newsletter" } ] } } ``` `401` No body. `403` No body. `404` No such recipe. ``` { "error": "Recipe not found" } ``` ### POST /api/v1/automation-recipes/{slug} **Create a draft automation from a recipe.** Materialises the recipe into a `draft` automation (never active) with its starter copy, resolving each placeholder by the choice given: `{ existing: }` uses something the workspace already has; `{ create: }` makes it now — a tag or list of that name is reused case-insensitively, a field is registered with the recipe's type when the custom-field registry exists; `{ later: true }` (tags, lists and forms only) leaves the slot empty with a `recipe_placeholder` marker in the config, so the automation cannot be activated until it is chosen in the editor or the author explicitly opts for "any". A placeholder with no choice takes the recipe's suggestion (create it; a form is left for later), so an empty body is a complete import. A recipe with an anniversary trigger inherits the yearly repeat default. Requires `automations:write`; a choice that creates something also needs that thing's own permission (`tags:write`, `lists:write`, `contacts:write` for a field), or the call is refused with `403` before anything is made. #### Request body (optional) | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | Name for the new automation. Defaults to the recipe's name. | | `choices` | object | no | One entry per placeholder id. | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/automation-recipes/slug" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "name": "Newsletter welcome", "choices": { "list": { "existing": "9c1e2d3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f" }, "welcomed": { "create": "Welcomed" } } }' ``` #### Responses `201` The draft, with what the import created and what it left for later. ``` { "automation": { "id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c", "name": "Welcome series", "status": "draft", "trigger_type": "list_joined" }, "created": { "tags": [ "Welcomed" ], "lists": [ "Newsletter" ], "fields": [] }, "unresolved": [], "registry_unavailable": false } ``` `400` A choice names no placeholder of this recipe, has the wrong shape, a `create` for a form, a `later` for a field, an `existing` id that is not this workspace's, or a field key that is not registered. ``` { "error": "choices.list: 9c1e… is not a list in this workspace" } ``` `401` No body. `403` No body. `404` No such recipe. ``` { "error": "Recipe not found" } ``` `409` Registering the recipe's field would conflict with values contacts already hold (see the custom-fields API). ``` { "error": "Contact not found" } ``` `500` No body. ### POST /api/v1/events **Tell SendBeam that something happened.** Reports an event by NAME, and runs every active automation carrying the "Something happened elsewhere" trigger for that name. The caller says what happened; the workspace decides what it should do, so neither side has to know the other's automation ids — unlike `POST /api/v1/automations/{id}/trigger`, which names one automation and breaks when it is rebuilt. An event never creates a contact: somebody else's system mentioning an address is not consent to email it. An address the workspace does not hold answers `200` with `"matched": false` rather than an error, so a sender does not retry a normal case for ever. Names are lower-cased, so `Deal_Won` and `deal_won` are one event. Requires `contacts:write`, because running an automation can send email. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `event` | string | yes | What happened, as a name you also type into the trigger. Letters, numbers, dot, dash or underscore; lower-cased on arrival. | | `email` | string | yes | Who it happened to. Matched against contacts in this workspace; never created. | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/events" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "event": "deal_won", "email": "jane@example.com" }' ``` #### Responses `200` Accepted. `matched` says whether the address is a contact here, and `enrolled` how many automations started. ``` { "ok": true, "event": "string", "matched": true, "enrolled": 1 } ``` `400` `event` or `email` is missing, or the name is not in the allowed shape. ``` { "error": "event must be 1-60 characters: letters, numbers, dot, dash or underscore" } ``` `401` No body. `403` No body. ## Workspace blueprints A saved snapshot of one workspace's configuration — custom fields, automations and templates, never contacts/lists or sending domains — that can be applied into any other workspace on the same account, most usefully when creating one. Admin-only. ### GET /api/v1/workspace-blueprints **List the account's blueprints.** Every blueprint saved on this login's account, newest first, with counts of what each one captured. Requires `automations:read`. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/workspace-blueprints" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` The blueprints — an empty list with `available: false` before the account has been set up (MIGRATION-ACCOUNTS.sql pending), never an error. ``` { "blueprints": [ { "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "account_id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c", "source_tenant_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33", "created_by": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b", "name": "Agency starter kit", "description": "", "created_at": "2026-09-15T09:00:00.000Z", "updated_at": "2026-09-15T09:00:00.000Z", "counts": { "custom_fields": 2, "automations": 1, "templates": 3 } } ], "available": true } ``` `401` No body. `403` No body. ### POST /api/v1/workspace-blueprints **Save the current workspace as a blueprint.** Captures the AUTHENTICATED workspace's custom-field definitions, its automations (each turned back into the same recipe shape `POST /api/v1/automation-recipes/{slug}` resolves, so applying one goes through that same import) and its email templates (any hosted image a template references is copied into the destination workspace on apply). Contacts, lists and sending domains are never captured. Admin-only; requires `automations:read`, `templates:read` and `contacts:read` (reading every domain a blueprint touches). #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | | | `description` | string | no | | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/workspace-blueprints" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "name": "Agency starter kit", "description": "Custom fields, welcome automation and templates every new client site starts with." }' ``` #### Responses `201` Saved. ``` { "blueprint": { "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "account_id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c", "source_tenant_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33", "created_by": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b", "name": "Agency starter kit", "description": "", "created_at": "2026-09-15T09:00:00.000Z", "updated_at": "2026-09-15T09:00:00.000Z", "counts": { "custom_fields": 2, "automations": 1, "templates": 3 } } } ``` `400` Missing or out-of-range `name`, or a bad `description`. ``` { "error": "Give the blueprint a name between 2 and 80 characters." } ``` `401` No body. `403` Missing permission, or a member seat (blueprints are admin-only). ``` { "error": "Contact not found" } ``` `503` The account is not set up yet (MIGRATION-ACCOUNTS.sql pending), or the blueprints table itself is (MIGRATION-WORKSPACE-BLUEPRINTS.sql pending). ``` { "error": "Contact not found" } ``` ### DELETE /api/v1/workspace-blueprints/{id} **Delete a blueprint.** Removes the blueprint from the account. Never touches a workspace it was already applied to — applying one copies its content in, so nothing keeps pointing back at the blueprint row afterward. Admin-only. #### Example request ``` curl -X DELETE "https://sendbeam.io/api/v1/workspace-blueprints/id" \ -H "x-api-key: sb_live_…" ``` #### Responses `204` Deleted. No body. `401` No body. `403` Not an admin on this account. ``` { "error": "Contact not found" } ``` `404` No such blueprint on this account. ``` { "error": "Blueprint not found" } ``` `503` The blueprints table is not set up yet (MIGRATION-WORKSPACE-BLUEPRINTS.sql pending). ``` { "error": "Contact not found" } ``` ### POST /api/v1/workspace-blueprints/{id}/apply **Apply a blueprint into a workspace.** Materialises the blueprint into `tenant_id`: custom fields are created (a key already declared there is left alone and reported as skipped, never overwritten), each captured automation is imported as a `draft` exactly as `POST /api/v1/automation-recipes/{slug}` would (a tag/list/field it needs is created under its captured name, reusing one of that name if it already exists there), and each template is created with any hosted image copied into the destination's own storage (a note says how many could not be, rather than shipping a broken image reference). The target workspace must belong to the SAME account as the blueprint. A session caller may target any workspace they hold the admin role in; an API key, as everywhere else in this API, may only target the one workspace it was minted for. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `tenant_id` | string | yes | The workspace to apply the blueprint into. | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/workspace-blueprints/id/apply" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "tenant_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" }' ``` #### Responses `200` What was created, skipped or could not be carried over. ``` { "applied": { "custom_fields": { "created": [ "renewal_date" ], "skipped": [ "plan" ], "unavailable": false }, "templates": { "created": 3, "failed": 0, "media_copied": 2, "media_not_carried_over": 0 }, "automations": { "created": [ { "name": "Welcome series", "id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c", "unresolved": [] } ], "failed": [] } } } ``` `400` `tenant_id` missing, or names a workspace that is not on this account. ``` { "error": "Contact not found" } ``` `401` No body. `403` Missing permission for something this blueprint would create, or the caller does not administer the target workspace. ``` { "error": "Contact not found" } ``` `404` No such blueprint on this account. ``` { "error": "Blueprint not found" } ``` `503` The account or the blueprints table is not set up yet. ``` { "error": "Contact not found" } ``` ## Forms Signup and contact forms you embed on your sites (`forms:read` / `forms:write`), plus the public submission endpoint. ### GET /api/v1/forms **List forms.** Returns every form in the workspace, newest first, each with its `views`, `submissions` and `conversion_rate` (see the Form schema). `turnstile_secret` is always masked (`••••••••` when set, `null` otherwise). Requires `forms:read` (`forms:write` or a legacy `automations:write` also satisfies it). #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/forms" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` Forms. ``` { "forms": [ { "id": "11111111-2222-4333-8444-555555555555", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Newsletter signup", "list_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "fields": [ "email", "first_name", "last_name", "company" ], "thank_you_message": "Thanks for subscribing!", "redirect_url": null, "status": "active", "kind": "signup", "notify_email": null, "notify_subject": null, "allowed_origins": [ "https://acme.com" ], "turnstile_site_key": "0x4AAAAAAA", "turnstile_secret": "••••••••", "daily_cap": 200, "created_at": "2026-08-28T14:00:00.000Z", "views": 1840, "submissions": 92, "conversion_rate": 0.05 } ] } ``` `401` No body. `403` No body. `500` Failed to fetch forms. ``` { "error": "Failed to fetch forms" } ``` ### POST /api/v1/forms **Create a form.** Creates an active form. `kind` is `signup` (default: creates/subscribes a contact, optionally joins `list_id`, which must be one of this workspace's lists) or `contact` (emails the message to `notify_email`, never creates a contact; `list_id` is forced to null). `notify_email` must be a workspace member's address or an address on one of the workspace's verified sending domains (`400` otherwise). Defaults: `fields` `["email","first_name","last_name"]` for signup or `["email","name","subject","message"]` for contact; `thank_you_message` `Thanks for subscribing!` or `Thanks — your message has been sent. We'll reply by email.`. Requires `forms:write` (a legacy `automations:write` is also accepted). #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | | | `headline` | string \| null | no | | | `body` | string \| null | no | | | `button_label` | string \| null | no | | | `image_url` | string \| null | no | | | `indexable` | boolean | no | | | `ab_test` | object \| null | no | A second version of the form tested against the first. While `status` is `testing`, half of the views of the hosted page, the pop-up and the WordPress plugin see version B at random (per view, no cookie; inline embeds always show A); each view and submission is counted against its version. Set `{ "status": "testing", "b": {…} }` to start (started_at is set by the server), `{ "status": "decided", "winner": "a"\|"b" }` to end it — deciding for B writes its copy onto the form — and `null` to clear. | | `kind` | FormKind | no | | | `list_id` | string | no | Signup forms only. Must be one of this workspace's lists. | | `fields` | array of string | no | | | `thank_you_message` | string | no | | | `redirect_url` | string | no | | | `notify_email` | string \| null | no | Required for contact forms. Must be a workspace member's address or an address on one of the workspace's verified sending domains. | | `notify_subject` | string \| null | no | | | `allowed_origins` | array \| null | no | Up to 20 origins of the form `scheme://host[:port]`; null clears. | | `turnstile_site_key` | string \| null | no | | | `turnstile_secret` | string \| null | no | | | `daily_cap` | integer | no | | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/forms" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "name": "Contact us", "kind": "contact", "notify_email": "hello@acme.com", "notify_subject": "Website enquiry", "allowed_origins": [ "https://acme.com" ], "daily_cap": 100 }' ``` #### Responses `201` Created. ``` { "form": { "id": "11111111-2222-4333-8444-555555555555", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Newsletter signup", "list_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "fields": [ "email", "first_name", "last_name", "company" ], "thank_you_message": "Thanks for subscribing!", "redirect_url": null, "status": "active", "kind": "signup", "notify_email": null, "notify_subject": null, "allowed_origins": [ "https://acme.com" ], "turnstile_site_key": "0x4AAAAAAA", "turnstile_secret": "••••••••", "daily_cap": 200, "created_at": "2026-08-28T14:00:00.000Z", "views": 1840, "submissions": 92, "conversion_rate": 0.05 } } ``` `400` No body. `401` No body. `403` No body. `500` `Failed to create form`, or a hint that a database migration is pending. ``` { "error": "Failed to create form" } ``` ### PUT /api/v1/forms **Update a form.** Updates the form identified by `id` in the body. Only supplied fields change. Sending `turnstile_secret` as the mask `••••••••` leaves the stored secret unchanged. Switching `kind` to `contact` clears `list_id` and requires a `notify_email` (new or already stored). A new `list_id` must be one of this workspace's lists and a new `notify_email` a workspace member's address or one on a verified sending domain (`400` otherwise). Requires `forms:write` (a legacy `automations:write` is also accepted). #### Request body See the OpenAPI document for the body schema. #### Example request ``` curl -X PUT "https://sendbeam.io/api/v1/forms" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "id": "11111111-2222-4333-8444-555555555555", "status": "inactive" }' ``` #### Responses `200` Updated. ``` { "form": { "id": "11111111-2222-4333-8444-555555555555", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Newsletter signup", "list_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "fields": [ "email", "first_name", "last_name", "company" ], "thank_you_message": "Thanks for subscribing!", "redirect_url": null, "status": "active", "kind": "signup", "notify_email": null, "notify_subject": null, "allowed_origins": [ "https://acme.com" ], "turnstile_site_key": "0x4AAAAAAA", "turnstile_secret": "••••••••", "daily_cap": 200, "created_at": "2026-08-28T14:00:00.000Z", "views": 1840, "submissions": 92, "conversion_rate": 0.05 } } ``` `400` No body. `401` No body. `403` No body. `404` No body. `500` `Failed to update form`, or a hint that a database migration is pending. ``` { "error": "Failed to update form" } ``` ### DELETE /api/v1/forms **Delete a form.** Deletes the form identified by `id` in the body. Requires `forms:write` (a legacy `automations:write` is also accepted). #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | #### Example request ``` curl -X DELETE "https://sendbeam.io/api/v1/forms" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" }' ``` #### Responses `200` No body. `400` Invalid JSON body or `id is required`. ``` { "error": "id is required" } ``` `401` No body. `403` No body. `404` No body. `500` Failed to delete form. ``` { "error": "Failed to delete form" } ``` ### GET /api/v1/forms/{id} **Get a form.** One form by id, with its `views`, `submissions` and `conversion_rate`. `turnstile_secret` is masked as on the list. A form belonging to another workspace is a `404`, never its row. Requires `forms:read` (`forms:write` or a legacy `automations:write` also satisfies it). #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/forms/id" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` The form. ``` { "form": { "id": "11111111-2222-4333-8444-555555555555", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Newsletter signup", "list_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "fields": [ "email", "first_name", "last_name", "company" ], "thank_you_message": "Thanks for subscribing!", "redirect_url": null, "status": "active", "kind": "signup", "notify_email": null, "notify_subject": null, "allowed_origins": [ "https://acme.com" ], "turnstile_site_key": "0x4AAAAAAA", "turnstile_secret": "••••••••", "daily_cap": 200, "created_at": "2026-08-28T14:00:00.000Z", "views": 1840, "submissions": 92, "conversion_rate": 0.05 } } ``` `401` No body. `403` No body. `404` No body. ### POST /api/forms/{formId} **Submit a form (public).** Public, no API key: call it from your website with `fetch` or a plain form handler. CORS is open (`Access-Control-Allow-Origin: *`, or the matched origin when the form restricts `allowed_origins`). **Signup forms** upsert a subscribed contact (`source: "form"`), merge any declared custom fields into `custom_fields`, add the contact to the form's list, send a double opt-in email when the list (or the Free plan) requires it — confirmations to one address are throttled, and each counts towards the monthly email allowance — enrol `form_submitted` and `contact_created` automations (and `list_joined` immediately, or on confirmation for double opt-in), and email the owner if `notify_email` is set. A brand-new contact counts against the plan's contact cap. This is also the only way an address on the suppression list can come back. **Contact forms** (`kind: "contact"`) require `message`, store the submission and email it to `notify_email` with the visitor as Reply-To. No contact is created. **Abuse protection.** Submissions are checked against the form being active, its `allowed_origins`, the email address itself, Cloudflare Turnstile when the form has a secret, and rate limits. Automated submissions are also filtered by checks we do not document, and a `200` confirms only that the request was accepted, not that a contact was created — build your integration on the form working for a real person, not on the status code. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `email` | string | yes | | | `first_name` | string | no | | | `last_name` | string | no | | | `name` | string | no | Contact forms: full name (falls back to first_name + last_name). | | `subject` | string | no | Contact forms only. | | `message` | string | no | Contact forms: required. | | `turnstile_token` | string | no | Cloudflare Turnstile response when the form has Turnstile enabled (`cf-turnstile-response` is accepted as an alias). | #### Example request ``` curl -X POST "https://sendbeam.io/api/forms/formId" \ -H "Content-Type: application/json" \ -d '{ "email": "jane@example.com", "first_name": "Jane", "last_name": "Doe", "name": "Jane Doe", "subject": "Question about pricing", "message": "Do you offer annual billing?", "turnstile_token": "string" }' ``` #### Responses `200` Accepted. ``` { "success": true, "message": "Thanks for subscribing!", "redirect_url": null } ``` `400` Invalid JSON body, `A valid email address is required`, or (contact forms) `A message is required`. ``` { "error": "A valid email address is required" } ``` `403` Origin not allowed, Turnstile verification failed (`turnstile` carries Cloudflare's error codes), or the workspace's contact cap is reached. ``` { "error": "string", "turnstile": "string" } ``` `404` Form not found or inactive. ``` { "error": "Form not found or inactive" } ``` `429` Rate limited. ``` { "error": "Contact not found" } ``` `500` Failed to subscribe. ``` { "error": "Failed to subscribe" } ``` `503` Contact forms only: the message could not be delivered to the owner. Fall back to a `mailto:` link. ``` { "error": "Contact not found" } ``` ### POST /api/forms/{formId}/view **Record a form view (public).** Public, no API key, no body: the inline embed code sends this once as it loads (`navigator.sendBeacon`), so the form's `views` and `conversion_rate` include the sites it is pasted into. The hosted page and the pop-up count their own views, so nothing needs to call this for them. If you render a form yourself from its endpoint, call it once each time the form is shown to a person. A view is dropped, with the same `204`, when the request looks automated (a crawler user agent, a prefetch or link preview, or more views of one form from one address in an hour than a person would produce). A form that restricts `allowed_origins` takes views only from those origins. #### Example request ``` curl -X POST "https://sendbeam.io/api/forms/formId/view" ``` #### Responses `204` Received. Says nothing about whether the view was counted. No body. `403` Origin not allowed for this form. No body. `404` Form not found or inactive. No body. `429` Too many views of this form from this address; the rest of the hour's views are not counted. No body. ## Sending Send a single transactional email to one contact. ### POST /api/v1/send **Send a single email to a contact.** Sends one transactional email to a contact from the workspace's sender identity (re-validated on every send: the workspace's own shared address or a domain it has verified). Only `subscribed` contacts can be mailed. Counts against the plan's monthly email quota and its hourly sending ceiling; a paused workspace or exhausted monthly quota is refused with `403`, an exhausted hourly ceiling with `429` and a `Retry-After` header, before anything is sent. Every send is recorded in the workspace's Activity page (filter **API**) with its delivery, open and click events, and is included in Reports. Requires `campaigns:send`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `contact_id` | string | yes | | | `subject` | string | yes | | | `html_content` | string | yes | | | `text_content` | string | no | Plain-text alternative. | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/send" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "contact_id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "subject": "Your receipt", "html_content": "

Hi {{first_name}}, thanks for your order.

", "text_content": "Hi Jane, thanks for your order." }' ``` #### Responses `200` Accepted for delivery. ``` { "ok": true } ``` `400` Invalid JSON body or `contact_id, subject, and html_content are required`. ``` { "error": "contact_id, subject, and html_content are required" } ``` `401` No body. `403` Missing permission, plan write gate, sending paused for this workspace, or the monthly email quota is exhausted. ``` { "error": "Contact not found" } ``` `404` No body. `422` Platform sending is not configured, or the contact is not subscribed. ``` { "error": "Contact not found" } ``` `429` The plan's hourly sending ceiling is used up. Carries a `Retry-After` header saying how many seconds to wait. ``` { "error": "Hourly send limit reached. Try again later." } ``` `503` The delivery provider rejected the message; `error` carries the provider's reason. ``` { "error": "Failed to send email" } ``` ### POST /api/v1/transactional **Send site email to any address.** Sends the email a site sends to its own users — order confirmations, password resets, booking reminders — through the workspace's verified domain. Unlike `POST /api/v1/send`, recipients need not be contacts, and an unsubscribed person still receives mail they asked for (a marketing opt-out does not cover receipts); addresses that bounced or complained before are refused and listed in `skipped`. The message is delivered as written: no merge tags, no tracking, no unsubscribe link. `to`, `cc` and `bcc` each accept an address, `"Name
"`, `{ email, name }` or an array of those — every address gets its own copy — up to 10 per call. `from_email` must be on a verified domain (otherwise the workspace sender is used); `from_name` is used either way. `headers` may carry up to ten `X-*` headers. Counts against the monthly quota and hourly ceiling like every send; a bounce or complaint on a non-contact address suppresses that address. When the recipient is a contact, the send shows on their activity page. Requires `transactional:send`. The WordPress plugin's site-email option uses this endpoint. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `to` | string \| array of string \| object | yes | Recipient(s): an address, `"Name "`, `{ email, name }`, or an array of those. | | `cc` | string \| array of string | no | Same shapes as `to`; each address gets its own copy. | | `bcc` | string \| array of string | no | Same shapes as `to`; each address gets its own copy. | | `subject` | string | yes | | | `html` | string | no | HTML body. `html_content` is accepted as an alias. Required unless `text` is given. | | `text` | string | no | Plain-text body (`text_content` alias). A text-only message is also rendered as simple HTML. | | `reply_to` | string | no | One address, optionally `"Name "`. | | `from_name` | string | no | | | `from_email` | string | no | Used only when the address is on a domain verified in this workspace; otherwise the workspace sender applies. | | `headers` | object | no | Up to ten `X-*` headers, single-line, under 500 characters each. | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/transactional" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "to": "Jane Doe ", "cc": "string", "bcc": "string", "subject": "Your order #1001", "html": "

Thanks for your order.

", "text": "Thanks for your order.", "reply_to": "orders@example.com", "from_name": "Example Shop", "from_email": "orders@example.com", "headers": { "X-Order-Id": "1001" } }' ``` #### Responses `200` At least one copy was accepted for delivery. ``` { "ok": true, "sent": [ { "to": "jane@example.com", "message_id": "msg_5f8b2c…" } ] } ``` `400` Invalid body; `error` names the field. ``` { "error": "`to` is required: an email address, \"Name
\", { email, name } or an array of those" } ``` `401` No body. `403` Missing permission, sending paused, or the monthly email quota is exhausted. ``` { "error": "Forbidden: transactional:send permission required" } ``` `422` Platform sending is not configured, or no recipient can take delivery — each address is suppressed, or sits on a reserved documentation/test domain that can never receive mail (`skipped` says which). ``` { "error": "No deliverable recipient: every address is suppressed or not a routable mailbox.", "skipped": [ { "to": "old@acme.co", "reason": "This address bounced before and is not mailed again." }, { "to": "jane@example.com", "reason": "Not a deliverable address: example domain — reserved for documentation, never a real mailbox." } ] } ``` `429` The plan's hourly sending ceiling cannot fit this many recipients. Carries a `Retry-After` header saying how many seconds to wait. ``` { "error": "Hourly send limit reached. Try again later." } ``` `503` No copy could be sent; `error` carries the provider's reason and `failed` lists each address. ``` { "error": "Sender not allowed." } ``` ## Webhooks Register an https endpoint and SendBeam POSTs a signed JSON payload to it whenever a subscribed event happens in the workspace. Endpoints carry a workspace-wide signing secret, so they are admin-only: an API key with the scope below works whoever made it, but a signed-in person who holds a MEMBER seat is refused with 403 on every route here. ### GET /api/v1/webhooks **List webhook endpoints.** Returns every webhook endpoint in the workspace, newest first. The signing `secret` is never included — it is shown once when the endpoint is created and can only be replaced, not read back. Requires `webhooks:read`. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/webhooks" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` Webhook endpoints. ``` { "webhooks": [ { "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "url": "https://example.com/hooks/sendbeam", "description": "Sync new contacts into the CRM", "event_types": [ "contact.created" ], "filters": { "list_ids": [ "b3f1c2a4-1111-4a2b-8c3d-0000000000aa" ] }, "enabled": true, "disabled_reason": null, "consecutive_failures": 0, "last_success_at": null, "last_failure_at": null, "created_at": "2026-09-02T12:00:00Z" } ] } ``` `401` No body. `403` No body. `500` Failed to list webhooks. ``` { "error": "Failed to list webhooks" } ``` ### POST /api/v1/webhooks **Create a webhook endpoint.** Registers an endpoint and subscribes it to one or more events. The `url` must be `https://`, carry no credentials, and resolve to a public address — a private, loopback or link-local target is refused. Counts against the plan's webhook cap, pooled across all workspaces on the account (Free 1, Starter 5, Pro and Business unlimited). **This is the only response that ever contains `secret`**: store it when you receive it, because no later request can show it again. Requires `webhooks:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `url` | string | yes | Public `https://` URL to POST to. Must not resolve to a private, loopback or link-local address, and must not contain credentials. | | `description` | string | no | Optional note for your own reference. | | `event_types` | array of WebhookEvent | yes | At least one event. Duplicates are collapsed; an unknown name is refused and named back to you. | | `filters` | WebhookFilters | no | Optional scope. Omitted or `{}` means unfiltered — every event of a subscribed type fires, which is also every existing endpoint's behaviour before this field existed. A dimension you set must be present on the event itself or that endpoint is skipped for it (an endpoint scoped to a list, for instance, never receives a listless event); dimensions you set combine with AND, ids within one dimension combine with OR. Every id is checked against your own workspace at write time; an id that is not yours is refused, not silently ignored. At most 100 ids per key. | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/webhooks" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/sendbeam", "description": "Sync new contacts into the CRM", "event_types": [ "contact.created", "contact.unsubscribed" ], "filters": { "list_ids": [ "b3f1c2a4-1111-4a2b-8c3d-0000000000aa" ] } }' ``` #### Responses `201` Created, with the signing secret in the clear this one time. ``` { "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "url": "https://example.com/hooks/sendbeam", "description": "Sync new contacts into the CRM", "event_types": [ "contact.created" ], "filters": { "list_ids": [ "b3f1c2a4-1111-4a2b-8c3d-0000000000aa" ] }, "enabled": true, "secret": "9f2c1a0b3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8", "created_at": "2026-09-02T12:00:00Z" } ``` `400` Invalid JSON body, `url is required`, a URL that is refused (`The URL must use https://`, `That address is not a public IP`, `That host resolves to a private address`, `That host is not reachable from the internet`, `The URL must not contain credentials`, `The URL is too long`, `That host name does not resolve`), `event_types must be a non-empty array of event names`, `Unknown event type: . Valid events are: …`, `description must be a string`, or `description is too long ( characters; the limit is 200)`. ``` { "error": "Contact not found" } ``` `401` No body. `403` Missing permission, or the plan's per-workspace webhook cap is reached. ``` { "error": "Your plan allows 1 webhook endpoint across your workspaces. Delete one or upgrade." } ``` `500` Failed to create webhook. ``` { "error": "Failed to create webhook" } ``` ### GET /api/v1/webhooks/{id} **Get a webhook endpoint.** Returns one endpoint, with the same fields as the list and never the `secret`. Requires `webhooks:read`. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/webhooks/id" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` The endpoint. ``` { "webhook": { "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "url": "https://example.com/hooks/sendbeam", "description": "Sync new contacts into the CRM", "event_types": [ "contact.created" ], "filters": { "list_ids": [ "b3f1c2a4-1111-4a2b-8c3d-0000000000aa" ] }, "enabled": true, "disabled_reason": null, "consecutive_failures": 0, "last_success_at": null, "last_failure_at": null, "created_at": "2026-09-02T12:00:00Z" } } ``` `401` No body. `403` No body. `404` No body. ### PATCH /api/v1/webhooks/{id} **Update a webhook endpoint.** Updates any of `url`, `description`, `event_types` and `enabled`; omitted fields are left alone. A changed `url` is re-validated the same way creation validates it. Setting `enabled` to `true` on an endpoint that was auto-disabled clears `disabled_reason` and resets `consecutive_failures` to 0, so it starts again with a clean record. The response never contains `secret`. Requires `webhooks:write`. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `url` | string | no | | | `description` | string \| null | no | | | `event_types` | array of WebhookEvent | no | | | `filters` | WebhookFilters | no | Optional scope. Omitted or `{}` means unfiltered — every event of a subscribed type fires, which is also every existing endpoint's behaviour before this field existed. A dimension you set must be present on the event itself or that endpoint is skipped for it (an endpoint scoped to a list, for instance, never receives a listless event); dimensions you set combine with AND, ids within one dimension combine with OR. Every id is checked against your own workspace at write time; an id that is not yours is refused, not silently ignored. At most 100 ids per key. | | `enabled` | boolean | no | Set to `true` to re-enable an endpoint that was auto-disabled; that also clears `disabled_reason` and resets `consecutive_failures`. | #### Example request ``` curl -X PATCH "https://sendbeam.io/api/v1/webhooks/id" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/sendbeam-v2", "description": null, "event_types": [ "contact.created" ], "filters": { "list_ids": [ "b3f1c2a4-1111-4a2b-8c3d-0000000000aa" ] }, "enabled": true }' ``` #### Responses `200` Updated endpoint. ``` { "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "url": "https://example.com/hooks/sendbeam", "description": "Sync new contacts into the CRM", "event_types": [ "contact.created" ], "filters": { "list_ids": [ "b3f1c2a4-1111-4a2b-8c3d-0000000000aa" ] }, "enabled": true, "disabled_reason": null, "consecutive_failures": 0, "last_success_at": null, "last_failure_at": null, "created_at": "2026-09-02T12:00:00Z" } ``` `400` Invalid JSON body, `No valid fields to update`, `url must be a non-empty string`, a refused URL, `enabled must be true or false`, or an `event_types` / `description` validation message. ``` { "error": "No valid fields to update" } ``` `401` No body. `403` No body. `404` No body. `500` Failed to update webhook. ``` { "error": "Failed to update webhook" } ``` ### DELETE /api/v1/webhooks/{id} **Delete a webhook endpoint.** Deletes the endpoint along with its queued and logged deliveries. Requires `webhooks:write`. #### Example request ``` curl -X DELETE "https://sendbeam.io/api/v1/webhooks/id" \ -H "x-api-key: sb_live_…" ``` #### Responses `204` Deleted. No body. `401` No body. `403` No body. `404` No body. `500` Failed to delete webhook. ``` { "error": "Failed to delete webhook" } ``` ### POST /api/v1/webhooks/{id}/rotate-secret **Rotate the signing secret.** Replaces the endpoint's signing secret and returns the new one. The old secret stops verifying immediately — there is no overlap window — so update your receiver as soon as you have the new value. Together with creation, this is the only response that contains `secret`. Requires `webhooks:write`. #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/webhooks/id/rotate-secret" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` The new signing secret. ``` { "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "secret": "3b7d4bad9bdd2b0d7b3dcb6d9f2c1a0b3d4e5f60718293a4b5c6d7e8f90a1b2c3" } ``` `401` No body. `403` No body. `404` No body. `500` Failed to rotate the signing secret. ``` { "error": "Failed to rotate the signing secret" } ``` ### GET /api/v1/webhooks/{id}/deliveries **List recent deliveries.** The delivery log for one endpoint, most recent first, for debugging an integration. The stored request body is omitted by default because it can contain contact data; pass `include=payload` to get it. Requires `webhooks:read`. | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `page` | query | integer | no | Page number, starting at 1. | | `limit` | query | integer | no | Items per page (1–100). | | `status` | query | WebhookDeliveryStatus | no | Only deliveries in this state. | | `include` | query | "payload" | no | Comma-separated extras. `payload` adds the exact JSON body sent to each `payload` field. | #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/webhooks/id/deliveries?page=1&limit=50" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` A page of deliveries. ``` { "deliveries": [ { "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "event_type": "contact.created", "status": "pending", "attempts": 1, "last_status_code": 200, "last_error": null, "delivered_at": null, "created_at": "2026-09-02T12:00:00Z", "payload": {} } ], "pagination": { "page": 1, "limit": 50, "total": 1234, "total_pages": 25 } } ``` `401` No body. `403` No body. `404` No body. `500` Failed to list deliveries. ``` { "error": "Failed to list deliveries" } ``` ### GET /api/v1/events **List recent events.** The most recent events of one type in the workspace, newest first, each exactly the JSON body a webhook endpoint received for it — so an integration can show real data, for example in a test step, before its own endpoint has been sent anything. Events are read from the deliveries made to your webhook endpoints: an event that no endpoint was subscribed to when it happened is not listed, an event sent to several endpoints is listed once, and test events are never listed. `id` is one of that event's delivery ids and stays the same from one call to the next. No events is an empty list, not an error. Requires `webhooks:read`. | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `type` | query | WebhookEvent | yes | The event to list. An unknown name is refused and named back to you. | | `limit` | query | integer | no | How many events to return (1–25). A number outside the range is brought inside it. | #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/events?type=contact.created&limit=10" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` Up to `limit` events, newest first. ``` { "events": [ { "id": "8d7f3c1e-2b4a-4c6d-9e8f-0a1b2c3d4e5f", "event": "contact.created", "created_at": "2026-09-11T09:12:04.000Z", "data": { "contact": { "id": "3f0e2b6e-9a11-4d7a-8c3c-2f2b4b1c9d10", "email": "ada@example.com", "status": "subscribed", "first_name": "Ada", "last_name": "Lovelace", "source": "api", "language": null, "custom_fields": {}, "created_at": "2026-09-11T09:12:04.000Z", "subscribed_at": "2026-09-11T09:12:04.000Z", "unsubscribed_at": null } } } ] } ``` `400` `type` is missing or is not an event name. ``` { "error": "Unknown event type: contact.signup. Valid events are: contact.created, contact.updated, …" } ``` `401` No body. `403` No body. `500` Failed to list events. ``` { "error": "Failed to list events" } ``` ### POST /api/v1/webhooks/{id}/test **Send a test event.** POSTs one synthetic, signed payload to the endpoint immediately and reports the outcome, so you can check a new endpoint without waiting for a real event. The body has the same shape as a real event of that type — a contact event has the person under `data.contact` — with `"test": true` at the top of `data`, obviously fake values, and a delivery id that starts `test_`, so anything you map from a test keeps working when real events arrive. It defaults to a `contact.created` event; pass `event` to use another. A test is not a real event, so it is not added to the delivery log. The response is `200` whether the endpoint accepted the payload or not — read `ok`. Requires `webhooks:write`. #### Request body (optional) | Field | Type | Required | Description | | --- | --- | --- | --- | | `event` | any | no | Which event name to put in the test payload. Defaults to `contact.created`. | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/webhooks/id/test" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "event": null }' ``` #### Responses `200` The result of the attempt. ``` { "ok": true, "status": 200, "error": "timed out" } ``` `400` Invalid JSON body or `Unknown event type: `. ``` { "error": "Unknown event type: contact.exploded" } ``` `401` No body. `403` No body. `404` No body. `500` No body. ## Connect The one-button connection the WordPress plugin uses: the site owner approves what the site may do on a SendBeam page, and the site exchanges the resulting one-use grant for an API key. No API key — obtaining one is the point. ### POST /api/v1/connect/exchange **Exchange a connect grant for an API key.** Second half of the Connect flow the WordPress plugin uses. The site owner approves the connection at `https://sendbeam.io/connect/wordpress?…` in a pop-up; SendBeam mints an API key with the approved scopes and redirects the pop-up back to `return_to` with `state` and `grant`. The site's SERVER then posts the grant here and receives the key — once. Send no `x-api-key`: the grant is the credential. It is bound to the `state` the site generated and to the site origin the key was minted for, expires ten minutes after it was issued, and is destroyed the first time it is used, so the key never travels through a browser. Scopes are the plugin's own words — `forms`, `contacts:write`, `transactional:send`, `ecommerce`, `domain` — and the key they map to carries the ordinary API permissions behind them. Approving the connection also SETS THE SITE UP, and the response says what was done: the sending domain the owner chose is added with its DNS records (scope `domain`), a workspace with no signup form is given a `Subscribers` list and a `Newsletter signup` form, and a workspace with no from line takes the site's name and `hello@` that domain. Nothing already chosen is changed, and a previous key for the same site — matched by name OR by the host it was minted for — is revoked, so a reconnection replaces rather than accumulates. When there is no domain, `domain_state` and `domain_note` say why: a hosting company's host cannot be added at all, and a plan at its domain cap says so in words. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `grant` | string | yes | The grant id from the redirect, 43 characters of base64url. | | `state` | string | yes | The token the site generated and sent as `state`, returned unchanged. | | `site_url` | string | yes | The site origin the grant was minted for, exactly as sent. | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/connect/exchange" \ -H "Content-Type: application/json" \ -d '{ "grant": "k7Qb2m9x1s4d6f8g0h2j4k6l8n0p2r4t6v8x0z2b4d6", "state": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "site_url": "https://example.com" }' ``` #### Responses `200` The key, once. Store it; it is never shown again. ``` { "api_key": "sb_live_XXXXXXXX_YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", "key_prefix": "XXXXXXXX", "workspace": { "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "name": "Example Shop" }, "scopes": [ "forms", "transactional:send", "domain" ], "default_form": { "id": "8a7b6c5d-4e3f-2a1b-0c9d-8e7f6a5b4c3d", "name": "Newsletter signup" }, "domain": { "name": "example.com", "verified": false, "records": [ { "type": "CNAME", "name": "send.example.com", "value": "send.9f2c.dom.sendbeam.io", "found": null } ], "domain_connect_url": null, "checked_at": null }, "domain_state": "ok", "domain_note": null, "sender": { "from_name": "Example Shop", "from_email": "hello@example.com" } } ``` `400` Malformed body, or the `state` or `site_url` does not match the grant. ``` { "error": "invalid" } ``` `410` The grant has expired, has already been used, or never existed. Start the connection again. ``` { "error": "grant_expired_or_used" } ``` `429` More than 20 attempts in an hour from this address or for this site. ``` { "error": "rate_limited" } ``` ### GET /api/v1/connect/status **What the connected site still has to do.** What the connected site shows its owner: the workspace, the signup form to offer, the sending domain with the DNS records still outstanding, and the sender line. Cheap enough to call on every admin-page render — cache it for a minute — and it makes no external lookups at all once the domain is verified. The `domain` block requires `domains:read` and is `null` without it; it is also `null` for a key that was not minted by a Connect flow, because such a key belongs to no site. `domain_state` and `domain_note` say which of those it is, so a site can tell an owner what to do rather than only that something is missing. Which domain a key is told about is fixed at connection time and cannot be chosen by the caller. The POST form of this call does not count against the workspace’s hourly write allowance: it reads. #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/connect/status" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` The connection as it stands. ``` { "workspace": { "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "name": "Example Shop" }, "default_form": { "id": "8a7b6c5d-4e3f-2a1b-0c9d-8e7f6a5b4c3d", "name": "Newsletter signup" }, "domain": { "name": "example.com", "verified": true, "records": [ { "type": "CNAME", "name": "send.example.com", "value": "send.9f2c.dom.sendbeam.io", "found": true } ], "domain_connect_url": null, "checked_at": "2026-09-23T15:04:05Z" }, "domain_state": "ok", "domain_note": null, "sender": { "from_name": "Example Shop", "from_email": "hello@example.com" } } ``` `401` No body. ### POST /api/v1/connect/status **Check the sending domain now.** The same body, after running the sending domain’s DNS check now — what the Check button under Settings → Sending does. Use it when the owner says they have added the records, rather than waiting for the background re-check. Requires `domains:read`. Rate limited to 12 an hour per key; send `{}` or omit the body for a plain read, which is not limited. #### Request body (optional) | Field | Type | Required | Description | | --- | --- | --- | --- | | `check_domain` | boolean | no | Resolve the domain's records and ask the provider, then answer with the fresh `verified` and `checked_at`. | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/connect/status" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "check_domain": false }' ``` #### Responses `200` The connection as it stands, checked just now. ``` { "workspace": { "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "name": "Example Shop" }, "default_form": { "id": "8a7b6c5d-4e3f-2a1b-0c9d-8e7f6a5b4c3d", "name": "Newsletter signup" }, "domain": { "name": "example.com", "verified": true, "records": [ { "type": "CNAME", "name": "send.example.com", "value": "send.9f2c.dom.sendbeam.io", "found": true } ], "domain_connect_url": null, "checked_at": "2026-09-23T15:04:05Z" }, "domain_state": "ok", "domain_note": null, "sender": { "from_name": "Example Shop", "from_email": "hello@example.com" } } ``` `401` No body. `403` The key lacks `domains:read`, so the check is refused rather than run and withheld. ``` { "error": "Forbidden: domains:read permission required" } ``` `429` More than 12 checks in an hour with this key. The body carries `retry_after` (seconds) and a `message` written for the person who pressed the button, and the response carries a `Retry-After` header saying the same. SendBeam re-checks an unverified domain on its own every few minutes, so waiting costs nothing. ``` { "error": "rate_limited", "retry_after": 3600, "message": "SendBeam re-checks this domain on its own every few minutes. You can ask again in 60 minutes." } ``` ### GET /api/v1/domains/{id}/connect **Send the owner to their registrar to add the records.** Automatic DNS (Domain Connect): redirects the SIGNED-IN workspace admin to their own DNS provider, where they approve SendBeam’s records and are sent back. A browser redirect, not an API-key call — it is the address `domain_connect_url` carries in a Connect status body, and it is opened in a tab rather than fetched. When the provider cannot apply the records the browser comes back to SendBeam’s sending settings with a reason instead. | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | yes | The sending domain to set up. | | `return_to` | query | string | no | Where to put the owner down when the registrar has finished — a WordPress site sends its own plugin page, because that is where the button was pressed and the plugin is what has to notice the records are in. Honoured ONLY when its origin is that of a site this workspace holds an ACTIVE Connect key for; anything else is ignored and the flow ends on SendBeam’s sending settings as before. The site is returned to with `sb_dc=done`, or `sb_dc=error&sb_dc_reason=` when the registrar declined. | #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/domains/9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d/connect?return_to=string" ``` #### Responses `302` To the registrar’s approval page, or back to `/settings/domains` with a `dc_error` reason when it cannot start. No body. ### POST /api/v1/connect/disconnect **Revoke the calling key.** Revokes THE KEY THAT MADE THIS CALL, and nothing else — there is no key id to send, because the credential is the request. Use it when the site owner disconnects in WordPress, so deleting a plugin does not leave a live key behind that can write contacts and send the site’s mail. The workspace is untouched: the sending domain, the list, the form and the sender stay exactly where they are, and other sites sending from the same domain are unaffected. Any later call with the same key is `401`, which is what makes it idempotent. Like the status check, it does not count against the workspace’s hourly write allowance. #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/connect/disconnect" \ -H "x-api-key: sb_live_…" ``` #### Responses `204` Revoked. No body. No body. `400` The request was signed by a session rather than an API key, so there is no key to retire. ``` { "error": "This endpoint retires the API key that called it. Sign the request with that key." } ``` `401` No body. `500` No body. ## Audit log Who did what in the workspace: sign-ins, keys, team, sending, webhooks, connections, exports and account policy. Read-only. A key needs `audit:read`; a person needs the workspace admin role. Entries are kept for 12 months. ### GET /api/v1/account/audit-log **List audit events.** Who did what in the workspace, newest first: sign-ins and sign-outs, two-factor and passkey changes, API keys created and revoked, team changes, sender and domain changes, webhooks, connections, exports, and the account security policy. Cursor-paged: pass the `next_cursor` a page returns as `cursor` for the next one; `next_cursor` is null on the last page. Entries are kept for `retention_days` (365) and a query never reaches further back. Requires `audit:read`; a session caller needs the workspace admin role. `scope=account` (sessions only, for a person who administers every workspace on the account) returns every workspace's events with `workspace_id` and `workspace_name` on each. | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `cursor` | query | string | no | Opaque cursor from the previous page's `next_cursor`. Omit for the first page. | | `limit` | query | integer | no | Items per page (1–100). | | `action` | query | string | no | One action (`api_key.created`), or a family as a prefix ending in a dot (`team.`). Actions: `session.signed_in`, `session.signed_out`, `session.ended_by_policy`, `access.denied`, `mfa.enabled`, `mfa.disabled`, `password.changed`, `passkey.added`, `passkey.removed`, `trusted_device.remembered`, `trusted_device.forgotten`, `api_key.created`, `api_key.revoked`, `team.invited`, `team.invitation_revoked`, `team.joined`, `team.role_changed`, `team.member_removed`, `team.ownership_transferred`, `workspace.renamed`, `workspace.deletion_scheduled`, `workspace.restored`, `workspace.exported`, `contacts.exported`, `suppressions.exported`, `sending.identity_changed`, `domain.added`, `domain.removed`, `webhook.created`, `webhook.updated`, `webhook.deleted`, `webhook.secret_rotated`, `connection.connected`, `connection.updated`, `connection.disconnected`, `ecommerce.secret_set`, `ecommerce.secret_cleared`, `account.policy_changed`, `account.scim_token_created`, `account.scim_token_revoked`, `account.erasure_run`, `scim.user_provisioned`, `scim.user_renamed`, `scim.user_deprovisioned`. | | `actor` | query | string | no | Only events whose actor label starts with this: an email address or an API key name. Case-insensitive. | | `from` | query | string | no | Only events on or after this day (YYYY-MM-DD). | | `to` | query | string | no | Only events on or before this day (YYYY-MM-DD; the whole day counts). | | `scope` | query | "workspace" \| "account" | no | `workspace` (default) or `account`. Sessions only: an API key always reads the workspace it was minted for. | #### Example request ``` curl -X GET "https://sendbeam.io/api/v1/account/audit-log?cursor=string&limit=50" \ -H "x-api-key: sb_live_…" ``` #### Responses `200` A page of events. ``` { "events": [ { "id": "0192b6a4-6d3e-7c1a-9f2e-3b4c5d6e7f80", "created_at": "2026-09-25T09:12:41.000Z", "action": "api_key.created", "actor_type": "user", "actor_id": "4f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "actor_label": "jane@example.com", "target_type": "api_key", "target_id": "9a1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d", "target_label": "Zapier", "workspace_id": "2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f", "workspace_name": "Harbour Lane", "metadata": { "permissions": [ "contacts:read", "contacts:write" ] }, "ip": "203.0.113.7" } ], "next_cursor": null, "retention_days": 365 } ``` `401` No body. `403` The key lacks `audit:read`, the session is not a workspace admin, or `scope=account` was asked for by a key or by someone who does not administer every workspace on the account. ``` { "error": "Forbidden: audit:read permission required" } ``` `404` The workspace has no account. ``` { "error": "No account" } ``` `500` The log could not be read. ``` { "error": "The audit log could not be read" } ``` ## Subscriber pages HTML pages subscribers reach from email links. No authentication. ### GET /c/{id} **Web version of a sent campaign.** The "view in browser" page for a campaign that has been sent (`{{web_version_url}}` in the email). With `contact` and the signed `token` from that recipient's email the page is personalised — merge tags filled in and a working unsubscribe link; without them, or with a token that does not verify, it renders the campaign with empty merge fields. Drafts, scheduled and cancelled campaigns are 404. Not indexed. | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | yes | Campaign ID. | | `contact` | query | string | no | Recipient contact ID, for a personalised view. | | `token` | query | string | no | The recipient's signed token (the same one as their unsubscribe link). | #### Example request ``` curl -X GET "https://sendbeam.io/c/9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d?contact=9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d&token=string" ``` #### Responses `200` The campaign as an HTML page. No body (HTML page). `404` No sent campaign with this id. No body (HTML page). ### GET /p/{poll}/{option} **Where a poll answer lands.** The page a poll button in an email opens. The answer is recorded by the click itself — every link in a sent email is tracked per recipient, and this URL on the click event is the answer — so the page stores nothing and only thanks the reader. `poll` is the poll block's id and `option` the button's 1-based position; anything else is 404. Not indexed. Results are in `GET /api/v1/campaigns/{id}/report` under `polls`. | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `poll` | path | string | yes | The poll block's id. | | `option` | path | integer | yes | Which answer, 1-based. | #### Example request ``` curl -X GET "https://sendbeam.io/p/string/1" ``` #### Responses `200` A thank-you page. No body (HTML page). `404` Not a poll answer path. No body. ### GET /api/unsubscribe **Unsubscribe confirmation page.** Never changes state (link scanners follow GET). The signed `token` from the email link is required and is verified against `contact` and `campaign`; a link without a valid signature shows an "invalid link" page. A contact on one or more lists sees a preference page — each list ticked, the campaign's own list marked "this email" — with Save preferences (leave only the unticked lists) and Unsubscribe from everything. When another workspace on the same account has marked a list offerable, that page also carries a "More from us" tab — never the one it opens on — listing those lists unticked, with a Sign me up button. Otherwise shows a page with a button that POSTs back with the same parameters. Returns `text/html`. | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `contact` | query | string | yes | Contact ID from the email link. | | `campaign` | query | string | no | Campaign ID, to attribute the unsubscribe. | | `token` | query | string | yes | Signed token from the email link. Bound to the contact and campaign. | #### Example request ``` curl -X GET "https://sendbeam.io/api/unsubscribe?contact=9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d&campaign=9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" ``` #### Responses `200` Confirmation page, or an "already unsubscribed" page. No body (HTML page). `400` Missing `contact`. No body (HTML page). `403` Token missing, invalid or expired. No body (HTML page). `404` Unknown contact. No body (HTML page). ### POST /api/unsubscribe **Unsubscribe a contact.** Performs the unsubscribe. Accepts the parameters from the query string (RFC 8058 one-click, as mailbox providers POST to the `List-Unsubscribe` URL) or from a form-encoded body (the confirmation page). The signed `token` is required and must verify against `contact` and `campaign`; links without a valid signature (including those from emails sent before links were signed) are refused with `403`. Sets the contact to `unsubscribed`, adds the address to the workspace's suppression list and, if `campaign` is given, increments that campaign's unsubscribe count. With `action=join` — the "More from us" form on the preference page — nothing is unsubscribed: each id in `join` is a list that another workspace on the same account has marked offerable, and the contact is signed up to it through that workspace's ordinary signup path, so its suppression list, contact cap and double opt-in all apply; the page says only whether anything changed. Returns `text/html`. | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `contact` | query | string | no | Contact ID (required here or in the body). | | `campaign` | query | string | no | | | `token` | query | string | no | | #### Request body (optional) See the OpenAPI document for the body schema. #### Example request ``` curl -X POST "https://sendbeam.io/api/unsubscribe?contact=9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d&campaign=9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" ``` #### Responses `200` Unsubscribed, or already unsubscribed. No body (HTML page). `400` Missing `contact`. No body (HTML page). `403` Token missing, invalid or expired. No body (HTML page). `404` Unknown contact. No body (HTML page). `500` Update failed. No body (HTML page). ### GET /api/confirm-optin **Confirm a double opt-in subscription.** Target of the link in the double opt-in email. Marks the list membership confirmed and enrols `list_joined` automations. Returns `text/html`. | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `token` | query | string | yes | | | `list` | query | string | yes | | | `contact` | query | string | yes | | #### Example request ``` curl -X GET "https://sendbeam.io/api/confirm-optin?token=string&list=9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" ``` #### Responses `200` Subscription confirmed. No body (HTML page). `400` Missing parameters, or the link is expired/invalid/already used. No body (HTML page). ## MCP The Model Context Protocol server for AI agents: every operation in this document that a key may call, offered as a tool to Claude Code, Cursor and any other MCP client. See the guide at /docs/integrations/mcp. ### POST /api/v1/mcp **MCP server (Streamable HTTP).** A Model Context Protocol server over Streamable HTTP, stateless, JSON responses. Send JSON-RPC 2.0 messages (`initialize`, `ping`, `tools/list`, `tools/call`; a notification alone answers `202`). Authenticate with the workspace API key as `Authorization: Bearer sb_live_…` or in `x-api-key` — or, for an app connected through OAuth (Claude.ai and other clients that sign in rather than take a key), with the access token that sign-in produced; a `401` carries `WWW-Authenticate` naming the protected-resource metadata at `/.well-known/oauth-protected-resource/api/v1/mcp`. Either way the key's permissions decide which tools are listed and callable — each tool is one operation in this document, run with the same checks a direct call gets, and a write spends the same hourly allowance. Behind a feature flag (`404` when off). `GET` and `DELETE` answer `405`: there is no server stream and no session. #### Request body See the OpenAPI document for the body schema. #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/mcp" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": "string", "method": "initialize", "params": {} }' ``` #### Responses `200` The JSON-RPC response (or, for a batch, the array of responses). ``` {} ``` `202` The message was a notification; nothing to answer. No body. `400` Not JSON, an empty batch, or more than 20 requests in one. ``` {} ``` `401` No body. `404` The MCP server is not switched on for this workspace. ``` { "error": "Contact not found" } ``` ## E-commerce ### POST /api/v1/ecommerce/events **Report a cart, product-view, order, refund or cancellation event.** Feeds the three native e-commerce automation triggers (`cart_abandoned`, `product_viewed`, `order_placed` — see `TriggerType`): matches or creates the contact by email using the same rules as every other place a contact is upserted from an external event (a suppressed address is never (re)subscribed; the plan's contact cap is respected), then fires any automation built on the matching trigger — at once, the same as a form submission. `order_placed` also increases the contact's `lifetime_value` custom field (Number, auto-registered) by `value`; it is always ADDED, never overwritten, so orders accumulate. Two auth paths: an `x-api-key` with `ecommerce:write` and this JSON body (the WordPress plugin, n8n, Zapier, your own code); or, alongside it, Shopify's own webhook format verified by `X-Shopify-Hmac-Sha256` against a per-workspace secret set at Settings → E-commerce — post that path directly from Shopify Admin → Settings → Notifications → Webhooks, no app required (`?tenant=` identifies the workspace, since Shopify cannot send a custom header). A Shopify request with no configured secret, or a signature that does not verify, is refused; a request that verifies but names an unmapped topic, or a Shopify checkout/order with no known email yet, is acknowledged (`200`, `processed: false`) rather than treated as an error, since a store's webhook delivery must not be retried forever over something that will never resolve. #### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `type` | "cart_abandoned" \| "product_viewed" \| "order_placed" \| "order_refunded" \| "order_cancelled" | yes | `order_refunded` and `order_cancelled` adjust an `order_placed` reported earlier with the same `external_id`: they net the order off every revenue figure and the contact's `lifetime_value`, fire no automation, and never create a contact. `email` is optional for them. A refund without `value` is the whole remaining total; a second identical refund nets nothing. | | `email` | string | yes | | | `name` | string | no | Split on the first space into first_name/last_name; only ever fills in a name the contact does not already have. | | `value` | number | no | Cart/order total in the store's own currency. For `order_placed` it is required in practice: without it the event neither updates `lifetime_value` nor is stored as an order, so it can never be attributed. Optional otherwise. | | `currency` | string | no | | | `external_id` | string | no | The order's id in your store. With it, an `order_placed` is stored once (a retry is a no-op) and attributed to the campaign or automation email that preceded it — see /docs/ecommerce/revenue. Without it the order still fires triggers and moves `lifetime_value`, but is never stored or attributed. `order_id` is accepted as an alias. | | `order_id` | string | no | Alias of `external_id`. | | `placed_at` | string | no | When the order was placed. Defaults to now; attribution windows are measured back from it. | | `source` | string | no | Recorded as the contact's `source` only when this call creates a new contact. Defaults to `api`. | #### Example request ``` curl -X POST "https://sendbeam.io/api/v1/ecommerce/events" \ -H "x-api-key: sb_live_…" \ -H "Content-Type: application/json" \ -d '{ "type": "cart_abandoned", "email": "jane@example.com", "name": "string", "value": 1, "currency": "GBP", "external_id": "string", "order_id": "string", "placed_at": "2026-09-02T12:00:00Z", "source": "string" }' ``` #### Responses `200` Processed — or deliberately not (`processed: false`, with `reason` — `suppressed` or `contact_limit` for the x-api-key path, `unknown_order` for an adjustment naming an order this endpoint never stored). An adjustment answers with `adjusted` and the `amount` netted this time (0 when already applied). ``` { "ok": true, "processed": true, "contact_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33", "contact_created": false, "enrolled": true, "lifetime_value": 214.3 } ``` `400` Invalid JSON body, an invalid `type`/`email`/`value`, or (Shopify path) a missing `?tenant=`. ``` { "error": "Contact not found" } ``` `401` Missing/invalid API key, or (Shopify path) an unknown workspace, no webhook secret configured for it, or a signature that does not verify. ``` { "error": "Invalid Shopify webhook signature" } ``` `403` Missing `ecommerce:write` permission. ``` { "error": "Forbidden: ecommerce:write permission required" } ``` `500` Contact lookup/creation failed. ``` { "error": "Contact not found" } ``` ## Schemas JsonRpcRequest — A JSON-RPC 2.0 request as MCP defines it. For `tools/call`, `params` is `{ name, arguments }`. | Field | Type | Required | Description | | --- | --- | --- | --- | | `jsonrpc` | "2.0" | yes | | | `id` | string \| integer | no | Absent on a notification. | | `method` | "initialize" \| "ping" \| "tools/list" \| "tools/call" \| "notifications/initialized" | yes | | | `params` | object | no | | Example: ``` { "jsonrpc": "2.0", "id": "string", "method": "initialize", "params": {} } ``` Error | Field | Type | Required | Description | | --- | --- | --- | --- | | `error` | string | yes | | Example: ``` { "error": "Contact not found" } ``` ConnectForm — A signup form the site can put on a page. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `name` | string | yes | | Example: ``` { "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "name": "Newsletter signup" } ``` ConnectDomainRecord — One DNS record the site owner adds at their registrar. Every one is a CNAME, and none carries a priority. | Field | Type | Required | Description | | --- | --- | --- | --- | | `type` | string | yes | | | `name` | string | yes | The record name, fully qualified. | | `value` | string | yes | What it points at. | | `found` | boolean \| null | yes | Whether the last check resolved this record. `null` when it has never been checked — show that as "not checked yet" rather than as a record that is wrong. Show the tick per row: two of three records right looks like none at all without it. | Example: ``` { "type": "CNAME", "name": "send.example.com", "value": "send.9f2c.dom.sendbeam.io", "found": false } ``` ConnectDomain — The site's sending domain. Requires `domains:read`. | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | The host, with any leading `www.` removed. | | `verified` | boolean | yes | True once every record resolves and the provider agrees. Do not route a site's email through an unverified domain. | | `records` | array of ConnectDomainRecord | yes | The records still to add, in the order to show them. | | `domain_connect_url` | string \| null | yes | Open this in a new tab and the owner's own registrar adds the records for them. Present only when the registrar supports it and the owner has not already been through it. | | `checked_at` | string \| null | yes | When the records were last resolved. | Example: ``` { "name": "example.com", "verified": true, "records": [ { "type": "CNAME", "name": "send.example.com", "value": "send.9f2c.dom.sendbeam.io", "found": false } ], "domain_connect_url": null, "checked_at": null } ``` ConnectSender — The workspace's from line. `from_email` is null until one is chosen. | Field | Type | Required | Description | | --- | --- | --- | --- | | `from_name` | string | yes | | | `from_email` | string \| null | yes | | Example: ``` { "from_name": "Example Shop", "from_email": "hello@example.com" } ``` ConnectDomainState — Why `domain` is what it is. `ok` whenever there is one. `not_granted`: the owner left the sending-domain line unticked when they connected. `no_permission`: this key holds no `domains:read`. `no_site`: the key belongs to no site, so it belongs to no domain. `not_found`: the workspace no longer has a row for that host. `managed_host`: the host is a hosting company’s (example.wordpress.com, a bare IP address), which mail cannot be sent from. `unavailable`: the read, or the step that would have added it, failed. ``` { "type": "string", "enum": [ "ok", "not_granted", "no_permission", "no_site", "not_found", "managed_host", "unavailable" ], "description": "Why `domain` is what it is. `ok` whenever there is one. `not_granted`: the owner left the sending-domain line unticked when they connected. `no_permission`: this key holds no `domains:read`. `no_site`: the key belongs to no site, so it belongs to no domain. `not_found`: the workspace no longer has a row for that host. `managed_host`: the host is a hosting company’s (example.wordpress.com, a bare IP address), which mail cannot be sent from. `unavailable`: the read, or the step that would have added it, failed." } ``` Example: ``` "ok" ``` ConnectStatus | Field | Type | Required | Description | | --- | --- | --- | --- | | `workspace` | object | yes | | | `default_form` | ConnectForm \| null | yes | | | `domain` | ConnectDomain \| null | yes | Null without `domains:read`, and null for a key that belongs to no site. `domain_state` says which. | | `domain_state` | ConnectDomainState | yes | Why `domain` is what it is. `ok` whenever there is one. `not_granted`: the owner left the sending-domain line unticked when they connected. `no_permission`: this key holds no `domains:read`. `no_site`: the key belongs to no site, so it belongs to no domain. `not_found`: the workspace no longer has a row for that host. `managed_host`: the host is a hosting company’s (example.wordpress.com, a bare IP address), which mail cannot be sent from. `unavailable`: the read, or the step that would have added it, failed. | | `domain_note` | string \| null | yes | One sentence for the site owner when there is no domain — written for them, and carrying the real reason where there is one ("Your plan allows 1 sending domain per workspace."). Null when `domain_state` is `ok`. | | `sender` | ConnectSender | yes | The workspace's from line. `from_email` is null until one is chosen. | Example: ``` { "workspace": { "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "name": "Example Shop" }, "default_form": { "id": "8a7b6c5d-4e3f-2a1b-0c9d-8e7f6a5b4c3d", "name": "Newsletter signup" }, "domain": { "name": "example.com", "verified": true, "records": [ { "type": "CNAME", "name": "send.example.com", "value": "send.9f2c.dom.sendbeam.io", "found": true } ], "domain_connect_url": null, "checked_at": "2026-09-23T15:04:05Z" }, "domain_state": "ok", "domain_note": null, "sender": { "from_name": "Example Shop", "from_email": "hello@example.com" } } ``` MediaFile | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | File id (`.`), unique in the workspace; what `DELETE /media/{id}` takes. | | `url` | string | yes | Public URL to use in an email. | | `name` | string | yes | The original file name. | | `size` | integer | yes | Bytes. | | `type` | "image/png" \| "image/jpeg" \| "image/gif" \| "image/webp" | yes | | | `uploaded` | string | yes | | Example: ``` { "id": "string", "url": "string", "name": "string", "size": 1, "type": "image/png", "uploaded": "2026-09-02T12:00:00Z" } ``` MediaListing | Field | Type | Required | Description | | --- | --- | --- | --- | | `configured` | boolean | yes | False when image hosting is not enabled on the installation; the list is then empty and uploads answer `503`. | | `files` | array of MediaFile | yes | | | `count` | integer | yes | | | `used_bytes` | integer | yes | Storage the workspace is using. | | `quota_bytes` | integer | yes | The plan's cap. | | `max_bytes` | integer | yes | Largest single upload accepted. | | `types` | array of string | no | | | `truncated` | boolean | no | True when the workspace holds more than 5,000 images and the list stopped there. | Example: ``` { "configured": true, "files": [ { "id": "string", "url": "string", "name": "string", "size": 1, "type": "image/png", "uploaded": "2026-09-02T12:00:00Z" } ], "count": 1, "used_bytes": 1, "quota_bytes": 1, "max_bytes": 1, "types": [ "string" ], "truncated": true } ``` WebhookEvent — One of the events an endpoint can subscribe to. The list is generated from the platform's own event catalog, so it cannot drift from what is actually sent. ``` { "type": "string", "description": "One of the events an endpoint can subscribe to. The list is generated from the platform's own event catalog, so it cannot drift from what is actually sent.", "enum": [ "contact.created", "contact.updated", "contact.unsubscribed", "contact.resubscribed", "contact.bounced", "contact.complained", "contact.deleted", "contact.tag_added", "contact.tag_removed", "contact.list_joined", "contact.list_left", "email.sent", "email.delivered", "email.opened", "email.clicked", "email.bounced", "email.complained", "campaign.sent", "form.submitted", "domain.verified", "domain.failed", "workspace.paused", "workspace.resumed", "workspace.health_warning", "automation.failed", "automation.step_reached" ], "example": "contact.created" } ``` Example: ``` "contact.created" ``` WebhookDeliveryStatus — `pending` is queued or waiting for its next retry, `delivered` got a 2xx, `failed` used up its attempts, `abandoned` was dropped because the endpoint was disabled or deleted first. ``` { "type": "string", "description": "`pending` is queued or waiting for its next retry, `delivered` got a 2xx, `failed` used up its attempts, `abandoned` was dropped because the endpoint was disabled or deleted first.", "enum": [ "pending", "delivered", "failed", "abandoned" ] } ``` Example: ``` "pending" ``` WebhookFilters — Optional scope. Omitted or `{}` means unfiltered — every event of a subscribed type fires, which is also every existing endpoint's behaviour before this field existed. A dimension you set must be present on the event itself or that endpoint is skipped for it (an endpoint scoped to a list, for instance, never receives a listless event); dimensions you set combine with AND, ids within one dimension combine with OR. Every id is checked against your own workspace at write time; an id that is not yours is refused, not silently ignored. At most 100 ids per key. | Field | Type | Required | Description | | --- | --- | --- | --- | | `list_ids` | array of string | no | Only contact.list_joined / contact.list_left for these lists (and any contact event tied to one of them). | | `form_ids` | array of string | no | Only form.submitted for these forms (and contacts created through one of them). | | `tag_ids` | array of string | no | Only contact.tag_added / contact.tag_removed for these tags. | | `campaign_ids` | array of string | no | Only campaign.sent, and the email.* events, for these campaigns. | | `domain_ids` | array of string | no | Only domain.verified / domain.failed for these sending domains. | Example: ``` { "list_ids": [ "b3f1c2a4-1111-4a2b-8c3d-0000000000aa" ] } ``` WebhookEndpoint — A registered endpoint. The signing secret is never part of this shape. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `url` | string | yes | | | `description` | string \| null | yes | Your own note about what this endpoint is for. | | `event_types` | array of WebhookEvent | yes | The events this endpoint receives. | | `filters` | WebhookFilters | yes | Optional scope. Omitted or `{}` means unfiltered — every event of a subscribed type fires, which is also every existing endpoint's behaviour before this field existed. A dimension you set must be present on the event itself or that endpoint is skipped for it (an endpoint scoped to a list, for instance, never receives a listless event); dimensions you set combine with AND, ids within one dimension combine with OR. Every id is checked against your own workspace at write time; an id that is not yours is refused, not silently ignored. At most 100 ids per key. | | `enabled` | boolean | yes | A disabled endpoint receives nothing; anything queued for it while disabled is abandoned. | | `disabled_reason` | string \| null | yes | Set when SendBeam disabled the endpoint itself after sustained failure; null when you disabled it or it is enabled. | | `consecutive_failures` | integer | yes | Failed deliveries in a row. Reset to 0 by the next success, and by re-enabling an auto-disabled endpoint. | | `last_success_at` | string \| null | yes | | | `last_failure_at` | string \| null | yes | | | `created_at` | string | yes | | Example: ``` { "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "url": "https://example.com/hooks/sendbeam", "description": "Sync new contacts into the CRM", "event_types": [ "contact.created" ], "filters": { "list_ids": [ "b3f1c2a4-1111-4a2b-8c3d-0000000000aa" ] }, "enabled": true, "disabled_reason": null, "consecutive_failures": 0, "last_success_at": null, "last_failure_at": null, "created_at": "2026-09-02T12:00:00Z" } ``` WebhookCreate | Field | Type | Required | Description | | --- | --- | --- | --- | | `url` | string | yes | Public `https://` URL to POST to. Must not resolve to a private, loopback or link-local address, and must not contain credentials. | | `description` | string | no | Optional note for your own reference. | | `event_types` | array of WebhookEvent | yes | At least one event. Duplicates are collapsed; an unknown name is refused and named back to you. | | `filters` | WebhookFilters | no | Optional scope. Omitted or `{}` means unfiltered — every event of a subscribed type fires, which is also every existing endpoint's behaviour before this field existed. A dimension you set must be present on the event itself or that endpoint is skipped for it (an endpoint scoped to a list, for instance, never receives a listless event); dimensions you set combine with AND, ids within one dimension combine with OR. Every id is checked against your own workspace at write time; an id that is not yours is refused, not silently ignored. At most 100 ids per key. | Example: ``` { "url": "https://example.com/hooks/sendbeam", "description": "Sync new contacts into the CRM", "event_types": [ "contact.created", "contact.unsubscribed" ], "filters": { "list_ids": [ "b3f1c2a4-1111-4a2b-8c3d-0000000000aa" ] } } ``` WebhookUpdate — Every field is optional; send only what you want changed. | Field | Type | Required | Description | | --- | --- | --- | --- | | `url` | string | no | | | `description` | string \| null | no | | | `event_types` | array of WebhookEvent | no | | | `filters` | WebhookFilters | no | Optional scope. Omitted or `{}` means unfiltered — every event of a subscribed type fires, which is also every existing endpoint's behaviour before this field existed. A dimension you set must be present on the event itself or that endpoint is skipped for it (an endpoint scoped to a list, for instance, never receives a listless event); dimensions you set combine with AND, ids within one dimension combine with OR. Every id is checked against your own workspace at write time; an id that is not yours is refused, not silently ignored. At most 100 ids per key. | | `enabled` | boolean | no | Set to `true` to re-enable an endpoint that was auto-disabled; that also clears `disabled_reason` and resets `consecutive_failures`. | Example: ``` { "url": "https://example.com/hooks/sendbeam-v2", "description": null, "event_types": [ "contact.created" ], "filters": { "list_ids": [ "b3f1c2a4-1111-4a2b-8c3d-0000000000aa" ] }, "enabled": true } ``` WebhookCreated — The created endpoint, plus the signing secret. This is the only time the secret is returned in the clear. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `url` | string | yes | | | `description` | string \| null | yes | | | `event_types` | array of WebhookEvent | yes | | | `filters` | WebhookFilters | yes | Optional scope. Omitted or `{}` means unfiltered — every event of a subscribed type fires, which is also every existing endpoint's behaviour before this field existed. A dimension you set must be present on the event itself or that endpoint is skipped for it (an endpoint scoped to a list, for instance, never receives a listless event); dimensions you set combine with AND, ids within one dimension combine with OR. Every id is checked against your own workspace at write time; an id that is not yours is refused, not silently ignored. At most 100 ids per key. | | `enabled` | boolean | yes | | | `secret` | string | yes | 64 hexadecimal characters. Store it now — it is never shown again, only replaced. | | `created_at` | string | yes | | Example: ``` { "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "url": "https://example.com/hooks/sendbeam", "description": "Sync new contacts into the CRM", "event_types": [ "contact.created" ], "filters": { "list_ids": [ "b3f1c2a4-1111-4a2b-8c3d-0000000000aa" ] }, "enabled": true, "secret": "9f2c1a0b3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8", "created_at": "2026-09-02T12:00:00Z" } ``` WebhookSecret | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `secret` | string | yes | The new signing secret, 64 hexadecimal characters. The previous one is invalid from this moment. | Example: ``` { "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "secret": "3b7d4bad9bdd2b0d7b3dcb6d9f2c1a0b3d4e5f60718293a4b5c6d7e8f90a1b2c3" } ``` WebhookDelivery — One attempt record from the delivery log. `payload` is present only when the request asked for it with `include=payload`. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Also the `id` field inside the payload and the `X-SendBeam-Delivery` header, stable across retries — use it to deduplicate. | | `event_type` | WebhookEvent | yes | One of the events an endpoint can subscribe to. The list is generated from the platform's own event catalog, so it cannot drift from what is actually sent. | | `status` | WebhookDeliveryStatus | yes | `pending` is queued or waiting for its next retry, `delivered` got a 2xx, `failed` used up its attempts, `abandoned` was dropped because the endpoint was disabled or deleted first. | | `attempts` | integer | yes | How many times it has been POSTed so far. | | `last_status_code` | integer \| null | yes | HTTP status of the most recent attempt, null if the request never got a response. | | `last_error` | string \| null | yes | Why the most recent attempt failed, truncated to 500 characters. | | `delivered_at` | string \| null | yes | | | `created_at` | string | yes | | | `payload` | object | no | The exact JSON body sent. Only present with `include=payload`. | Example: ``` { "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "event_type": "contact.created", "status": "pending", "attempts": 1, "last_status_code": 200, "last_error": null, "delivered_at": null, "created_at": "2026-09-02T12:00:00Z", "payload": {} } ``` WebhookTestRequest | Field | Type | Required | Description | | --- | --- | --- | --- | | `event` | any | no | Which event name to put in the test payload. Defaults to `contact.created`. | Example: ``` { "event": null } ``` WebhookTestResult | Field | Type | Required | Description | | --- | --- | --- | --- | | `ok` | boolean | yes | True when the endpoint answered with a 2xx. | | `status` | integer | no | The HTTP status the endpoint returned, when it returned one. | | `error` | string | no | Why the attempt failed: a timeout, a connection problem, a redirect (which is never followed), or the URL no longer passing validation. | Example: ``` { "ok": true, "status": 200, "error": "timed out" } ``` ImportSourceCredentials | Field | Type | Required | Description | | --- | --- | --- | --- | | `source` | "mailchimp" \| "mailerlite" \| "kit" \| "brevo" \| "emailoctopus" | yes | | | `credentials` | object | yes | | Example: ``` { "source": "mailchimp", "credentials": { "api_key": "string", "dc": "string" } } ``` ImportSourceList | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Audience / group / tag / list id; `""` is the "All subscribers" entry (MailerLite, Kit). | | `name` | string | yes | | | `member_count` | integer | yes | Subscribed members as reported by the platform; null when it reports none (Kit tags). | | `suppressed_count` | integer | no | Unsubscribed + bounced (+ complained) as reported by the platform. | | `kind` | "audience" \| "group" \| "tag" \| "all" | no | | Example: ``` { "id": "string", "name": "string", "member_count": 1, "suppressed_count": 1, "kind": "audience" } ``` ImportCapabilities | Field | Type | Required | Description | | --- | --- | --- | --- | | `platform` | string | yes | | | `label` | string | yes | | | `provides` | object | yes | Keys: subscribed_at, consent_ip, consent_timestamp, consent_source, bounces, complaints, unsubscribes, tags, custom_fields. | | `notes` | array of string | yes | | | `limitations` | array of string | yes | | Example: ``` { "platform": "string", "label": "string", "provides": {}, "notes": [ "string" ], "limitations": [ "string" ] } ``` ImportConnectResult | Field | Type | Required | Description | | --- | --- | --- | --- | | `ok` | boolean | yes | | | `source` | string | yes | | | `label` | string | yes | | | `account` | object | yes | | | `lists` | array of ImportSourceList | yes | | | `requires_list` | boolean | yes | True when `list_ids` must be given to `/run` (Mailchimp, EmailOctopus). | | `capabilities` | ImportCapabilities | yes | | | `subrequests` | integer | no | Calls made to the platform for this check. | Example: ``` { "ok": true, "source": "string", "label": "string", "account": { "name": "string", "email": "string" }, "lists": [ { "id": "string", "name": "string", "member_count": 1, "suppressed_count": 1, "kind": "audience" } ], "requires_list": true, "capabilities": { "platform": "string", "label": "string", "provides": {}, "notes": [ "string" ], "limitations": [ "string" ] }, "subrequests": 1 } ``` ImportRunRequest ``` { "allOf": [ { "$ref": "#/components/schemas/ImportSourceCredentials" }, { "type": "object", "properties": { "list_ids": { "type": "array", "items": { "type": "string" }, "maxItems": 20, "description": "Audience ids (Mailchimp, required), group ids (MailerLite), tag ids (Kit), list ids (Brevo), or list ids (EmailOctopus, required). Empty → all subscribers (MailerLite, Kit, Brevo)." }, "options": { "type": "object", "properties": { "tags": { "type": "array", "items": { "type": "string" }, "description": "Tags attached to every imported contact (created when missing)." }, "list_id": { "type": "string", "format": "uuid", "description": "Add every subscribed contact to this list (unconfirmed on a double opt-in list; no email is sent)." }, "include_unsubscribed": { "type": "boolean", "default": true, "description": "`false` skips unsubscribed / bounced / complained records instead of suppressing them (`skipped_suppressed`). Keep `true` to carry your opt-outs across." }, "max_contacts": { "type": "integer", "minimum": 1, "maximum": 25000, "description": "Stop after this many contacts have been written (rounded up to the end of the source page)." }, "source_label": { "type": "string", "maxLength": 40, "default": "-import", "description": "Source label on new contacts." }, "cursor": { "type": "object", "description": "The `next_hint.cursor` of a previous truncated run of the same source and `list_ids`." } } } } } ] } ``` Example: ``` null ``` ImportRunResult | Field | Type | Required | Description | | --- | --- | --- | --- | | `ok` | boolean | yes | | | `source` | string | yes | | | `imported` | integer | yes | New contacts created. | | `updated` | integer | yes | Existing contacts changed (fields merged, status escalated). | | `skipped_duplicates` | integer | yes | Records whose address already existed. | | `suppressed` | integer | yes | Opted-out addresses recorded on the suppression list this run (new, upgraded or already there). | | `skipped_unconfirmed` | integer | yes | Pending / unconfirmed / inactive people — never imported. | | `skipped_undeliverable` | integer | no | Records dropped because they can never be delivered: placeholder addresses, plus records whose domain cannot receive mail (no such domain, null MX, or neither MX nor A records). | | `undeliverable_domains` | array of object | no | Those domains, most records first, at most ten. | | `skipped_placeholder` | integer | no | Of `skipped_undeliverable`, records that were placeholder addresses (example domains, reserved test domains, `abuse@` / `postmaster@`). | | `placeholder_addresses` | PlaceholderAddresses | no | Placeholder addresses, named, at most twenty: `example.com` / `.net` / `.org` and their subdomains, the reserved `.test`, `.invalid`, `.localhost` and `.example` domains, and the `abuse@` / `postmaster@` role mailboxes. None can ever be a real subscriber. | | `skipped_other` | integer | yes | Records the platform marks as archived or transactional. | | `skipped_suppressed` | integer | yes | Opted-out records skipped because `include_unsubscribed` was `false`. | | `invalid` | integer | yes | Records without a usable email address. | | `tags_created` | integer | yes | | | `tags_attached` | integer | yes | | | `tags_skipped` | boolean | yes | Always `false` (tags now arrive inline from every platform); kept for compatibility. | | `tags_truncated` | integer | no | Mailchimp only: members with more than 50 tags whose full tag set could not be fetched within this run's lookup budget; they carry the custom field `mailchimp_tags_truncated`. | | `custom_field_keys` | array of string | yes | | | `list_added` | integer | yes | | | `list_pending_confirmation` | integer | yes | | | `contacts_read` | integer | yes | Records read from the platform this run. | | `contacts_written` | integer | yes | Records handed to the importer this run (imported + updated + duplicates). | | `pages` | integer | yes | Source pages written. | | `subrequests` | integer | yes | Requests made to the platform. | | `db_requests` | integer | yes | Estimated database round-trips. | | `duration_ms` | integer | yes | | | `truncated` | boolean | yes | True when the run stopped before the source was exhausted; continue with `next_hint.cursor`. | | `stop_reason` | "max_contacts" \| "subrequests" \| "time" \| "contact_limit" \| null | yes | | | `next_hint` | object | yes | | | `capabilities` | ImportCapabilities | yes | | Example: ``` { "ok": true, "source": "string", "imported": 1, "updated": 1, "skipped_duplicates": 1, "suppressed": 1, "skipped_unconfirmed": 1, "skipped_undeliverable": 1, "undeliverable_domains": [ { "domain": "string", "rows": 1 } ], "skipped_placeholder": 1, "placeholder_addresses": [ { "email": "string", "reason": "string" } ], "skipped_other": 1, "skipped_suppressed": 1, "invalid": 1, "tags_created": 1, "tags_attached": 1, "tags_skipped": true, "tags_truncated": 1, "custom_field_keys": [ "string" ], "list_added": 1, "list_pending_confirmation": 1, "contacts_read": 1, "contacts_written": 1, "pages": 1, "subrequests": 1, "db_requests": 1, "duration_ms": 1, "truncated": true, "stop_reason": "max_contacts", "next_hint": { "message": "string", "cursor": {}, "list_ids": [ "string" ] }, "capabilities": { "platform": "string", "label": "string", "provides": {}, "notes": [ "string" ], "limitations": [ "string" ] } } ``` Pagination | Field | Type | Required | Description | | --- | --- | --- | --- | | `page` | integer | yes | | | `limit` | integer | yes | | | `total` | integer | yes | Total items across all pages. | | `total_pages` | integer | yes | | Example: ``` { "page": 1, "limit": 50, "total": 1234, "total_pages": 25 } ``` ContactStatus — `archived` is out of the working list (hidden from listings unless asked for, never mailed, not counted) but not blocked. ``` { "type": "string", "enum": [ "subscribed", "unsubscribed", "bounced", "complained", "archived" ], "description": "`archived` is out of the working list (hidden from listings unless asked for, never mailed, not counted) but not blocked." } ``` Example: ``` "subscribed" ``` SuppressionSource — Where a suppression came from: `link` (unsubscribe link or preference page), `provider` (bounce or complaint report), `import`, `owner` (blocked in the app or with `suppress: true`), `delete` (a deleted contact; an admin may lift it), `erasure` (a request to be forgotten; hash only, never lifted). Null on rows written before 2026-09-14. ``` { "type": "string", "enum": [ "link", "provider", "import", "owner", "delete", "erasure" ], "description": "Where a suppression came from: `link` (unsubscribe link or preference page), `provider` (bounce or complaint report), `import`, `owner` (blocked in the app or with `suppress: true`), `delete` (a deleted contact; an admin may lift it), `erasure` (a request to be forgotten; hash only, never lifted). Null on rows written before 2026-09-14." } ``` Example: ``` "link" ``` AuditEvent | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `created_at` | string | yes | | | `action` | string | yes | One of the actions listed on the `action` parameter of `GET /api/v1/account/audit-log`. New actions may be added; treat unknown ones as informational. | | `actor_type` | "user" \| "api_key" \| "scim" \| "system" | yes | | | `actor_id` | string \| null | no | The user id or API key id, null for SCIM and the system. | | `actor_label` | string \| null | no | The actor's email address, the API key's name, `SCIM` or `System`. | | `target_type` | string \| null | no | What was acted on: `api_key`, `user`, `invitation`, `webhook`, `domain`, `connection`, `tenant`, `account`… | | `target_id` | string \| null | no | | | `target_label` | string \| null | no | The target as a person would name it: a key name, an email address, a URL, a domain. | | `workspace_id` | string \| null | no | Null for an account-level event (policy, SCIM, erasure). | | `workspace_name` | string \| null | no | | | `metadata` | object | yes | Details specific to the action, credentials never included: a role, a permission list, a before/after pair, a sign-in method. | | `ip` | string \| null | no | The network address the request came from. | Example: ``` { "id": "0192b6a4-6d3e-7c1a-9f2e-3b4c5d6e7f80", "created_at": "2026-09-25T09:12:41.000Z", "action": "team.role_changed", "actor_type": "user", "actor_id": "4f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "actor_label": "jane@example.com", "target_type": "user", "target_id": "7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a", "target_label": "sam@example.com", "workspace_id": "2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f", "workspace_name": "Harbour Lane", "metadata": { "role": "admin" }, "ip": "203.0.113.7" } ``` SuppressionRow | Field | Type | Required | Description | | --- | --- | --- | --- | | `email_hash` | string | yes | SHA-256 hex of the normalised address — the key for `POST /api/v1/suppressions/lift`. | | `reason` | SuppressionReason | yes | Why an address is suppressed. Strength order: complained > bounced > unsubscribed > deleted. | | `source` | SuppressionSource \| null | yes | | | `created_at` | string | yes | | | `email_masked` | string \| null | yes | `j***@example.com`: stored on rows written since 2026-09-14, derived from a still-existing contact row otherwise, null when neither (older rows, erasures). | | `email_domain` | string \| null | yes | | | `contact` | object \| null | yes | The contact row that still carries this address, when there is one — the only place the full address appears. | | `liftable` | boolean | yes | True for a `deleted` row that is not an erasure. | Example: ``` { "email_hash": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8", "reason": "deleted", "source": "delete", "created_at": "2026-09-14T09:12:41.000Z", "email_masked": "j***@example.com", "email_domain": "example.com", "contact": null, "liftable": true } ``` SuppressionReason — Why an address is suppressed. Strength order: complained > bounced > unsubscribed > deleted. ``` { "type": "string", "enum": [ "unsubscribed", "bounced", "complained", "deleted" ], "description": "Why an address is suppressed. Strength order: complained > bounced > unsubscribed > deleted." } ``` Example: ``` "unsubscribed" ``` SuppressionImportReason — Reasons an import may set (`deleted` is reserved for contact deletion). Aliases such as `cleaned`, `cancelled`, `junk` and `spam` are accepted and mapped. ``` { "type": "string", "enum": [ "unsubscribed", "bounced", "complained" ], "description": "Reasons an import may set (`deleted` is reserved for contact deletion). Aliases such as `cleaned`, `cancelled`, `junk` and `spam` are accepted and mapped." } ``` Example: ``` "unsubscribed" ``` SuppressionImport | Field | Type | Required | Description | | --- | --- | --- | --- | | `reason` | SuppressionImportReason | no | Reasons an import may set (`deleted` is reserved for contact deletion). Aliases such as `cleaned`, `cancelled`, `junk` and `spam` are accepted and mapped. | | `entries` | array of string \| object | yes | | Example: ``` { "reason": "unsubscribed", "entries": [ "jane@example.com" ] } ``` SuppressionImportResult | Field | Type | Required | Description | | --- | --- | --- | --- | | `received` | integer | yes | Rows or entries in the request. Equals added + upgraded + unchanged + invalid. | | `added` | integer | yes | Addresses that were not on the list before. | | `upgraded` | integer | yes | Addresses already on the list whose reason became stronger. | | `unchanged` | integer | yes | Already on the list with the same or a stronger reason, plus repeats within the request. | | `invalid` | integer | yes | Rows skipped: malformed address, over 254 characters, or an unknown reason. | | `contacts_updated` | integer | yes | Existing contacts switched from `subscribed` to the imported status. | | `by_reason` | object | yes | Valid, de-duplicated addresses split by the reason applied. | Example: ``` { "received": 3, "added": 2, "upgraded": 1, "unchanged": 0, "invalid": 0, "contacts_updated": 1, "by_reason": { "unsubscribed": 1, "bounced": 1, "complained": 1 } } ``` SuppressionSummary | Field | Type | Required | Description | | --- | --- | --- | --- | | `count` | integer | yes | Addresses on the list, all reasons. | | `by_reason` | object | yes | | Example: ``` { "count": 1204, "by_reason": { "unsubscribed": 1130, "bounced": 61, "complained": 9, "deleted": 4 } } ``` SuppressionCheck | Field | Type | Required | Description | | --- | --- | --- | --- | | `email` | string | yes | The address as normalised (trimmed, lower-cased). | | `suppressed` | boolean | yes | | | `reason` | SuppressionReason | no | Why an address is suppressed. Strength order: complained > bounced > unsubscribed > deleted. | | `created_at` | string | no | When the address was added to the list. Present when suppressed. | Example: ``` { "email": "bob@example.com", "suppressed": true, "reason": "bounced", "created_at": "2026-09-03T09:12:41.000Z" } ``` CustomFieldDefinition | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `key` | string | yes | Immutable. What `{{custom_fields.}}`, `custom_fields.` in rules, CSV headers and API payloads use. | | `label` | string | yes | What the app shows; freely editable. | | `type` | "text" \| "number" \| "boolean" \| "date" \| "dropdown" | yes | | | `options` | array of string | yes | Dropdown choices, in order; empty for other types. | | `position` | integer | no | | | `uses` | integer \| null | no | Contacts carrying the key (list endpoint only; null when usage counts are unavailable). | | `created_at` | string | no | | | `updated_at` | string | no | | Example: ``` { "id": "5e2b8c1a-4d3f-4a6b-9c8d-1e2f3a4b5c6d", "key": "plan", "label": "Plan", "type": "dropdown", "options": [ "free", "pro", "business" ], "position": 0, "created_at": "2026-09-14T09:00:00.000Z", "updated_at": "2026-09-14T09:00:00.000Z" } ``` CustomFieldInput | Field | Type | Required | Description | | --- | --- | --- | --- | | `key` | string | yes | | | `label` | string | no | Defaults to the key with underscores as spaces, capitalised. | | `type` | "text" \| "number" \| "boolean" \| "date" \| "dropdown" | no | | | `options` | array of string | no | Required for `dropdown`; ignored otherwise. | | `confirm_violations` | boolean | no | Declare the field even though some stored values do not fit the type (they are kept as they are). | Example: ``` { "key": "string", "label": "string", "type": "text", "options": [ "string" ], "confirm_violations": false } ``` CustomFieldViolations | Field | Type | Required | Description | | --- | --- | --- | --- | | `count` | integer | no | Contacts whose stored value does not fit. | | `examples` | array of string | no | Up to five offending values. | | `partial` | boolean | no | True when only the most common values were checked, so `count` is a floor. | Example: ``` { "count": 1, "examples": [ "string" ], "partial": true } ``` CustomFields — A flat object of at most 50 keys. Keys are 1–64 characters (`__proto__`, `constructor` and `prototype` are refused); values are strings of at most 200 characters, finite numbers or booleans. `null` values are dropped; arrays and nested objects are rejected with `400`. **Typed fields.** A key declared under Custom fields (`GET /custom-fields`) takes only a value of its type — a `number` field a number or a numeric string, a `boolean` field a boolean or `yes`/`no`/`true`/`false`, a `date` field a real calendar date (`YYYY-MM-DD`, an ISO date-time, or `DD/MM/YYYY`; stored as `YYYY-MM-DD`), a `dropdown` field one of its options — otherwise `400` `custom_fields. must be …`; an empty string clears a typed field. A key no field names is registered as a `text` field when it is first written. Available in emails as `{{custom_fields.}}` and in segment and automation rules as `custom_fields.`. Signup forms cap each value at 200 characters. ``` { "type": "object", "maxProperties": 50, "propertyNames": { "minLength": 1, "maxLength": 64 }, "additionalProperties": { "oneOf": [ { "type": "string", "maxLength": 200 }, { "type": "number" }, { "type": "boolean" } ] }, "description": "A flat object of at most 50 keys. Keys are 1–64 characters (`__proto__`, `constructor` and `prototype` are refused); values are strings of at most 200 characters, finite numbers or booleans. `null` values are dropped; arrays and nested objects are rejected with `400`. **Typed fields.** A key declared under Custom fields (`GET /custom-fields`) takes only a value of its type — a `number` field a number or a numeric string, a `boolean` field a boolean or `yes`/`no`/`true`/`false`, a `date` field a real calendar date (`YYYY-MM-DD`, an ISO date-time, or `DD/MM/YYYY`; stored as `YYYY-MM-DD`), a `dropdown` field one of its options — otherwise `400` `custom_fields. must be …`; an empty string clears a typed field. A key no field names is registered as a `text` field when it is first written. Available in emails as `{{custom_fields.}}` and in segment and automation rules as `custom_fields.`. Signup forms cap each value at 200 characters.", "example": { "plan": "pro", "region": "London", "seats": 3, "trial": false } } ``` Example: ``` { "plan": "pro", "region": "London", "seats": 3, "trial": false } ``` Contact | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `tenant_id` | string | yes | Workspace ID. | | `email` | string | yes | | | `first_name` | string | yes | | | `last_name` | string | yes | | | `status` | ContactStatus | yes | `archived` is out of the working list (hidden from listings unless asked for, never mailed, not counted) but not blocked. | | `custom_fields` | CustomFields | yes | A flat object of at most 50 keys. Keys are 1–64 characters (`__proto__`, `constructor` and `prototype` are refused); values are strings of at most 200 characters, finite numbers or booleans. `null` values are dropped; arrays and nested objects are rejected with `400`. **Typed fields.** A key declared under Custom fields (`GET /custom-fields`) takes only a value of its type — a `number` field a number or a numeric string, a `boolean` field a boolean or `yes`/`no`/`true`/`false`, a `date` field a real calendar date (`YYYY-MM-DD`, an ISO date-time, or `DD/MM/YYYY`; stored as `YYYY-MM-DD`), a `dropdown` field one of its options — otherwise `400` `custom_fields. must be …`; an empty string clears a typed field. A key no field names is registered as a `text` field when it is first written. Available in emails as `{{custom_fields.}}` and in segment and automation rules as `custom_fields.`. Signup forms cap each value at 200 characters. | | `source` | string | yes | e.g. `api`, `import`, `form`, or whatever was supplied at creation. | | `language` | string \| null | no | ISO 639-1 code of the language the person reads in, or null when not known. Set through the API, an import column, a form field named `language`, or the contact page; never inferred. | | `subscribed_at` | string \| null | yes | | | `unsubscribed_at` | string \| null | yes | | | `archived_at` | string \| null | no | Set while `status` is `archived`. Archiving never writes to the suppression list, so the address stays re-addable. | | `archived_from` | string \| null | no | The status the contact held before it was archived, restored on un-archive. A subscribed contact whose address was blocked meanwhile comes back unsubscribed. | | `created_at` | string | yes | | Example: ``` { "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "email": "jane@example.com", "first_name": "Jane", "last_name": "Doe", "status": "subscribed", "custom_fields": { "plan": "pro" }, "source": "api", "language": "en", "subscribed_at": "2026-09-01T10:00:00.000Z", "unsubscribed_at": null, "created_at": "2026-09-01T10:00:00.000Z" } ``` ContactSummary | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `email` | string | yes | | | `first_name` | string | yes | | | `last_name` | string | yes | | Example: ``` { "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "email": "jane@example.com", "first_name": "string", "last_name": "string" } ``` PlaceholderAddresses — Placeholder addresses, named, at most twenty: `example.com` / `.net` / `.org` and their subdomains, the reserved `.test`, `.invalid`, `.localhost` and `.example` domains, and the `abuse@` / `postmaster@` role mailboxes. None can ever be a real subscriber. ``` { "type": "array", "maxItems": 20, "description": "Placeholder addresses, named, at most twenty: `example.com` / `.net` / `.org` and their subdomains, the reserved `.test`, `.invalid`, `.localhost` and `.example` domains, and the `abuse@` / `postmaster@` role mailboxes. None can ever be a real subscriber.", "items": { "type": "object", "required": [ "email", "reason" ], "properties": { "email": { "type": "string" }, "reason": { "type": "string" } } } } ``` Example: ``` [ { "email": "string", "reason": "string" } ] ``` AudiencePreview | Field | Type | Required | Description | | --- | --- | --- | --- | | `audience` | object | yes | | | `can_send` | boolean | yes | | | `reason` | string | no | Present when `can_send` is false: the error a send would return. | | `placeholders` | array of object | yes | Recipients that can never receive mail, sorted by address, at most twenty named. | | `placeholder_count` | integer | yes | Every placeholder recipient, including any beyond the twenty named. | Example: ``` { "audience": { "send_to_type": "all", "send_to_id": null, "recipients": 1 }, "can_send": true, "reason": "string", "placeholders": [ { "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "email": "string", "kind": "example_domain", "reason": "string" } ], "placeholder_count": 1 } ``` ContactUpdate — Only these keys are accepted; anything else is a 400. | Field | Type | Required | Description | | --- | --- | --- | --- | | `email` | string | no | | | `first_name` | string | no | | | `last_name` | string | no | | | `status` | ContactStatus | no | `archived` is out of the working list (hidden from listings unless asked for, never mailed, not counted) but not blocked. | | `custom_fields` | object | no | Merged into the contact's custom fields: keys you send are set, a key sent as `null` is removed, every other key is kept. Values follow the CustomFields limits. Send `replace_custom_fields: true` to replace the whole object instead. | | `source` | string | no | | | `language` | string \| null | no | ISO 639-1 code; `null` or an empty string clears it. An unknown code is a 400. | | `replace_custom_fields` | boolean | no | With `true`, `custom_fields` replaces the contact's whole custom-field object (keys not sent are dropped) instead of merging. Requires `custom_fields` in the same request. | | `resubscribe` | boolean | no | Set `true` when the person has given you new consent after unsubscribing. Sets `status` to `subscribed` (cannot be combined with another `status`), `subscribed_at` to now, clears `unsubscribed_at`, leaves `source` as it is, and removes the address's `unsubscribed` suppression entry. A bounced, complained or deleted address is still refused with `409`. | | `suppress` | boolean | no | With `status: "unsubscribed"`, also block the address: it goes on the workspace's suppression list as `unsubscribed`, so no later import, API call or signup form can re-add it as a subscriber until the person opts in again. Without it an unsubscribe is a status only. Cannot be combined with `resubscribe`. | Example: ``` { "first_name": "Janet", "custom_fields": { "plan": "business", "trial_ends": null } } ``` AssignedTag | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `name` | string | yes | | | `color` | string | yes | | | `assigned_at` | string | yes | | Example: ``` { "id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33", "name": "Customer", "color": "#2563EB", "assigned_at": "2026-09-01T10:05:00.000Z" } ``` ContactWithTags ``` { "allOf": [ { "$ref": "#/components/schemas/Contact" }, { "type": "object", "required": [ "tags" ], "properties": { "tags": { "type": "array", "items": { "$ref": "#/components/schemas/AssignedTag" } } } } ] } ``` Example: ``` null ``` Tag | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `tenant_id` | string | yes | | | `name` | string | yes | | | `color` | string | yes | | | `created_at` | string | yes | | Example: ``` { "id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Customer", "color": "#2563EB", "created_at": "2026-08-20T09:00:00.000Z" } ``` ContactTag | Field | Type | Required | Description | | --- | --- | --- | --- | | `contact_id` | string | yes | | | `tag_id` | string | yes | | | `created_at` | string | yes | | Example: ``` { "contact_id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "tag_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33", "created_at": "2026-09-01T10:05:00.000Z" } ``` List | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `tenant_id` | string | yes | | | `name` | string | yes | | | `description` | string | yes | | | `double_optin` | boolean | yes | When true, new members must confirm by email before campaigns reach them. | | `offerable_across_account` | boolean | no | When true, the list is offered — unticked, under "More from us" on the preference page — to people who subscribed on another workspace of the same account. Nobody joins unless they tick it themselves, and they join through this workspace's ordinary signup path: its suppression list, its plan's contact cap and its double opt-in all apply. Never appears on a form or in a campaign link, and never shows anyone this list's contacts. False by default; only a signed-in workspace admin of a workspace that belongs to an account can turn it on. | | `created_at` | string | yes | | Example: ``` { "id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Newsletter", "description": "Weekly product updates", "double_optin": false, "offerable_across_account": false, "created_at": "2026-08-20T09:00:00.000Z" } ``` ListWithCount ``` { "allOf": [ { "$ref": "#/components/schemas/List" }, { "type": "object", "required": [ "member_count" ], "properties": { "member_count": { "type": "integer" } } } ] } ``` Example: ``` null ``` ListContact — A list membership. The confirmation token behind a pending double opt-in only ever travels in the confirmation email and is never returned by the API. | Field | Type | Required | Description | | --- | --- | --- | --- | | `list_id` | string | yes | | | `contact_id` | string | yes | | | `created_at` | string | yes | | | `confirmed` | boolean | no | Double opt-in state. `false` until the contact clicks the confirmation link on a double opt-in list. | Example: ``` { "list_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "contact_id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "created_at": "2026-09-01T10:10:00.000Z", "confirmed": true } ``` ListContactResult | Field | Type | Required | Description | | --- | --- | --- | --- | | `list_contact` | ListContact | yes | A list membership. The confirmation token behind a pending double opt-in only ever travels in the confirmation email and is never returned by the API. | | `double_optin_sent` | boolean | yes | True when a confirmation email was sent on this request. False on a single opt-in list, and on a double opt-in list when one already went to this address in the last 10 minutes — the membership still awaits confirmation (see `membership`). | | `membership` | "confirmed" \| "pending_confirmation" | yes | `confirmed`: the contact is on the list and campaigns to it reach them. `pending_confirmation`: they are on the list but skipped by campaigns until they click the confirmation link. | | `already_member` | boolean | yes | True when the contact was already on the list before this request (only returned for an unconfirmed member of a double opt-in list, with status 200). | Example: ``` { "list_contact": { "list_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "contact_id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "created_at": "2026-09-01T10:10:00.000Z", "confirmed": true }, "double_optin_sent": true, "membership": "confirmed", "already_member": true } ``` ListMember ``` { "allOf": [ { "$ref": "#/components/schemas/Contact" }, { "type": "object", "required": [ "added_at" ], "properties": { "added_at": { "type": "string", "format": "date-time", "description": "When the contact joined the list." } } } ] } ``` Example: ``` null ``` SegmentRule — One condition. Rules in a segment are ANDed and evaluated identically for previews, counts and campaign sends. `field` is one of `email`, `first_name`, `last_name`, `status`, `source`, `language` (an ISO 639-1 code), `created_at`, or `custom_fields.`. Text operators are case-insensitive; `greater_than` / `less_than` compare numerically or chronologically when both sides parse as numbers or dates. `is` / `is_not` are aliases of `equals` / `not_equals`. `is_empty` and `is_not_empty` ignore `value`, but the key must still be present — send an empty string. The `tag` field takes `has_tag` / `not_has_tag` with a tag id as the value. The `campaign` field takes `opened`, `not_opened`, `clicked` or `not_clicked` with a sent campaign id as the value; `not_opened` / `not_clicked` mean the contact RECEIVED that campaign and did not open / click it, so they never match people who were not sent it. The `activity` field takes `opened_within`, `not_opened_within`, `clicked_within` or `not_clicked_within` with a number of days (1–365) as the value, counting any open or click on any email from the workspace in that window; the `not_*_within` forms match every other contact. | Field | Type | Required | Description | | --- | --- | --- | --- | | `field` | string | yes | | | `operator` | "equals" \| "is" \| "not_equals" \| "is_not" \| "contains" \| "not_contains" \| "starts_with" \| "ends_with" \| "is_empty" \| "is_not_empty" \| "greater_than" \| "less_than" \| "has_tag" \| "not_has_tag" \| "opened" \| "not_opened" \| "clicked" \| "not_clicked" \| "opened_within" \| "not_opened_within" \| "clicked_within" \| "not_clicked_within" | yes | | | `value` | string | yes | Required (non-empty) for every operator except `is_empty` / `is_not_empty`, where it may be omitted. A tag id for `tag`, a campaign id for `campaign`, a number of days (1–365) for `activity`. | Example: ``` { "field": "custom_fields.region", "operator": "equals", "value": "London" } ``` Segment | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `tenant_id` | string | yes | | | `name` | string | yes | | | `rules` | array of SegmentRule | yes | | | `created_at` | string | yes | | Example: ``` { "id": "2d3e4f5a-6b7c-4d8e-9f0a-1b2c3d4e5f6a", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "London customers", "rules": [ { "field": "custom_fields.region", "operator": "equals", "value": "London" } ], "created_at": "2026-08-25T12:00:00.000Z" } ``` SegmentWithCount ``` { "allOf": [ { "$ref": "#/components/schemas/Segment" }, { "type": "object", "required": [ "contact_count", "reachable_count" ], "properties": { "contact_count": { "type": "integer", "description": "Contacts matching the rules, whatever their status." }, "reachable_count": { "type": "integer", "description": "Matching contacts whose status is subscribed." } } } ] } ``` Example: ``` null ``` CampaignStatus ``` { "type": "string", "enum": [ "draft", "scheduled", "sending", "sent", "cancelled" ] } ``` Example: ``` "draft" ``` SendToType ``` { "type": "string", "enum": [ "all", "list", "segment" ] } ``` Example: ``` "all" ``` FeedItem | Field | Type | Required | Description | | --- | --- | --- | --- | | `key` | string | yes | The item's guid/id, else its link. | | `title` | string | yes | | | `link` | string | yes | http(s) only; empty otherwise. | | `summary` | string | yes | Plain text, at most 320 characters. | | `date` | string | yes | | Example: ``` { "key": "string", "title": "string", "link": "string", "summary": "string", "date": "2026-09-02T12:00:00Z" } ``` RssFeedInput | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | | | `feed_url` | string | no | Public http(s) URL of an RSS 2.0 or Atom feed. | | `send_to_type` | "all" \| "list" \| "segment" | no | | | `send_to_id` | string | no | | | `template_id` | string | no | A template with the `{{rss_items}}` tag (the "Latest posts" block); null = built-in digest layout. | | `subject_template` | string | no | Placeholders: `{{item_title}}`, `{{feed_title}}`, `{{item_count}}`. | | `intro` | string | no | | | `frequency` | "immediate" \| "daily" \| "weekly" | no | | | `send_hour` | integer | no | UTC. | | `send_weekday` | integer | no | 0 = Sunday. | | `max_items` | integer | no | | | `status` | "active" \| "paused" | no | | Example: ``` { "name": "string", "feed_url": "string", "send_to_type": "all", "send_to_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "template_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "subject_template": "{{item_title}}", "intro": "string", "frequency": "daily", "send_hour": 9, "send_weekday": 1, "max_items": 5, "status": "active" } ``` RssFeed ``` { "allOf": [ { "$ref": "#/components/schemas/RssFeedInput" }, { "type": "object", "required": [ "id", "tenant_id", "name", "feed_url", "status", "created_at" ], "properties": { "id": { "type": "string", "format": "uuid" }, "tenant_id": { "type": "string", "format": "uuid" }, "last_checked_at": { "type": "string", "format": "date-time", "nullable": true }, "last_sent_at": { "type": "string", "format": "date-time", "nullable": true }, "last_item_key": { "type": "string", "nullable": true }, "last_error": { "type": "string", "nullable": true }, "feed_title": { "type": "string", "nullable": true }, "created_at": { "type": "string", "format": "date-time" }, "updated_at": { "type": "string", "format": "date-time" } } } ] } ``` Example: ``` null ``` FeedCheck — What fetching the feed found when it was saved. | Field | Type | Required | Description | | --- | --- | --- | --- | | `total_items` | integer | yes | Posts in the feed right now. | | `newest_title` | string | yes | Title of the newest post, or null for an empty feed. | Example: ``` { "total_items": 12, "newest_title": "What shipped in September" } ``` Share | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | | | `count` | integer | yes | | Example: ``` { "name": "string", "count": 1 } ``` PollResult — How a campaign's recipients answered one poll block. An answer is the recipient's click on one of the block's buttons; one per recipient, the latest. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The poll block's id, as it appears in the answer links (`/p/{id}/{option}`). | | `question` | string | yes | The block's question; empty when the campaign has no block JSON for it. | | `answers` | integer | yes | Recipients who answered, each counted once. | | `options` | array of object | yes | | Example: ``` { "id": "string", "question": "string", "answers": 1, "options": [ { "index": 1, "label": "string", "count": 1 } ] } ``` LanguageVersion — One translation of a campaign: the whole email in that language. | Field | Type | Required | Description | | --- | --- | --- | --- | | `subject` | string | yes | | | `html_content` | string | yes | | | `text_content` | string | no | | | `blocks` | array of BuilderBlock | no | | Example: ``` { "subject": "string", "html_content": "string", "text_content": "string", "blocks": [ { "id": "block_1", "type": "text", "props": { "heading": "This month", "text": "Hi {{first_name|there}},", "padding": 24 } } ] } ``` AbVariantStats — Counts for one version. `revenue`, `orders` and `currency` appear once an order your store sent in has been attributed to a send of this version: `revenue` is the total of those orders in `currency` — the largest currency when they were placed in more than one — and is never converted or added across currencies. | Field | Type | Required | Description | | --- | --- | --- | --- | | `sent` | integer | yes | | | `opened` | integer | yes | | | `clicked` | integer | yes | | | `revenue` | number | no | In `currency`; never converted. | | `orders` | integer | no | | | `currency` | string | no | | Example: ``` { "sent": 1, "opened": 1, "clicked": 1, "revenue": 1, "orders": 1, "currency": "GBP" } ``` Campaign | Field | Type | Required | Description | | --- | --- | --- | --- | | `subject_b` | string | no | Version B's subject line when a subject-line test is set (a mirror of `ab_test.variants[0].subject`); null for from-name and content tests. | | `ab_test` | object | no | A/B test settings and state: `sample_pct`, `wait_minutes`, `metric` (`opens`, `clicks` or `revenue`); `test_on` (`subject` — also when absent — , `from_name`, `content` or `send_time`); `variants` (versions B…E, each carrying only the field that differs: `subject`, `from_name`, or `html_content`/`text_content`/`blocks`); `status` (`pending` before send, `testing`, `decided`, `skipped`); `decide_at`; `winner` (a version letter); `decided_at`; `decided_by` (`auto`/`owner`); `samples` (per-letter sample sizes; `sample_a`/`sample_b` are kept for older readers), `held`; `reason` when the test was cut down or skipped; and once decided the per-version counts under `stats` (`a`/`b` also at the top level), each an `AbVariantStats` — with `revenue`, `orders` and `currency` where a store's orders were attributed to that version. A send-time test also carries `release_at` (per letter, when each version goes out) and, once decided, `remainder_at`. | | `id` | string | yes | | | `tenant_id` | string | yes | | | `name` | string | yes | | | `subject` | string | yes | | | `from_name` | string | yes | | | `from_email` | string | yes | | | `status_reason` | string \| null | no | Why the campaign is in its current status when that is not self-evident — why a send stopped, or why a schedule did not fire. Free text — read it, do not match on it. | | `send_pace_hours` | integer \| null | no | Spread the send over this many hours: the queue paces it at audience ÷ (hours × 60) a minute, on top of the plan's ceilings. Null = as fast as the plan allows, or the workspace's default pace. | | `send_at_best_time` | boolean | no | Send each contact at the hour they usually open — the most common UTC hour of their opens over the last 180 days, needing at least three — within 24 hours of the start; contacts without enough opens go at the start. Never combined with `ab_test` (`400`). Pro and Business, behind a flag while it is being tried (`403` otherwise). | | `rss_feed_id` | string \| null | no | The RSS feed that generated this campaign, when it was created by a feed rather than by hand. | | `html_content` | string | yes | | | `text_content` | string | yes | | | `language` | string \| null | no | ISO 639-1 code of the language the campaign's own subject and body are written in — the version every contact without a matching translation receives. Null = unspecified. | | `languages` | object \| null | no | Translations keyed by ISO 639-1 code (up to five). Present on single-campaign responses and omitted from the list. A campaign carries either language versions or an A/B test, never both. | | `blocks` | array of BuilderBlock | no | The visual builder's block JSON when `html_content` was rendered from it; `null` when the email is HTML only. A draft with blocks reopens in the visual builder, and Duplicate carries them across. | | `status` | CampaignStatus | yes | | | `send_to_type` | SendToType | yes | | | `send_to_id` | string \| null | yes | List or segment ID when `send_to_type` is `list`/`segment`. | | `scheduled_at` | string \| null | yes | | | `sent_at` | string \| null | yes | | | `stats_sent` | integer | yes | | | `stats_delivered` | integer | yes | | | `stats_opened` | integer | yes | | | `stats_clicked` | integer | yes | | | `stats_bounced` | integer | yes | | | `stats_unsubscribed` | integer | yes | | | `created_at` | string | yes | | | `updated_at` | string | no | Last write. Send it back as `expected_updated_at` on update to detect a concurrent edit. | | `draft_key` | string \| null | no | The idempotency key the draft was created with, if any. | Example: ``` { "id": "c3d4e5f6-7a8b-4c9d-8e0f-1a2b3c4d5e6f", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "September newsletter", "subject": "What's new this month", "from_name": "Acme", "from_email": "hello@acme.com", "html_content": "

Hello {{first_name}}

", "text_content": "Hello {{first_name}}", "status": "draft", "send_to_type": "list", "send_to_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "scheduled_at": null, "sent_at": null, "stats_sent": 0, "stats_delivered": 0, "stats_opened": 0, "stats_clicked": 0, "stats_bounced": 0, "stats_unsubscribed": 0, "created_at": "2026-09-01T11:00:00.000Z" } ``` CampaignWithStats ``` { "allOf": [ { "$ref": "#/components/schemas/Campaign" }, { "type": "object", "required": [ "stats" ], "properties": { "stats": { "type": "object", "required": [ "total_sends", "delivered", "opened", "bounced" ], "description": "Counts of per-recipient send records by current status.", "properties": { "total_sends": { "type": "integer" }, "delivered": { "type": "integer" }, "opened": { "type": "integer" }, "bounced": { "type": "integer" }, "by_language": { "type": "array", "description": "For a campaign with language versions: sent, opened and clicked per language that went out, the campaign's own language first (its code, or null when it has none). Empty for a single-language campaign.", "items": { "type": "object", "required": [ "language", "sent", "opened", "clicked" ], "properties": { "language": { "type": "string", "nullable": true, "description": "ISO 639-1 code" }, "sent": { "type": "integer" }, "opened": { "type": "integer" }, "clicked": { "type": "integer" } } } } }, "example": { "total_sends": 1834, "delivered": 1790, "opened": 612, "bounced": 9, "by_language": [ { "language": "en", "sent": 1500, "opened": 480, "clicked": 96 }, { "language": "fr", "sent": 334, "opened": 132, "clicked": 30 } ] } } } } ] } ``` Example: ``` null ``` CampaignCreate | Field | Type | Required | Description | | --- | --- | --- | --- | | `draft_key` | string | no | Optional idempotency key (a UUID, or up to 64 letters, digits, `-`, `_`), unique per workspace. A repeat create with the same key returns the existing draft (`200`). | | `language` | string \| null | no | ISO 639-1 code of the language the campaign's own subject and body are written in (#80). Null clears it. | | `languages` | object \| null | no | Translations keyed by ISO 639-1 code, up to five, each with its own `subject` and `html_content` (optional `text_content`, `blocks`). A contact whose `language` matches a key receives that version; everyone else the campaign's own. Keys must differ from `language`. Cannot be combined with `ab_test` (`400`). Null clears them. | | `subject_b` | string | no | A second subject line to test against `subject` — the two-version shape. Setting it (with `ab_test`) arms a subject-line test; `null` removes a subject test. Ignored when `ab_test.variants` is given. | | `send_pace_hours` | integer \| null | no | Spread the send over this many hours (see Campaign); null for the workspace default. | | `send_at_best_time` | boolean | no | Send each contact at their usual open hour (see Campaign). Not with `ab_test`. | | `ab_test` | object | no | A/B test settings. Either `subject_b` plus these settings (a two-version subject test), or `test_on` plus `variants` — up to four extra versions (B–E) of the subject line, the from name or the email content; version A is the campaign itself. On send, `sample_pct` of the audience is split evenly between the versions, and after `wait_minutes` the version with the better rate on `metric` goes to everyone else. An audience under two recipients per version sends plain with version A (state `skipped`). `null` removes the test. | | `name` | string | yes | | | `subject` | string | no | May be omitted or empty on a draft; required to send. | | `from_name` | string | no | | | `from_email` | string | yes | Stored with the campaign. Delivery uses the workspace's saved sender details, which must be the workspace's own shared address or an address on a sending domain it has verified; that check runs on every send. | | `html_content` | string | no | Supports `{{first_name}}`, `{{last_name}}`, `{{email}}`, `{{custom_fields.}}`, `{{workspace_name}}` (the workspace's name — the sender's business), `{{sender_name}}` (the From name), `{{unsubscribe_url}}` and `{{web_version_url}}` merge tags, each with an optional fallback after a pipe — `{{first_name\|there}}` — used when the contact has no value. Write `\\|` for a literal pipe in the fallback. Conditional content: `{{#if custom_fields.plan is "pro"}}…{{else}}…{{/if}}`, with the operators is, is not, contains, does not contain, starts with, ends with, is set, is not set, is greater than and is less than, nesting one level. A tag or condition that does not resolve is sent exactly as typed. At most 500,000 characters. | | `text_content` | string | no | The same merge tags and conditional blocks as `html_content`, inserted unescaped. At most 200,000 characters. | | `blocks` | array of BuilderBlock | no | The visual builder's block JSON, when `html_content` was rendered from it. Optional; `null` or absent means HTML only. | | `send_to_type` | SendToType | yes | | | `send_to_id` | string \| null | no | Required when `send_to_type` is `list` or `segment`. | Example: ``` { "draft_key": "string", "language": "en", "languages": { "fr": { "subject": "Nouveautés de septembre", "html_content": "

Bonjour…

" } }, "subject_b": "string", "send_pace_hours": null, "send_at_best_time": true, "ab_test": { "sample_pct": 20, "wait_minutes": 120, "metric": "opens", "test_on": "subject", "variants": [ { "subject": "string", "from_name": "string", "html_content": "string", "text_content": "string", "blocks": [], "send_offset_minutes": 60 } ] }, "name": "September newsletter", "subject": "What's new this month", "from_name": "Acme", "from_email": "hello@acme.com", "html_content": "

Hello {{first_name}}

", "text_content": "Hello {{first_name}}", "blocks": [ { "id": "block_1", "type": "text", "props": { "heading": "This month", "text": "Hi {{first_name|there}},", "padding": 24 } } ], "send_to_type": "all", "send_to_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d" } ``` CampaignUpdate | Field | Type | Required | Description | | --- | --- | --- | --- | | `expected_updated_at` | string \| null | no | The `updated_at` you last read. When the campaign has changed since, the update is refused with `409` `code: "stale"`. Omit or send `null` to skip the check. | | `name` | string | no | May not be emptied. | | `subject` | string | no | May be empty on a draft, not on a scheduled campaign. | | `from_name` | string | no | | | `from_email` | string | no | | | `html_content` | string | no | At most 500,000 characters (`400` otherwise). | | `text_content` | string | no | At most 200,000 characters (`400` otherwise). | | `send_to_type` | SendToType | no | | | `send_to_id` | string \| null | no | | Example: ``` { "subject": "What's new in September", "send_to_type": "all", "send_to_id": null } ``` TemplateCategory ``` { "type": "string", "enum": [ "welcome", "newsletter", "promotion", "transactional", "custom" ] } ``` Example: ``` "welcome" ``` CustomBlockSlot — A part of a custom block a marketer may change. Declared in the MJML as `[[key]]` (text), `[[key:url]]` (a link) or `[[key:image]]` (an image address). | Field | Type | Required | Description | | --- | --- | --- | --- | | `key` | string | yes | | | `type` | "text" \| "url" \| "image" | yes | | | `label` | string | yes | | | `default` | string | yes | | Example: ``` { "key": "headline", "type": "text", "label": "Headline", "default": "Big news" } ``` CustomBlock — A block of the workspace's own, written in MJML and compiled when saved. `html` is the compiled body fragment with its slot markers in place; `head_html` the styles and Outlook conditionals an email carries once for it. The visual builder places it as a block of type `custom`. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `tenant_id` | string | yes | | | `name` | string | yes | | | `description` | string | yes | | | `mjml` | string | yes | The source, up to 100,000 characters. | | `html` | string | yes | | | `head_html` | string | yes | | | `slots` | array of CustomBlockSlot | yes | | | `created_at` | string | yes | | | `updated_at` | string | yes | | Example: ``` { "id": "b7c8d9e0-f1a2-4b3c-8d4e-5f6a7b8c9d0e", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Hero", "description": "Headline, one line and a button on the brand colour.", "mjml": "[[headline]][[cta_label]]", "html": "
…[[headline]]…
", "head_html": "", "slots": [ { "key": "headline", "type": "text", "label": "Headline", "default": "Big news" }, { "key": "cta", "type": "url", "label": "Button link", "default": "https://acme.test" }, { "key": "cta_label", "type": "text", "label": "Button text", "default": "Read more" } ], "created_at": "2026-09-26T09:00:00.000Z", "updated_at": "2026-09-26T09:00:00.000Z" } ``` BuilderBlock — One block of the visual email builder. `props` holds the block's settings (text, colours, padding…) and is not validated field by field; the builder treats every prop as optional. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `type` | "header" \| "text" \| "image" \| "button" \| "divider" \| "spacer" \| "columns" \| "social" \| "footer" \| "rss" \| "html" \| "video" \| "feature" \| "quote" \| "list" \| "poll" \| "custom" | yes | | | `props` | object | yes | For a `custom` block: `blockId` (a custom block of the workspace), `fragment` and `head` (its compiled HTML, snapshotted when placed), `slots` and `values` (the marketer's text, links and images, by slot key). | Example: ``` { "id": "block_1", "type": "text", "props": { "heading": "This month", "text": "Hi {{first_name|there}},", "padding": 24 } } ``` Template | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `tenant_id` | string | yes | | | `name` | string | yes | | | `subject` | string | yes | | | `html_content` | string | yes | | | `category` | TemplateCategory | yes | | | `blocks` | array of BuilderBlock | no | The visual builder's block JSON when `html_content` was rendered from it; `null` when the template is HTML only. A template with blocks reopens in the visual builder. | | `created_at` | string | yes | | Example: ``` { "id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Welcome email", "subject": "Welcome, {{first_name}}!", "html_content": "

Hi {{first_name}}

", "category": "welcome", "created_at": "2026-08-20T09:00:00.000Z" } ``` AutomationStatus ``` { "type": "string", "enum": [ "active", "paused", "draft" ] } ``` Example: ``` "active" ``` HealthIssue | Field | Type | Required | Description | | --- | --- | --- | --- | | `code` | string | yes | | | `message` | string | yes | | | `step_id` | string | no | The step the issue concerns, when it is about one. | Example: ``` { "code": "template_missing", "message": "string", "step_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" } ``` TriggerType ``` { "type": "string", "enum": [ "contact_created", "list_joined", "form_submitted", "tag_added", "tag_removed", "field_updated", "date_anniversary", "date_specific", "api", "automation", "cart_abandoned", "product_viewed", "order_placed", "event_received" ] } ``` Example: ``` "contact_created" ``` ConditionRule — Either a contact-field test or an email-activity test. An activity rule asks whether the contact opened or clicked the email sent by an EARLIER step in this automation, named by that step's id. | Field | Type | Required | Description | | --- | --- | --- | --- | | `type` | "field" \| "email_activity" | no | Defaults to `field`. | | `field` | string | no | | | `operator` | "equals" \| "not_equals" \| "contains" \| "not_contains" \| "is_empty" \| "is_not_empty" \| "starts_with" \| "ends_with" \| "greater_than" \| "less_than" | no | | | `value` | string | no | | | `activity` | "opened" \| "not_opened" \| "clicked" \| "not_clicked" | no | | | `step_id` | string | no | The send_email step the activity refers to. | Example: ``` { "field": "source", "operator": "equals", "value": "form" } ``` AutomationTriggerInput — One way into the automation. Up to 3 per automation, combined with OR: a contact matching any of them enters. | Field | Type | Required | Description | | --- | --- | --- | --- | | `type` | TriggerType | yes | | | `config` | TriggerConfig | no | Trigger-specific settings. `tag_added` / `tag_removed` use `tag_id` (omit for any tag). `list_joined` accepts `list_id`; `form_submitted` accepts `form_id`; `automation` accepts `automation_id`. `field_updated` requires `field`. `date_anniversary` and `date_specific` require `date_field` — a custom field holding `YYYY-MM-DD`, or `created_at` for an anniversary — plus optional `offset_days` (0–365) and `direction` (`before`, `on`, `after`). `contact_created`, `api`, `cart_abandoned`, `product_viewed` and `order_placed` take no settings — they fire for the matching event from any store or webhook that posts it (see `POST /api/v1/ecommerce/events`). `event_received` takes `event_name` — the name another system will post to `POST /api/v1/events`; omit it to run for every event the workspace is sent. Any id given must belong to this workspace, or the request is refused with `400`. | | `filters` | array of ConditionRule | no | A contact who fires this trigger but fails these does not enter. | Example: ``` { "type": "tag_added", "config": { "tag_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33" }, "filters": [] } ``` TriggerConfig — Trigger-specific settings. `tag_added` / `tag_removed` use `tag_id` (omit for any tag). `list_joined` accepts `list_id`; `form_submitted` accepts `form_id`; `automation` accepts `automation_id`. `field_updated` requires `field`. `date_anniversary` and `date_specific` require `date_field` — a custom field holding `YYYY-MM-DD`, or `created_at` for an anniversary — plus optional `offset_days` (0–365) and `direction` (`before`, `on`, `after`). `contact_created`, `api`, `cart_abandoned`, `product_viewed` and `order_placed` take no settings — they fire for the matching event from any store or webhook that posts it (see `POST /api/v1/ecommerce/events`). `event_received` takes `event_name` — the name another system will post to `POST /api/v1/events`; omit it to run for every event the workspace is sent. Any id given must belong to this workspace, or the request is refused with `400`. | Field | Type | Required | Description | | --- | --- | --- | --- | | `tag_id` | string | no | | | `list_id` | string | no | | | `form_id` | string | no | | | `automation_id` | string | no | | | `field` | string | no | | | `date_field` | string | no | | | `offset_days` | integer | no | | | `direction` | "before" \| "on" \| "after" | no | | | `event_name` | string | no | `event_received` only: the name the other system posts as `event` to `POST /api/v1/events`. Send it lower-case — letters, numbers, dot, dash or underscore, starting with a letter or number — exactly as that endpoint reduces an incoming name to, since the two are compared as strings: a trigger named `Deal_Won` never fires, because no event arrives spelt that way. Omit it to listen to every event. | | `recipe_placeholder` | string | no | Written by a recipe import when the tag, list or form was left for later: the label of what is still to choose. While it is present and the id is empty, activation is refused. Cleared by the editor once an id is chosen, or when the author opts for "any". | Example: ``` { "tag_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33" } ``` StepType ``` { "type": "string", "enum": [ "send_email", "wait", "condition", "add_tag", "remove_tag", "update_field", "unsubscribe", "trigger_automation", "call_webhook", "split" ] } ``` Example: ``` "send_email" ``` StepConfig — Step settings by type. `send_email`: `template_id` (subject and HTML are read from the template at send time, so template edits apply to every automation that uses it) or inline `subject` + `html_content` (both required), optionally `text_content`; a step with neither is refused with `400`. When both are present the inline fields win. `wait`: `mode` — `duration` with `duration_minutes`, or `time_of_day` / `day_of_week` / `day_of_month` with `time` (HH:MM), `weekdays` (0=Sunday), `day_of_month`, and `timezone`; `optimised` reuses the hour the contact signed up at. `condition`: `match` (`all` / `any` / `none`) plus `rules`; the older flat `field` / `operator` / `value` is still accepted and now takes the **no** branch instead of ejecting the contact. `add_tag` / `remove_tag`: `tag_id`. `update_field`: `field` and `value`, plus `mode` — `set` (default; every existing automation keeps this behaviour) writes `value` as-is, `increment` adds `value` (a number, may be negative) to the field's current number instead of replacing it — a running counter such as lifetime value or visit count. `increment` only works on a Number custom field; an undeclared key is registered as Number (never Text) the first time it is used this way. `unsubscribe`: `list_id` to leave one list, omitted to unsubscribe entirely. `trigger_automation`: `automation_id`. `call_webhook`: an optional `label` (up to 80 characters) naming this step — the step calls no URL of its own; it emits the `automation.step_reached` webhook event, so it does something only where a webhook endpoint in the workspace subscribes to that event. Every id must belong to this workspace, or the request is refused with `400` naming the step and key. `split`: `branches` — exactly two entries `{key:"a",pct}`, `{key:"b",pct}` with whole-number percentages summing to 100 (default 50/50; 100/0 sends everyone down A); the A share follows `yes_step_id`, the B share `no_step_id`, and each contact's branch is fixed when they reach the step. `send_email` also takes an optional `subject_b` (up to 200 characters) to send half of the contacts reaching the step a second subject line. | Field | Type | Required | Description | | --- | --- | --- | --- | | `template_id` | string | no | | | `subject` | string | no | | | `html_content` | string | no | | | `text_content` | string | no | | | `mode` | "duration" \| "time_of_day" \| "day_of_week" \| "day_of_month" \| "set" \| "increment" | no | `wait` uses duration/time_of_day/day_of_week/day_of_month; `update_field` uses set/increment (defaults to set). | | `duration_minutes` | integer | no | | | `duration_value` | integer | no | | | `duration_unit` | "minutes" \| "hours" \| "days" | no | | | `time` | string | no | | | `weekdays` | array of integer | no | | | `day_of_month` | integer | no | | | `timezone` | string | no | | | `optimised` | boolean | no | | | `match` | "all" \| "any" \| "none" | no | | | `rules` | array of ConditionRule | no | | | `field` | string | no | | | `operator` | string | no | | | `value` | string | no | | | `tag_id` | string | no | | | `list_id` | string | no | | | `automation_id` | string | no | | | `subject_b` | string | no | `send_email` only: a second subject line. Half of the contacts reaching the step get each; the automation page reports opens and clicks per subject. | | `branches` | array of object | no | `split` only: the two shares, `a` then `b`, whole-number percentages summing to 100. Default 50/50; 100/0 sends everyone down A. The A share follows `yes_step_id`, the B share `no_step_id`, and each contact's branch is fixed when they reach the step. | Example: ``` { "template_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "subject": "string", "html_content": "string", "text_content": "string", "mode": "duration", "duration_minutes": 1, "duration_value": 1, "duration_unit": "minutes", "time": "09:00", "weekdays": [ 1 ], "day_of_month": 1, "timezone": "Europe/London", "optimised": true, "match": "all", "rules": [ { "field": "source", "operator": "equals", "value": "form" } ], "field": "string", "operator": "string", "value": "string", "tag_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "list_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "automation_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "subject_b": "string", "branches": [ { "key": "a", "pct": 1 } ] } ``` AutomationStepInput — Steps form a graph. Each step may name the step that runs after it via `next_step_id` — and two steps may name the same one, which is how branches rejoin. `parent_id` positions a step under another for layout, and a `condition` or `split` step names its two outcomes via `yes_step_id` and `no_step_id`. Those fields reference the `id` of another step in the same array — either an existing step uuid (which is preserved, so contacts already in the automation keep their place) or any string key you invent for a new step. Send an array with no `parent_id` anywhere and it is chained in order, exactly as before. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | Existing step uuid to keep, or a client-side key other steps can reference. | | `type` | StepType | yes | | | `step_order` | integer | no | Orders siblings for display. Defaults to the array index. Does not drive execution. | | `config` | StepConfig | no | Step settings by type. `send_email`: `template_id` (subject and HTML are read from the template at send time, so template edits apply to every automation that uses it) or inline `subject` + `html_content` (both required), optionally `text_content`; a step with neither is refused with `400`. When both are present the inline fields win. `wait`: `mode` — `duration` with `duration_minutes`, or `time_of_day` / `day_of_week` / `day_of_month` with `time` (HH:MM), `weekdays` (0=Sunday), `day_of_month`, and `timezone`; `optimised` reuses the hour the contact signed up at. `condition`: `match` (`all` / `any` / `none`) plus `rules`; the older flat `field` / `operator` / `value` is still accepted and now takes the **no** branch instead of ejecting the contact. `add_tag` / `remove_tag`: `tag_id`. `update_field`: `field` and `value`, plus `mode` — `set` (default; every existing automation keeps this behaviour) writes `value` as-is, `increment` adds `value` (a number, may be negative) to the field's current number instead of replacing it — a running counter such as lifetime value or visit count. `increment` only works on a Number custom field; an undeclared key is registered as Number (never Text) the first time it is used this way. `unsubscribe`: `list_id` to leave one list, omitted to unsubscribe entirely. `trigger_automation`: `automation_id`. `call_webhook`: an optional `label` (up to 80 characters) naming this step — the step calls no URL of its own; it emits the `automation.step_reached` webhook event, so it does something only where a webhook endpoint in the workspace subscribes to that event. Every id must belong to this workspace, or the request is refused with `400` naming the step and key. `split`: `branches` — exactly two entries `{key:"a",pct}`, `{key:"b",pct}` with whole-number percentages summing to 100 (default 50/50; 100/0 sends everyone down A); the A share follows `yes_step_id`, the B share `no_step_id`, and each contact's branch is fixed when they reach the step. `send_email` also takes an optional `subject_b` (up to 200 characters) to send half of the contacts reaching the step a second subject line. | | `parent_id` | string | no | Layout only: the step this one sits under. When no `next_step_id` is given anywhere, the forward edges are derived from this, which is how an ordered list still works. | | `next_step_id` | string | no | The step that runs next. Several steps may name the SAME target, which is how two branches rejoin. Not valid on a condition, which branches with yes_step_id / no_step_id. | | `yes_step_id` | string | no | condition and split steps: the step taken when a condition passes, or the A share of a split. Omit to end that path. | | `no_step_id` | string | no | condition and split steps: the step taken when a condition fails, or the B share of a split. Omit to end that path. | Example: ``` { "id": "string", "type": "send_email", "step_order": 1, "config": { "template_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "subject": "string", "html_content": "string", "text_content": "string", "mode": "duration", "duration_minutes": 1, "duration_value": 1, "duration_unit": "minutes", "time": "09:00", "weekdays": [ 1 ], "day_of_month": 1, "timezone": "Europe/London", "optimised": true, "match": "all", "rules": [ { "field": "source", "operator": "equals", "value": "form" } ], "field": "string", "operator": "string", "value": "string", "tag_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "list_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "automation_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "subject_b": "string", "branches": [ { "key": "a", "pct": 1 } ] }, "parent_id": "string", "next_step_id": "string", "yes_step_id": "string", "no_step_id": "string" } ``` AutomationStep | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `automation_id` | string | yes | | | `step_order` | integer | yes | | | `type` | StepType | yes | | | `config` | StepConfig | yes | Step settings by type. `send_email`: `template_id` (subject and HTML are read from the template at send time, so template edits apply to every automation that uses it) or inline `subject` + `html_content` (both required), optionally `text_content`; a step with neither is refused with `400`. When both are present the inline fields win. `wait`: `mode` — `duration` with `duration_minutes`, or `time_of_day` / `day_of_week` / `day_of_month` with `time` (HH:MM), `weekdays` (0=Sunday), `day_of_month`, and `timezone`; `optimised` reuses the hour the contact signed up at. `condition`: `match` (`all` / `any` / `none`) plus `rules`; the older flat `field` / `operator` / `value` is still accepted and now takes the **no** branch instead of ejecting the contact. `add_tag` / `remove_tag`: `tag_id`. `update_field`: `field` and `value`, plus `mode` — `set` (default; every existing automation keeps this behaviour) writes `value` as-is, `increment` adds `value` (a number, may be negative) to the field's current number instead of replacing it — a running counter such as lifetime value or visit count. `increment` only works on a Number custom field; an undeclared key is registered as Number (never Text) the first time it is used this way. `unsubscribe`: `list_id` to leave one list, omitted to unsubscribe entirely. `trigger_automation`: `automation_id`. `call_webhook`: an optional `label` (up to 80 characters) naming this step — the step calls no URL of its own; it emits the `automation.step_reached` webhook event, so it does something only where a webhook endpoint in the workspace subscribes to that event. Every id must belong to this workspace, or the request is refused with `400` naming the step and key. `split`: `branches` — exactly two entries `{key:"a",pct}`, `{key:"b",pct}` with whole-number percentages summing to 100 (default 50/50; 100/0 sends everyone down A); the A share follows `yes_step_id`, the B share `no_step_id`, and each contact's branch is fixed when they reach the step. `send_email` also takes an optional `subject_b` (up to 200 characters) to send half of the contacts reaching the step a second subject line. | | `parent_id` | string | no | | | `next_step_id` | string | no | | | `yes_step_id` | string | no | | | `no_step_id` | string | no | | | `created_at` | string | yes | | Example: ``` { "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "automation_id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c", "step_order": 0, "type": "send_email", "config": { "template_id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b" }, "parent_id": null, "next_step_id": null, "yes_step_id": null, "no_step_id": null, "created_at": "2026-08-26T08:00:00.000Z" } ``` Automation | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `tenant_id` | string | yes | | | `name` | string | yes | | | `description` | string | yes | | | `trigger_type` | TriggerType | yes | | | `trigger_config` | TriggerConfig | yes | Trigger-specific settings. `tag_added` / `tag_removed` use `tag_id` (omit for any tag). `list_joined` accepts `list_id`; `form_submitted` accepts `form_id`; `automation` accepts `automation_id`. `field_updated` requires `field`. `date_anniversary` and `date_specific` require `date_field` — a custom field holding `YYYY-MM-DD`, or `created_at` for an anniversary — plus optional `offset_days` (0–365) and `direction` (`before`, `on`, `after`). `contact_created`, `api`, `cart_abandoned`, `product_viewed` and `order_placed` take no settings — they fire for the matching event from any store or webhook that posts it (see `POST /api/v1/ecommerce/events`). `event_received` takes `event_name` — the name another system will post to `POST /api/v1/events`; omit it to run for every event the workspace is sent. Any id given must belong to this workspace, or the request is refused with `400`. | | `triggers` | array of AutomationTriggerInput | no | Every way into this automation. trigger_type/trigger_config mirror the first entry. | | `repeat_enabled` | boolean | no | Whether a contact who finished may enter again. Defaults to false — re-entry re-sends the whole sequence — except when a trigger is `date_anniversary`, where a new automation defaults to true so it runs every year. Only creation applies a default; an update never changes repeat settings it was not sent. | | `repeat_cooldown_hours` | integer | no | Minimum hours between two enrolments of the same contact. Default 24; 7200 (300 days) when a trigger is `date_anniversary`, so a yearly run can never fire twice in one year. | | `trigger_broken` | boolean | no | Derived: the trigger names a list, form or tag that no longer exists, so the automation can never fire. | | `last_error` | string \| null | no | The most recent failure while running this automation, as shown on its health panel. Free text — read it, do not match on it. | | `last_error_at` | string \| null | no | When `last_error` was recorded. | | `error_count` | integer | no | Failures since the automation was last activated. Resets on activation. | | `status` | AutomationStatus | yes | | | `created_at` | string | yes | | Example: ``` { "id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Welcome series", "description": "Three emails over a week", "trigger_type": "contact_created", "trigger_config": {}, "status": "active", "created_at": "2026-08-26T08:00:00.000Z" } ``` AutomationWithSteps ``` { "allOf": [ { "$ref": "#/components/schemas/Automation" }, { "type": "object", "required": [ "steps" ], "properties": { "steps": { "type": "array", "items": { "$ref": "#/components/schemas/AutomationStep" } } } } ] } ``` Example: ``` null ``` AutomationCreate | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | | | `description` | string | no | | | `trigger_type` | TriggerType | yes | | | `trigger_config` | TriggerConfig | no | Trigger-specific settings. `tag_added` / `tag_removed` use `tag_id` (omit for any tag). `list_joined` accepts `list_id`; `form_submitted` accepts `form_id`; `automation` accepts `automation_id`. `field_updated` requires `field`. `date_anniversary` and `date_specific` require `date_field` — a custom field holding `YYYY-MM-DD`, or `created_at` for an anniversary — plus optional `offset_days` (0–365) and `direction` (`before`, `on`, `after`). `contact_created`, `api`, `cart_abandoned`, `product_viewed` and `order_placed` take no settings — they fire for the matching event from any store or webhook that posts it (see `POST /api/v1/ecommerce/events`). `event_received` takes `event_name` — the name another system will post to `POST /api/v1/events`; omit it to run for every event the workspace is sent. Any id given must belong to this workspace, or the request is refused with `400`. | | `steps` | array of AutomationStepInput | yes | | Example: ``` { "name": "Welcome series", "trigger_type": "contact_created", "trigger_config": {}, "steps": [ { "type": "send_email", "config": { "template_id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b" } }, { "type": "wait", "config": { "duration_minutes": 1440 } }, { "type": "add_tag", "config": { "tag_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33" } } ] } ``` AutomationRecipe | Field | Type | Required | Description | | --- | --- | --- | --- | | `slug` | string | yes | | | `name` | string | yes | | | `tagline` | string | yes | | | `description` | string | yes | | | `outline` | object | yes | What the recipe does, in words, with placeholders named by their label. | | `placeholders` | array of object | yes | | Example: ``` { "slug": "welcome-series", "name": "Welcome series", "tagline": "Three emails over a week for everyone who joins a list.", "description": "…", "outline": { "triggers": [ "Contact joins list \"List to watch\"" ], "steps": [ { "text": "Send “Welcome — here is what to expect”" }, { "text": "Wait 2 days" } ], "repeats": "Each contact goes through once" }, "placeholders": [ { "id": "list", "kind": "list", "label": "List to watch", "hint": "Joining this list starts the series.", "suggested": "Newsletter" } ] } ``` AutomationRecipeImport | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | Name for the new automation. Defaults to the recipe's name. | | `choices` | object | no | One entry per placeholder id. | Example: ``` { "name": "Newsletter welcome", "choices": { "list": { "existing": "9c1e2d3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f" }, "welcomed": { "create": "Welcomed" } } } ``` WorkspaceBlueprintCreate | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | | | `description` | string | no | | Example: ``` { "name": "Agency starter kit", "description": "Custom fields, welcome automation and templates every new client site starts with." } ``` WorkspaceBlueprintSummary | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `account_id` | string | yes | | | `source_tenant_id` | string | no | The workspace it was captured from — null once that workspace has been deleted (the blueprint itself still applies, minus any hosted images). | | `created_by` | string | no | | | `name` | string | yes | | | `description` | string | yes | | | `created_at` | string | yes | | | `updated_at` | string | yes | | | `counts` | object | yes | | Example: ``` { "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "account_id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c", "source_tenant_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33", "created_by": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b", "name": "Agency starter kit", "description": "", "created_at": "2026-09-15T09:00:00.000Z", "updated_at": "2026-09-15T09:00:00.000Z", "counts": { "custom_fields": 2, "automations": 1, "templates": 3 } } ``` WorkspaceBlueprintApplyResult | Field | Type | Required | Description | | --- | --- | --- | --- | | `custom_fields` | object | yes | | | `templates` | object | yes | | | `automations` | object | yes | | Example: ``` { "custom_fields": { "created": [ "renewal_date" ], "skipped": [ "plan" ], "unavailable": false }, "templates": { "created": 3, "failed": 0, "media_copied": 2, "media_not_carried_over": 0 }, "automations": { "created": [ { "name": "Welcome series", "id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c", "unresolved": [] } ], "failed": [] } } ``` AutomationUpdate | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | | | `description` | string | no | | | `trigger_type` | TriggerType | no | | | `trigger_config` | TriggerConfig | no | Trigger-specific settings. `tag_added` / `tag_removed` use `tag_id` (omit for any tag). `list_joined` accepts `list_id`; `form_submitted` accepts `form_id`; `automation` accepts `automation_id`. `field_updated` requires `field`. `date_anniversary` and `date_specific` require `date_field` — a custom field holding `YYYY-MM-DD`, or `created_at` for an anniversary — plus optional `offset_days` (0–365) and `direction` (`before`, `on`, `after`). `contact_created`, `api`, `cart_abandoned`, `product_viewed` and `order_placed` take no settings — they fire for the matching event from any store or webhook that posts it (see `POST /api/v1/ecommerce/events`). `event_received` takes `event_name` — the name another system will post to `POST /api/v1/events`; omit it to run for every event the workspace is sent. Any id given must belong to this workspace, or the request is refused with `400`. | | `steps` | array of AutomationStepInput | no | Replaces all existing steps. | Example: ``` { "name": "Welcome series v2" } ``` FormKind ``` { "type": "string", "enum": [ "signup", "contact" ] } ``` Example: ``` "signup" ``` Form | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `tenant_id` | string | yes | | | `name` | string | yes | | | `list_id` | string \| null | yes | List that signups join. Always null for contact forms. | | `fields` | array of string | yes | Field names the form accepts. Beyond the built-ins, each name is stored as a custom field on the contact (signup) or included in the notification (contact). | | `thank_you_message` | string | yes | | | `redirect_url` | string \| null | yes | | | `status` | "active" \| "inactive" | yes | | | `kind` | FormKind | no | | | `notify_email` | string \| null | no | Owner address that receives contact-form messages or signup notifications. | | `notify_subject` | string \| null | no | Subject prefix for owner notifications (defaults to the form name). | | `allowed_origins` | array of string | no | Origins (`https://example.com`) allowed to submit. Empty = any. | | `turnstile_site_key` | string \| null | no | | | `turnstile_secret` | string \| null | no | Always masked as `••••••••` when set. | | `daily_cap` | integer | no | Maximum submissions per 24 hours. Set and shown on the form. | | `created_at` | string | yes | | | `views` | integer | no | Times the form has been shown to a person, since counting began: on its hosted page, in the pop-up, through the WordPress plugin, and by inline embeds whose code was copied after views were introduced. Crawlers, prefetches and repeated loads from one visitor are not counted. Returned by the GET operations. | | `submissions` | integer | no | Submissions accepted since counting began. Attempts dropped by the automated-submission checks are not counted. Returned by the GET operations. | | `headline` | string \| null | no | The hosted page's own headline; null = the default. A heading passed in an embed or pop-up URL still wins. | | `body` | string \| null | no | The line of copy under the headline on the hosted page. | | `button_label` | string \| null | no | The submit button's label; null = Subscribe (Send message for a contact form). | | `ab_test` | object \| null | no | A second version of the form tested against the first. While `status` is `testing`, half of the views of the hosted page, the pop-up and the WordPress plugin see version B at random (per view, no cookie; inline embeds always show A); each view and submission is counted against its version. Set `{ "status": "testing", "b": {…} }` to start (started_at is set by the server), `{ "status": "decided", "winner": "a"\|"b" }` to end it — deciding for B writes its copy onto the form — and `null` to clear. | | `image_url` | string \| null | no | An https image shown above the form on its hosted page and used as the share image. | | `indexable` | boolean | no | Whether search engines may index the hosted page. False unless turned on; embeds and pop-ups are never indexable. | | `versions` | object | no | Views, submissions and conversion rate of version A and version B (B is its share of the totals; A is the rest). Returned by the GET operations. | | `conversion_rate` | number \| null | no | `submissions` ÷ `views`, 0–1 to four decimal places; `null` until the form has been viewed. Not capped: a form driven through the API has submissions with no view, so its rate can exceed 1. Returned by the GET operations. | Example: ``` { "id": "11111111-2222-4333-8444-555555555555", "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a", "name": "Newsletter signup", "list_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "fields": [ "email", "first_name", "last_name", "company" ], "thank_you_message": "Thanks for subscribing!", "redirect_url": null, "status": "active", "kind": "signup", "notify_email": null, "notify_subject": null, "allowed_origins": [ "https://acme.com" ], "turnstile_site_key": "0x4AAAAAAA", "turnstile_secret": "••••••••", "daily_cap": 200, "created_at": "2026-08-28T14:00:00.000Z", "views": 1840, "submissions": 92, "conversion_rate": 0.05 } ``` FormVersionStats | Field | Type | Required | Description | | --- | --- | --- | --- | | `views` | integer | yes | | | `submissions` | integer | yes | | | `conversion_rate` | number \| null | yes | | Example: ``` { "views": 1, "submissions": 1, "conversion_rate": null } ``` FormVersionB — What version B changes. Every field is optional; at least one must be set. Fields never change between versions. | Field | Type | Required | Description | | --- | --- | --- | --- | | `heading` | string | no | | | `sub` | string | no | | | `button` | string | no | | | `thank_you_message` | string | no | | | `accent` | string | no | A hex colour for the button. | Example: ``` { "heading": "string", "sub": "string", "button": "string", "thank_you_message": "string", "accent": "#f97316" } ``` FormAbTest — A second version of the form tested against the first. While `status` is `testing`, half of the views of the hosted page, the pop-up and the WordPress plugin see version B at random (per view, no cookie; inline embeds always show A); each view and submission is counted against its version. Set `{ "status": "testing", "b": {…} }` to start (started_at is set by the server), `{ "status": "decided", "winner": "a"|"b" }` to end it — deciding for B writes its copy onto the form — and `null` to clear. | Field | Type | Required | Description | | --- | --- | --- | --- | | `status` | "testing" \| "decided" | no | | | `started_at` | string | no | | | `decided_at` | string | no | | | `winner` | "a" \| "b" | no | | | `b` | FormVersionB | no | What version B changes. Every field is optional; at least one must be set. Fields never change between versions. | Example: ``` { "status": "testing", "started_at": "2026-09-26T09:00:00.000Z", "b": { "heading": "Get the weekly letter", "button": "Count me in" } } ``` FormCreate | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | | | `headline` | string \| null | no | | | `body` | string \| null | no | | | `button_label` | string \| null | no | | | `image_url` | string \| null | no | | | `indexable` | boolean | no | | | `ab_test` | object \| null | no | A second version of the form tested against the first. While `status` is `testing`, half of the views of the hosted page, the pop-up and the WordPress plugin see version B at random (per view, no cookie; inline embeds always show A); each view and submission is counted against its version. Set `{ "status": "testing", "b": {…} }` to start (started_at is set by the server), `{ "status": "decided", "winner": "a"\|"b" }` to end it — deciding for B writes its copy onto the form — and `null` to clear. | | `kind` | FormKind | no | | | `list_id` | string | no | Signup forms only. Must be one of this workspace's lists. | | `fields` | array of string | no | | | `thank_you_message` | string | no | | | `redirect_url` | string | no | | | `notify_email` | string \| null | no | Required for contact forms. Must be a workspace member's address or an address on one of the workspace's verified sending domains. | | `notify_subject` | string \| null | no | | | `allowed_origins` | array \| null | no | Up to 20 origins of the form `scheme://host[:port]`; null clears. | | `turnstile_site_key` | string \| null | no | | | `turnstile_secret` | string \| null | no | | | `daily_cap` | integer | no | | Example: ``` { "name": "Contact us", "kind": "contact", "notify_email": "hello@acme.com", "notify_subject": "Website enquiry", "allowed_origins": [ "https://acme.com" ], "daily_cap": 100 } ``` FormUpdate ``` { "allOf": [ { "type": "object", "required": [ "id" ], "properties": { "id": { "type": "string", "format": "uuid" }, "status": { "type": "string", "enum": [ "active", "inactive" ] } } }, { "$ref": "#/components/schemas/FormCreate" } ], "example": { "id": "11111111-2222-4333-8444-555555555555", "status": "inactive" } } ``` Example: ``` { "id": "11111111-2222-4333-8444-555555555555", "status": "inactive" } ``` FormSubmission — Built-in fields plus any custom field declared in the form's `fields`. Undeclared keys are ignored. String values are trimmed and capped (200 characters; 5,000 for `message`). | Field | Type | Required | Description | | --- | --- | --- | --- | | `email` | string | yes | | | `first_name` | string | no | | | `last_name` | string | no | | | `name` | string | no | Contact forms: full name (falls back to first_name + last_name). | | `subject` | string | no | Contact forms only. | | `message` | string | no | Contact forms: required. | | `turnstile_token` | string | no | Cloudflare Turnstile response when the form has Turnstile enabled (`cf-turnstile-response` is accepted as an alias). | Example: ``` { "email": "jane@example.com", "first_name": "Jane", "last_name": "Doe", "name": "Jane Doe", "subject": "Question about pricing", "message": "Do you offer annual billing?", "turnstile_token": "string" } ``` FormSubmissionResult | Field | Type | Required | Description | | --- | --- | --- | --- | | `success` | boolean | yes | | | `message` | string | yes | The form's thank-you message. | | `redirect_url` | string \| null | yes | | Example: ``` { "success": true, "message": "Thanks for subscribing!", "redirect_url": null } ``` --- # API reference: Webhooks Get a signed HTTP POST from SendBeam the moment a contact, email, campaign, form or domain event happens in your workspace. A webhook turns SendBeam into a source of live events for your own systems. You register an HTTPS URL, choose the events you care about, and SendBeam sends that URL a signed JSON `POST` the moment one of them happens — a contact joining, an email bouncing, a campaign finishing, a form being submitted, a sending domain verifying. ## What a webhook is Instead of polling the API asking "has anything changed?", you give SendBeam an address and it tells you. Each registered address is an **endpoint**. An endpoint has: - a **URL** — where the request goes; - a set of **events** — an endpoint receives only the events it subscribes to; - a **signing secret** — proves a request really came from SendBeam; - an **enabled** switch, and a delivery log you can read for debugging. You can register several endpoints and split events between them: one for your CRM, one for your data warehouse, one for an internal alerting bot. How many you may have depends on your plan — Free 1, Starter 5, Pro and Business unlimited — counted per workspace, so each site you run gets its own allowance. > Every plan receives the full event catalog. The plan changes how many endpoints you can register, never which events you can hear about. > ## The payload Every delivery is a `POST` with a JSON body of the same four fields, whatever the event: ``` { "id": "b1a4c0de-5f6a-4b7c-8d9e-0f1a2b3c4d5e", "event": "contact.created", "created_at": "2026-09-03T09:41:12.204Z", "data": { "contact": { "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "email": "jane@example.com", "status": "subscribed", "first_name": "Jane", "last_name": "Doe", "source": "form", "custom_fields": {}, "created_at": "2026-09-03T09:41:12.011Z", "subscribed_at": "2026-09-03T09:41:12.011Z", "unsubscribed_at": null, "tags": [] } } } ``` - `id` — the delivery's own identifier. It stays the same across retries, so use it as an idempotency key and ignore an `id` you have already processed. - `event` — the event name, one of the catalog below. - `created_at` — when the event happened, not when this attempt was made. - `data` — the object the event is about. Its fields depend on the event: a contact event carries the contact, an email event carries the message and recipient, a domain event carries the domain. The request also carries these headers: ``` POST /hooks/sendbeam HTTP/1.1 Content-Type: application/json User-Agent: SendBeam-Webhooks/1.0 (+https://sendbeam.io/docs/api/webhooks) X-SendBeam-Event: contact.created X-SendBeam-Delivery: b1a4c0de-5f6a-4b7c-8d9e-0f1a2b3c4d5e X-SendBeam-Signature: t=1788500472,v1=1f8b0c9d… ``` Answer quickly with any `2xx` status — that is the whole contract. Anything else, or no answer within a few seconds, counts as a failure and the delivery is retried. If your handler has slow work to do, acknowledge first and do the work afterwards. Redirects are never followed: a `3xx` counts as a failure, so register the final URL. ## Event catalog There are 26 events. Subscribe to as few or as many as you like on each endpoint. ### Contacts | Event | Sent when | | --- | --- | | `contact.created` | A new contact was added, by any route (form, import, API, dashboard). | | `contact.updated` | A contact’s fields, tags membership aside, changed. | | `contact.unsubscribed` | A contact unsubscribed, or was set to unsubscribed. | | `contact.resubscribed` | A previously unsubscribed contact subscribed again with fresh consent. | | `contact.bounced` | A contact’s address hard-bounced and is now suppressed. | | `contact.complained` | A contact marked a message as spam and is now suppressed. | | `contact.deleted` | A contact was deleted (an erasure or manual delete). | | `contact.tag_added` | A tag was added to a contact. | | `contact.tag_removed` | A tag was removed from a contact. | | `contact.list_joined` | A contact joined a list (immediately, or on double opt-in confirmation). | | `contact.list_left` | A contact left or was removed from a list. | ### Email | Event | Sent when | | --- | --- | | `email.sent` | An email was accepted by SendBeam’s managed delivery for sending. | | `email.delivered` | An email was delivered to the recipient’s mail server. | | `email.opened` | A recipient opened an email (first open only). | | `email.clicked` | A recipient clicked a link in an email (first click only). | | `email.bounced` | An email bounced. | | `email.complained` | A recipient marked an email as spam. | ### Campaigns | Event | Sent when | | --- | --- | | `campaign.sent` | A campaign finished sending to its whole audience. | ### Forms | Event | Sent when | | --- | --- | | `form.submitted` | A public form (signup or contact) was submitted and accepted. | ### Domains | Event | Sent when | | --- | --- | | `domain.verified` | A sending domain finished DNS verification successfully. | | `domain.failed` | A sending domain’s verification failed or lapsed. | ### Workspace | Event | Sent when | | --- | --- | | `workspace.paused` | Sending stopped for this workspace — automatically because delivery results deteriorated, or because we paused it. Nothing goes out until it resumes; everything else keeps working. | | `workspace.resumed` | Sending started again for this workspace. | | `workspace.health_warning` | Delivery results for this workspace are deteriorating. Sending continues, but this is the warning before a pause. | ### Automations | Event | Sent when | | --- | --- | | `automation.failed` | An automation could not complete a step — a deleted template, a tag that no longer exists, a send that failed. The automation stays active; the run that hit it stopped. At most one per automation per day. | | `automation.step_reached` | A contact reached a "Call a webhook" step in one of your automations. Unlike every other event here, this one is sent because an automation asked for it — add the step to an automation and choose which of your endpoints should hear about it. Carries the contact, the automation and the step's own label. | ## Creating an endpoint ### From the dashboard 1. Open the **Webhooks** page in your workspace settings and choose **Add endpoint**. 2. Choose where the events should go: **your server**, which gets the signed JSON described here, or a chat channel — [Slack](#slack) or [Microsoft Teams](#teams). 3. Paste your HTTPS URL and, optionally, a short description so you can tell endpoints apart later. 4. Tick the events this endpoint should receive. 5. Save. The signing secret is shown **once**, on the confirmation screen — copy it into your application's configuration before leaving the page. 6. Use **Send test event** to check your receiver answers, then watch the delivery log as real events arrive. > The secret is stored so that it can be used for signing but never shown again. If you lose it, rotate it — that issues a new one and invalidates the old. > ### Through the API Endpoint management lives under `/api/v1/webhooks` and uses the `webhooks:read` and `webhooks:write` permissions on your API key. Creating, updating and deleting endpoints count as API writes, like any other, towards the plan's hourly allowance. ``` curl -X POST https://sendbeam.io/api/v1/webhooks \ -H "x-api-key: $SENDBEAM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/sendbeam", "description": "Sync new contacts into the CRM", "event_types": ["contact.created", "contact.unsubscribed"] }' ``` The response — the only one that ever contains the secret: ``` { "id": "7c2f1e90-3a4b-4c5d-8e9f-1a2b3c4d5e6f", "url": "https://example.com/hooks/sendbeam", "description": "Sync new contacts into the CRM", "event_types": ["contact.created", "contact.unsubscribed"], "enabled": true, "secret": "9f2c1a0b3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8", "created_at": "2026-09-03T09:40:00.000Z" } ``` The rest of the surface: | Request | Does | | --- | --- | | `GET /api/v1/webhooks` | Lists your endpoints and their health (failure count, last success, last failure). Never returns secrets. | | `PATCH /api/v1/webhooks/{id}` | Changes the URL, description, events, `filters` or enabled state. | | `DELETE /api/v1/webhooks/{id}` | Removes the endpoint and its delivery log. | | `POST /api/v1/webhooks/{id}/rotate-secret` | Issues a new signing secret and returns it. The old one stops working at once. | | `GET /api/v1/webhooks/{id}/deliveries` | The recent delivery log, newest first. | | `POST /api/v1/webhooks/{id}/test` | Sends one synthetic event immediately and reports the result. | Full request and response schemas are in the [API reference](https://sendbeam.io/docs/api#tag-webhooks). ## Scoping an endpoint By default an endpoint receives every event of the types it subscribes to, from anywhere in the workspace. An optional `filters` object on create and update narrows that to particular resources. Recognised keys are `list_ids`, `form_ids`, `tag_ids`, `campaign_ids` and `domain_ids`; each takes an array of up to 100 ids that belong to your own workspace, and an id that does not is a `400` rather than a filter that quietly matches nothing. Omitting `filters`, or sending `{}`, means unscoped — the behaviour every endpoint has today. Every endpoint response includes the field, so `GET /api/v1/webhooks` tells you what each one is scoped to. ``` { "list_ids": ["b0d1e2f3-4a5b-4c6d-8e9f-0a1b2c3d4e5f"], "form_ids": ["3c4d5e6f-7a8b-49c0-8d1e-2f3a4b5c6d7e"] } ``` A dimension you scope has to be **present on the event** or it will not match, and dimensions you scope are combined with AND. The example above delivers only events that carry both that list and that form — a submission of that form joining that list. Scope on one dimension at a time unless you mean that. Events carry the dimensions that are part of what happened: `contact.list_joined` and `contact.list_left` carry a list; `form.submitted` and the contact events a public submission creates carry a form; `contact.tag_added` and `contact.tag_removed` carry a tag; `campaign.sent` and the `email.*` events of a campaign send carry a campaign; `domain.verified` and `domain.failed` carry a sending domain. Anything else carries none, so a `contact.updated` will never reach an endpoint scoped to a list. The `workspace.*` events carry no dimension at all, deliberately: they are about the workspace itself, not about a contact, a list or a campaign. An endpoint scoped to a list will therefore never receive one — if you want to know that sending stopped, subscribe an endpoint that is not scoped. In the dashboard this is the **Only for specific lists, forms or tags** choice on the Webhooks page, and **Edit scope** on an endpoint you already have. ## Sending to Slack An endpoint can deliver a readable Slack message instead of JSON. In Slack, add an **Incoming Webhook** for the channel you want and copy the URL it gives you (hooks.slack.com/services/…). In SendBeam, add an endpoint, choose **A Slack channel**, and paste it. There is no Slack app to install and no automation tool in between. A Slack channel carries the **operational** events only — the ones that mean something has stopped or changed and a person should look: - `workspace.paused` — Sending stopped for this workspace — automatically because delivery results deteriorated, or because we paused it. Nothing goes out until it resumes; everything else keeps working. - `workspace.resumed` — Sending started again for this workspace. - `workspace.health_warning` — Delivery results for this workspace are deteriorating. Sending continues, but this is the warning before a pause. - `automation.failed` — An automation could not complete a step — a deleted template, a tag that no longer exists, a send that failed. The automation stays active; the run that hit it stopped. At most one per automation per day. - `campaign.sent` — A campaign finished sending to its whole audience. - `domain.verified` — A sending domain finished DNS verification successfully. - `domain.failed` — A sending domain’s verification failed or lapsed. The contact and email events are deliberately not offered. A channel that receives a message for every subscriber is a channel people mute, and then the one that mattered — sending paused — is missed too. Route those through n8n, Make or Zapier, where they can be filtered and batched: see the [Slack integration page](https://sendbeam.io/integrations/slack). Every message names its workspace, so several workspaces can post into one channel and still be told apart, and carries a link to the page that answers it. Delivery works exactly as it does for a JSON endpoint: the same retries, the same delivery log, and the same auto-disable if the URL stops working — which is what happens when a Slack webhook is revoked. Re-enable it after pasting a new URL. Slack does not verify our signature, so the [signature header](#verifying-the-signature) is irrelevant to a Slack channel; the secrecy of the incoming webhook URL is what makes it trustworthy. Treat that URL as a credential — anyone holding it can post into the channel. ## Sending to Microsoft Teams An endpoint can deliver a card into a Microsoft Teams channel. In Teams, add the **Workflows** app, create *Post to a channel when a webhook request is received*, and copy the URL it gives you. In SendBeam, add an endpoint, choose **A Microsoft Teams channel**, and paste it. > If a guide tells you to add an **Incoming Webhook connector** and gives you an > outlook.office.com URL, it is out of date. Microsoft retired Office 365 > connectors in May 2026 and those URLs no longer deliver anything. Workflows is the replacement. > A Teams channel carries the same **operational** events as Slack — the ones that mean something has stopped or changed and a person should look: - `workspace.paused` — Sending stopped for this workspace — automatically because delivery results deteriorated, or because we paused it. Nothing goes out until it resumes; everything else keeps working. - `workspace.resumed` — Sending started again for this workspace. - `workspace.health_warning` — Delivery results for this workspace are deteriorating. Sending continues, but this is the warning before a pause. - `automation.failed` — An automation could not complete a step — a deleted template, a tag that no longer exists, a send that failed. The automation stays active; the run that hit it stopped. At most one per automation per day. - `campaign.sent` — A campaign finished sending to its whole audience. - `domain.verified` — A sending domain finished DNS verification successfully. - `domain.failed` — A sending domain’s verification failed or lapsed. Each message is an **Adaptive Card**: a headline coloured by severity, the workspace it belongs to, the few facts that matter, and a button to the page that answers it. Workflows will accept the older MessageCard format too, but it does not render buttons on one — which is why the card is what SendBeam sends. Delivery works exactly as it does for a JSON endpoint: the same retries, the same delivery log, and the same auto-disable if the URL stops working. Teams does not verify our signature, so the [signature header](#verifying-the-signature) is irrelevant here; the secrecy of the Workflow URL is what makes it trustworthy. Treat it as a credential — anyone holding it can post into the channel. ### What to expect Two things about Teams are worth knowing before you rely on it for anything urgent. - **A successful send means “accepted”, not “posted”.** Microsoft answers `202` the moment it receives the card, then runs your Workflow separately. If the Workflow itself fails, we never hear about it. “Send test event” says so plainly — check the channel to confirm the card arrived. - **Set the trigger to “Anyone”.** If the Workflow is set to require a signed-in user, it rejects our request and every delivery fails. Two things to expect that are Microsoft's design, not ours: the card posts as *“ via Workflows”* and cannot carry your own name or icon, and a Workflow whose owner leaves — or that sits unused for 90 days, or errors for 14 — is turned off by Microsoft. Adding a co-owner to the Workflow avoids the first of those. ## Verifying the signature Your endpoint is a public URL, so anyone could POST to it. Verify the signature on every request and reject anything that does not match — that, and nothing else, is what tells you the request came from SendBeam. Each delivery carries a header of the form: ``` X-SendBeam-Signature: t=,v1= ``` To check it: 1. Split the header on the comma and read `t` and `v1`. 2. Build the signed string by joining the timestamp and the **exact raw request body** with a full stop: `{t}.{raw body}`. The timestamp is part of what is signed, so an old genuine payload cannot be replayed against you at a different time. 3. Compute `HMAC-SHA256` over that string using your endpoint's secret, hex-encoded (lower case). 4. Compare it with `v1` using a constant-time comparison, so a timing measurement cannot recover the secret byte by byte. 5. Reject the request if the timestamp is more than five minutes away from your own clock. > Sign the **raw** body exactly as received. Parsing the JSON and re-serialising it changes whitespace and key order, and the signature will not match. > ### Node.js ``` import crypto from 'node:crypto'; import express from 'express'; const app = express(); const SECRET = process.env.SENDBEAM_WEBHOOK_SECRET; const TOLERANCE_SECONDS = 300; // 5 minutes function verify(rawBody, header, secret) { // Header looks like: t=1788500472,v1= const parts = Object.fromEntries(header.split(',').map((p) => p.split('='))); const timestamp = Number(parts.t); const received = parts.v1; if (!Number.isFinite(timestamp) || !received) return false; // Reject a signature that has drifted too far, so an old but genuine // payload cannot be replayed at you later. if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > TOLERANCE_SECONDS) return false; // The signed material is the timestamp, a full stop, then the exact raw body. const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex'); const a = Buffer.from(expected, 'utf8'); const b = Buffer.from(received, 'utf8'); return a.length === b.length && crypto.timingSafeEqual(a, b); } // Keep the RAW body: re-serialising parsed JSON will not match the signature. app.post('/hooks/sendbeam', express.raw({ type: 'application/json' }), (req, res) => { const rawBody = req.body.toString('utf8'); if (!verify(rawBody, req.get('X-SendBeam-Signature') || '', SECRET)) { return res.status(400).send('bad signature'); } const event = JSON.parse(rawBody); // event.id is stable across retries — use it to ignore a repeat. console.log(event.event, event.data); res.status(200).send('ok'); // answer fast; do the slow work afterwards }); app.listen(3000); ``` ### Python ``` import hashlib import hmac import json import time from flask import Flask, request app = Flask(__name__) SECRET = "your endpoint secret" TOLERANCE_SECONDS = 300 # 5 minutes def verify(raw_body: bytes, header: str, secret: str) -> bool: # Header looks like: t=1788500472,v1= try: parts = dict(p.split("=", 1) for p in header.split(",")) timestamp = int(parts["t"]) received = parts["v1"] except (ValueError, KeyError): return False if abs(int(time.time()) - timestamp) > TOLERANCE_SECONDS: return False signed = f"{timestamp}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, received) @app.post("/hooks/sendbeam") def hook(): raw_body = request.get_data() # raw bytes, not request.json if not verify(raw_body, request.headers.get("X-SendBeam-Signature", ""), SECRET): return "bad signature", 400 event = json.loads(raw_body) print(event["event"], event["data"]) return "ok", 200 ``` ## Retries and auto-disabling If a delivery fails — a non-`2xx` status, a redirect, a timeout, a connection that will not open — SendBeam tries again. Retries start within about half a minute and back off from there, so an endpoint that is briefly down catches up on its own. In all there are five attempts spread over roughly six hours; after the last one the delivery is marked **failed** and no further attempt is made for that event. Retries are per event, so a busy workspace can have many deliveries retrying at once. Because the same delivery `id` is sent on every attempt, a receiver that dedupes on it will never double-process an event it already accepted but failed to acknowledge in time. Separately, SendBeam watches the endpoint as a whole. After 20 failed deliveries in a row it is **auto-disabled**: deliveries stop, queued ones are dropped, and the endpoint shows a reason explaining what happened. This exists so a URL that has been decommissioned does not get retried forever. Fix the receiver, then re-enable the endpoint from the dashboard or with `PATCH … {"enabled": true}` — re-enabling clears the reason and resets the failure count, so it starts again with a clean record. Events that happened while it was disabled are not replayed. ## Testing and debugging **Send test event** in the dashboard — or `POST /api/v1/webhooks/{id}/test` — sends one synthetic, fully signed payload straight away and tells you what came back, so you can confirm a new receiver works without waiting for a real event. A test has the same shape as a real event of that type, with `"test": true` in `data`, an `id` starting `test_` and obviously fake values, so anything you map from it keeps working on real events. A test is never added to the delivery log. ``` curl -X POST "https://sendbeam.io/api/v1/webhooks/$WEBHOOK_ID/test" \ -H "x-api-key: $SENDBEAM_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event": "contact.created"}' # → {"ok":true,"status":200} ``` When something is not arriving, read the delivery log: it records every event queued for the endpoint, how many attempts it took, the last status code and the last error. ``` curl "https://sendbeam.io/api/v1/webhooks/$WEBHOOK_ID/deliveries?limit=20" \ -H "x-api-key: $SENDBEAM_API_KEY" ``` ``` { "deliveries": [ { "id": "b1a4c0de-5f6a-4b7c-8d9e-0f1a2b3c4d5e", "event_type": "contact.created", "status": "delivered", "attempts": 1, "last_status_code": 200, "last_error": null, "delivered_at": "2026-09-03T09:41:12.902Z", "created_at": "2026-09-03T09:41:12.204Z" } ], "pagination": { "page": 1, "limit": 20, "total": 1, "total_pages": 1 } } ``` The status of a row is `pending` (queued or waiting to retry), `delivered` (a 2xx came back), `failed` (attempts used up) or `abandoned` (the endpoint was disabled or deleted before it could be sent). The JSON body of each delivery is left out by default, because it can contain your contacts' personal data; add `&include=payload` when you actually need to see it. ## Requirements and limits - **HTTPS only.** A plain `http://` URL is refused. - **The URL must be reachable from the public internet.** Anything that resolves to a private, loopback or link-local address — `localhost`, `127.0.0.1`, `10.x`, `192.168.x`, a `.internal` or `.local` name — is refused when you register it and checked again before every delivery. To develop locally, put a tunnelling service in front of your machine and register the public URL it gives you. - **No credentials in the URL.** Use a secret path segment or verify the signature (better) instead of `https://user:pass@…`. - **Answer within a few seconds.** A delivery that has not been answered in about eight seconds is treated as failed and retried. - **Answer with a 2xx.** Redirects are not followed and count as failures. - **Your response body is ignored** and only the first few kilobytes of it are read at all. > Deliveries come from SendBeam's edge network rather than a fixed address, so allow-listing by source IP is not something we can support. Verify the signature instead — it is the stronger check. > --- # Guides: Guides Step-by-step recipes for the jobs a small site needs email for: forms without a backend, newsletter signups, transactional email and DNS. These guides are written for people who run a few small websites and would rather not run a server for them. Each one solves a single problem end to end, with code you can paste, a way to test it and a plain account of the trade-offs. They lean on two parts of SendBeam: the public form endpoint, which lets a static page post a signup or a message without any backend, and the REST API, which sends a single email to a contact. If you are looking for host- or framework-specific notes first, the [integrations pages](https://sendbeam.io/integrations) cover Astro, Next.js, Hugo, Eleventy, WordPress, Cloudflare Pages, Netlify, Vercel, Zapier and Make. ## The guides Contact form backend for Cloudflare Pages A contact form on a static Cloudflare Pages site that emails you, with Turnstile and no Pages Function. Newsletter signup on an Astro site An Astro component that posts a signup to SendBeam, with the form ID in an environment variable. Form to email without a backend Plain HTML and fetch: a form on any static host that lands in your inbox. Transactional email for a side project Welcome emails, receipts and notifications from a Node script or serverless function via the REST API. Double opt-in on a static site Confirmed subscriptions from a static site: what happens after the POST and what to tell the visitor. Verify a sending domain with Cloudflare DNS Add the CNAME records at Cloudflare, or let SendBeam add them, and pick your from-address. ## Starter kits Working code for every guide above lives in five small, runnable projects. Each one carries a newsletter signup, a contact form, the double opt-in wording, an unsubscribe route and the spam protection, with a deploy config for its host. Clone one, put your form IDs in its environment file and deploy: - HTML and vanilla JS [Plain HTML No API key needed · Any static host One page, one script, no build step: paste the files onto any host and both forms work. sendbeam-starters/plain-html](https://github.com/sendbeam-io/sendbeam-starters/tree/main/plain-html) - Astro [Astro on Cloudflare Pages No API key needed · Cloudflare Pages Signup and contact components, form IDs in PUBLIC_ environment variables, and a _headers file that lets Turnstile load. sendbeam-starters/astro-cloudflare-pages](https://github.com/sendbeam-io/sendbeam-starters/tree/main/astro-cloudflare-pages) - Eleventy [Eleventy on Netlify No API key needed · Netlify Nunjucks includes and a global data file, with a netlify.toml that sets the Content-Security-Policy for you. sendbeam-starters/eleventy-netlify](https://github.com/sendbeam-io/sendbeam-starters/tree/main/eleventy-netlify) - Next.js (App Router) [Next.js on Vercel No API key needed · Vercel Client components that post straight to the form endpoint, so no route handler and no secret sit in the middle. sendbeam-starters/nextjs-vercel](https://github.com/sendbeam-io/sendbeam-starters/tree/main/nextjs-vercel) - Hugo [Hugo No API key needed · Any static host Two partials and one script in static/, with the form IDs read from site params in hugo.toml. sendbeam-starters/hugo](https://github.com/sendbeam-io/sendbeam-starters/tree/main/hugo) Every starter covers the same five things — newsletter signup, contact form, double opt-in, unsubscribe and spam protection — reads its form IDs from environment variables or site config, and is MIT licensed. Browse them all in [sendbeam-starters](https://github.com/sendbeam-io/sendbeam-starters). --- # Guides: Contact form backend for Cloudflare Pages Put a working contact form on a static Cloudflare Pages site with no Pages Function, no Worker and no server: the page posts to SendBeam and the message lands in your inbox. Cloudflare Pages is a good home for a small site right up to the moment you need a contact form. A static build has nowhere to send the message. The usual answers are a Pages Function that calls an email API with a secret you now have to manage, or a third-party form service that emails you the submission. SendBeam's public form endpoint is the second kind of answer, with the difference that the same account also handles your newsletter and transactional email. This guide wires a plain HTML form on Cloudflare Pages to a SendBeam contact form, protects it with Cloudflare Turnstile and an origin allow-list, and sets the Content Security Policy header so all of it actually loads. ## Prerequisites - A SendBeam workspace (the Free plan is enough for this guide; see [billing and plans](https://sendbeam.io/docs/admin/billing) for its limits). - A site deployed on Cloudflare Pages, built by any static generator or by hand. - An address to receive messages at. It must be the email of a member of the workspace, or an address on a [verified sending domain](https://sendbeam.io/docs/getting-started/sending-domain). - Optional: a Cloudflare Turnstile widget (free) for the bot check. You create it in the Cloudflare dashboard under **Turnstile**; it gives you a site key and a secret key. ## Steps 1. **Create the contact form in SendBeam.** Open **Forms**, click add, and choose *Contact* as the form type. Under **Send messages to** enter the address that should receive submissions. Save, then open the form's page and copy its ID from the **API Endpoint** line: it is the UUID at the end of `https://sendbeam.io/api/forms/…`. The default fields for a contact form are `email`, `name`, `subject` and `message`, which is what the markup below sends. The details of what a contact form does are in [Creating a form](https://sendbeam.io/docs/forms/creating). 2. **Switch on the protection you want.** In the form's **Protection** section add your site under *Allowed sites* as an origin, for example `https://www.example.com` (scheme and host, no path; add the `pages.dev` preview origin too if you want to test there). If you are using Turnstile, paste the widget's site key and secret key in the same section. SendBeam stores the secret encrypted and verifies every token with Cloudflare before it reads the message. 3. **Add the form to your page.** Replace `YOUR_FORM_ID` and, if you are using Turnstile, `YOUR_TURNSTILE_SITE_KEY`, and `YOUR_FORM_CHECK_FIELD` with the hidden field name on the form's Embed tab — every form has its own. That input is positioned off screen and must stay empty; a submission that fills it is accepted with a 200 and thrown away, so the bot learns nothing. ` Name Email Subject Message Leave this empty Send message ` 4. **Add the script.** Save this as `public/contact-form.js` (or wherever your generator copies static files from). It posts the fields as JSON, shows the thank-you message SendBeam returns, and resets the Turnstile widget if the submission was refused so the visitor can try again. There is no Pages Function in this setup: the browser talks to SendBeam directly and the endpoint answers with CORS headers. `// public/contact-form.js (function () { var form = document.getElementById('contact-form'); var status = document.getElementById('contact-status'); if (!form || !status) return; form.addEventListener('submit', function (event) { event.preventDefault(); var button = form.querySelector('button[type="submit"]'); var tokenField = form.querySelector('[name="cf-turnstile-response"]'); var payload = { name: form.name.value, email: form.email.value, subject: form.subject.value, message: form.message.value, YOUR_FORM_CHECK_FIELD: form.YOUR_FORM_CHECK_FIELD ? form.YOUR_FORM_CHECK_FIELD.value : '', turnstile_token: tokenField ? tokenField.value : '' }; button.disabled = true; status.textContent = 'Sending…'; fetch(form.dataset.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) .then(function (res) { return res.json().then(function (json) { return { ok: res.ok, json: json }; }); }) .then(function (res) { if (res.ok && res.json.success) { form.reset(); form.querySelectorAll('input, textarea, button').forEach(function (el) { el.hidden = true; }); status.textContent = res.json.message; if (res.json.redirect_url) setTimeout(function () { location.assign(res.json.redirect_url); }, 1500); return; } status.textContent = res.json.error || 'Something went wrong. Please email us instead.'; if (window.turnstile) window.turnstile.reset(); }) .catch(function () { status.textContent = 'Could not reach the server. Please email us instead.'; }) .finally(function () { button.disabled = false; }); }); })();` 5. **Set the Content Security Policy.** If your site sends a CSP header (and a Pages site should), the Turnstile script, its iframe and the two outbound requests need allowing. Cloudflare Pages reads a `_headers` file from the build output; put this in `public/` so it is copied to the root. Adjust the other directives to match your site; the parts that matter here are `challenges.cloudflare.com` in `script-src`, `frame-src` and `connect-src`, and `sendbeam.io` in `connect-src`. `# public/_headers (copied to the site root by Cloudflare Pages) /* Content-Security-Policy: default-src 'self'; script-src 'self' https://challenges.cloudflare.com; connect-src 'self' https://sendbeam.io https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; img-src 'self' data:; style-src 'self' 'unsafe-inline' X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin` 6. **Deploy.** Commit and push; Pages builds and publishes as usual. Nothing is needed in the Pages project settings, and there are no secrets to add, because the only secret in the system (the Turnstile secret) lives in SendBeam. > **Using the Astro starter instead?** The > [astro-cloudflare-pages starter](https://github.com/sendbeam-io/sendbeam-starters/tree/main/astro-cloudflare-pages) > contains this form as an Astro component, reads the form ID from > `PUBLIC_SENDBEAM_CONTACT_FORM_ID` and ships the `_headers` file above. > Host notes are on the [Cloudflare Pages integration page](https://sendbeam.io/integrations/cloudflare-pages). > ## Test it Test on the deployed site, not on `localhost`: if you listed allowed sites, a request from a local origin is refused with a 403, which is the allow-list working. Fill in the form with an address you control and send it. You should see the thank-you message in place of the form, and within a few seconds an email at your *Send messages to* address with the subject prefixed by the form's name and the visitor as Reply-To. Press reply in your mail client and check the To field is the visitor's address. To test the endpoint on its own, or to see what a wrong origin looks like, use curl. With Turnstile enabled this request is refused with a 403 and a `turnstile` code, which is also correct behaviour. ``` curl -i -X POST https://sendbeam.io/api/forms/YOUR_FORM_ID \ -H "Content-Type: application/json" \ -H "Origin: https://www.example.com" \ -d '{"name":"Test","email":"you@example.com","subject":"Ping","message":"Hello from curl"}' ``` A successful submission returns: ``` { "success": true, "message": "Thanks — your message has been sent. We'll reply by email.", "redirect_url": null } ``` Every submission, delivered or not, is recorded against the form in SendBeam, so a message is never lost if the notification email fails. If it does fail the endpoint answers 503 with an `error`; the script above shows that text, which is your cue to keep a plain `mailto:` link somewhere on the page as a fallback. ## Dealing with spam A public form has no secret by design, so the protection is layered, and it helps to know the order SendBeam applies it in, because the first layer that trips decides the response: - **Rate limit.** Too many submissions from one visitor in a short window, then a 429. - **Allowed sites.** The browser's `Origin` (or `Referer`) must be on the list. Browsers cannot forge this, so drive-by embedding stops here. A script with curl can set any header it likes, which is why the next layers exist. - **Automated-submission checks.** A post that carries the signs of a script rather than a person gets a 200 and nothing else happens. Most crude bots fail here. - **Turnstile.** When the form has a secret, a valid token is required before the message is even read. This is the layer that stops scripted abuse; it is free and invisible to most people. - **Daily cap.** A per-form daily limit, shown and adjustable in the Protection section, so a bad night cannot empty your quota. For a personal or small business site, allowed sites plus Turnstile is the sensible setting. If you skip Turnstile you will still get the built-in submission checks and rate limits, which is enough for low-traffic pages, but expect the occasional human-typed spam message. ## Why not Netlify Forms, Formspree or a Pages Function? **Netlify Forms** is excellent if you are on Netlify. It is not available on Cloudflare Pages, which is the whole reason this guide exists. It also needs its `data-netlify` attribute present in the built HTML for detection, and the free tier stops at 100 submissions a month. **Formspree** works anywhere and is quick to set up. It is priced per form and per submission, and it is a forms product: there is no list, no double opt-in and no way to email the people who wrote in later. If a contact form is all you will ever need, it is a fair choice. **A Pages Function** calling an email API gives you total control and costs nothing extra. In return you own the API key as a Pages secret, the rate limiting, the bot protection, the DKIM setup for the sending domain and the maintenance. That is a reasonable trade for one site and a poor one for six. SendBeam's limitations are worth stating too: the receiving address must belong to a workspace member or a verified domain, the public endpoint is rate-limited as described above, and the Free plan sends from a shared address until you verify your own domain. ## Next steps - Add a newsletter signup to the same site: [Newsletter signup on an Astro site](https://sendbeam.io/docs/guides/newsletter-signup-astro) or the plain HTML version in [Form to email without a backend](https://sendbeam.io/docs/guides/form-to-email-without-backend). - Send from your own domain so notifications and newsletters carry your name: [Verify a sending domain with Cloudflare DNS](https://sendbeam.io/docs/guides/sending-domain-cloudflare-dns). - The full endpoint contract, including every error, is in the [API reference](https://sendbeam.io/docs/api) under Forms. ## Starter kits Working code for this, form and script and `_headers` file together, is in the starter kits. Clone the one closest to your setup, drop in your form ID and deploy: - HTML and vanilla JS [Plain HTML No API key needed · Any static host One page, one script, no build step: paste the files onto any host and both forms work. sendbeam-starters/plain-html](https://github.com/sendbeam-io/sendbeam-starters/tree/main/plain-html) - Astro [Astro on Cloudflare Pages No API key needed · Cloudflare Pages Signup and contact components, form IDs in PUBLIC_ environment variables, and a _headers file that lets Turnstile load. sendbeam-starters/astro-cloudflare-pages](https://github.com/sendbeam-io/sendbeam-starters/tree/main/astro-cloudflare-pages) - Eleventy [Eleventy on Netlify No API key needed · Netlify Nunjucks includes and a global data file, with a netlify.toml that sets the Content-Security-Policy for you. sendbeam-starters/eleventy-netlify](https://github.com/sendbeam-io/sendbeam-starters/tree/main/eleventy-netlify) - Next.js (App Router) [Next.js on Vercel No API key needed · Vercel Client components that post straight to the form endpoint, so no route handler and no secret sit in the middle. sendbeam-starters/nextjs-vercel](https://github.com/sendbeam-io/sendbeam-starters/tree/main/nextjs-vercel) - Hugo [Hugo No API key needed · Any static host Two partials and one script in static/, with the form IDs read from site params in hugo.toml. sendbeam-starters/hugo](https://github.com/sendbeam-io/sendbeam-starters/tree/main/hugo) Every starter covers the same five things — newsletter signup, contact form, double opt-in, unsubscribe and spam protection — reads its form IDs from environment variables or site config, and is MIT licensed. Browse them all in [sendbeam-starters](https://github.com/sendbeam-io/sendbeam-starters). --- # Guides: Newsletter signup on an Astro site An Astro component that adds a subscriber to a SendBeam list straight from the browser, with the form ID in an environment variable and no server-side code. Astro is built for content sites, and content sites want a newsletter. The friction is that a statically built Astro site has no place to put a subscriber, so people either bolt on a third-party embed with its own styles and script, or wire up an SSR endpoint and an API key to talk to a mailing service. This guide takes a third route: a small Astro component whose client script posts the signup as JSON to SendBeam's public form endpoint. The form ID is the only configuration, it lives in an environment variable, and the same list can later be mailed with campaigns or automations from SendBeam. ## Prerequisites - An Astro project. Static output is fine; nothing here needs an adapter or SSR. - A SendBeam workspace with a [list](https://sendbeam.io/docs/lists/creating) to add subscribers to. On the Free plan every list is double opt-in; on paid plans it is a per-list setting. - A signup form in SendBeam pointing at that list (created in the first step below). - Optional: a Cloudflare Turnstile widget if the form will sit on a busy page. ## Steps 1. **Create the signup form in SendBeam.** Under **Forms**, add a form with type *Signup* and choose the list under **Add to List**. Set the thank-you message; if the list is double opt-in, make it say "Check your inbox to confirm", because that message is exactly what the component will show. On the form's page, tick *First name* under Form Fields if you want to collect it, then copy the form ID from the **API Endpoint** line. 2. **Put the form ID in the environment.** Astro only exposes variables prefixed `PUBLIC_` to client code, and that is what you want here: the form ID is not a secret (it is visible to anyone who submits the form), it only identifies which form receives the post. Add the same variable in your host's dashboard for production builds. `# .env (PUBLIC_ variables are inlined into the client bundle by Astro) PUBLIC_SENDBEAM_SIGNUP_FORM_ID=00000000-0000-0000-0000-000000000000 # Optional: only if the form has Turnstile enabled in SendBeam PUBLIC_TURNSTILE_SITE_KEY=` 3. **Create the component.** The frontmatter reads the form ID at build time and writes the endpoint onto the form as a `data-endpoint` attribute, so the client script stays generic. The script is an ordinary Astro ``: it is bundled, type-checked and de-duplicated per page. It records the render time for the timing check, posts the fields, and swaps the inputs for the message SendBeam returns. The Turnstile widget and its script are only emitted when a site key is configured. `--- // src/components/NewsletterForm.astro const formId = import.meta.env.PUBLIC_SENDBEAM_SIGNUP_FORM_ID; const siteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY || ''; const endpoint = `https://sendbeam.io/api/forms/${formId}`; --- Email First name (optional) Leave this empty {siteKey && } Subscribe {siteKey && } // Astro bundles this once per page, however many forms are on it. document.querySelectorAll('form.sb-signup').forEach((form) => { const status = form.querySelector('.sb-status')!; const button = form.querySelector('button[type="submit"]')!; form.addEventListener('submit', async (event) => { event.preventDefault(); const data = new FormData(form); const payload = { email: String(data.get('email') || ''), first_name: String(data.get('first_name') || ''), YOUR_FORM_CHECK_FIELD: String(data.get('YOUR_FORM_CHECK_FIELD') || ''), turnstile_token: String(data.get('cf-turnstile-response') || ''), }; button.disabled = true; status.textContent = 'Subscribing…'; try { const res = await fetch(form.dataset.endpoint!, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const json = await res.json(); if (res.ok && json.success) { form.querySelectorAll('label, button, .cf-turnstile').forEach((el) => ((el as HTMLElement).hidden = true)); status.textContent = json.message; if (json.redirect_url) setTimeout(() => location.assign(json.redirect_url), 1500); } else { status.textContent = json.error || 'Something went wrong. Please try again.'; (window as any).turnstile?.reset(); } } catch { status.textContent = 'Could not reach the server. Please try again.'; } finally { button.disabled = false; } }); }); .sb-signup { display: grid; gap: .75rem; max-width: 28rem; position: relative; } .sb-signup label { display: grid; gap: .25rem; font-size: .9rem; } .sb-signup input { padding: .6rem .75rem; border: 1px solid #cbd5e1; border-radius: .5rem; font: inherit; } .sb-signup button { padding: .65rem 1rem; border: 0; border-radius: .5rem; background: #0b1020; color: #fff; font: inherit; font-weight: 600; cursor: pointer; } .sb-signup button:disabled { opacity: .6; cursor: default; } .sb-status:empty { display: none; } ` 4. **Use it on a page.** Drop the component wherever the signup belongs. If you place it in the layout footer, every page gets it and the script still runs once. `--- // src/pages/index.astro import Layout from '../layouts/Layout.astro'; import NewsletterForm from '../components/NewsletterForm.astro'; --- My site Get the newsletter ` 5. **Allow the requests in your CSP, if you have one.** The component makes one request to `sendbeam.io` and, with Turnstile, loads a script and an iframe from `challenges.cloudflare.com`. On Cloudflare Pages that is a `_headers` file; on Netlify a `[[headers]]` block in `netlify.toml`; on Vercel the `headers` key in `vercel.json`. `# public/_headers (Cloudflare Pages) — only needed if you send a CSP header /* Content-Security-Policy: default-src 'self'; script-src 'self' https://challenges.cloudflare.com; connect-src 'self' https://sendbeam.io https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:` 6. **Set the protection on the form.** In SendBeam, open the form's **Protection** section, add your site's origin under *Allowed sites*, and paste the Turnstile keys if you are using it. Then deploy. > The > [astro-cloudflare-pages starter](https://github.com/sendbeam-io/sendbeam-starters/tree/main/astro-cloudflare-pages) > is this component plus a contact form, a `.env.example` and the Pages headers file, > ready to clone. Framework notes are on the [Astro integration page](https://sendbeam.io/integrations/astro). > ## Test it Run `npm run dev` and submit the form with an address you control. If you have already set allowed sites, the local origin will be refused with a 403 and the component will display SendBeam's error text, which is the allow-list doing its job: either add `http://localhost:4321` temporarily or test on the deployed site. On success the inputs disappear and the thank-you message appears. Then check three things in SendBeam: the address is under **Contacts** with source *form*; it is a member of the list (pending, if the list is double opt-in, until the confirmation link is clicked); and, if you enabled *Email me about new subscribers* on the form, you received a note. To exercise the endpoint without the browser: ``` curl -s -X POST https://sendbeam.io/api/forms/YOUR_FORM_ID \ -H "Content-Type: application/json" \ -H "Origin: https://www.example.com" \ -d '{"email":"you@example.com","first_name":"Test"}' # → {"success":true,"message":"Thanks for subscribing!","redirect_url":null} ``` Submitting the same address twice is safe. The contact is updated rather than duplicated, and if the list is double opt-in, at most one confirmation email goes to an address every ten minutes, however many times the form is sent. Each confirmation email counts towards the workspace's monthly allowance. ## Dealing with spam Signup forms attract a different kind of abuse from contact forms: address-stuffing, where a bot subscribes strangers, and list-bombing, where one address is submitted to thousands of forms. Double opt-in is the real defence against both, because nothing is mailed to an address that never confirmed, and SendBeam applies its normal layers on top: per-visitor rate limits and a daily cap per form, the *Allowed sites* origin check, the automated-submission checks, and Turnstile when a secret is set. Addresses that previously bounced or complained are accepted with the normal thank-you and then ignored, so a public form can never be used to re-subscribe someone who left. For an Astro site with real traffic the recommendation is simple: allowed sites, Turnstile, and a double opt-in list. ## Why not a Formspree or Web3Forms endpoint? Both are good at what they do. **Web3Forms** in particular is generous and works the same way as this guide, with an access key in the page instead of a form ID. The difference is what happens after the POST. A forms service emails you the submission and stops. You then need somewhere to keep the subscribers, a way to confirm them, and a way to send the newsletter, which means a second product and a way to move addresses between the two. With SendBeam the POST creates the contact, joins the list, runs the confirmation flow and fires any automation you have set up, and the campaign editor is in the same place. The trade-off is that SendBeam is priced by contacts across your account (see [plans](https://sendbeam.io/docs/admin/billing)), and on the Free plan every list is double opt-in whether you want it or not. If you only want a message in your inbox and nothing else, a forms-only service is the smaller tool. ## Next steps - Understand what the visitor sees after subscribing, and what to say in the thank-you message: [Double opt-in on a static site](https://sendbeam.io/docs/guides/double-opt-in-static-site). - Add a contact form to the same site: [Contact form backend for Cloudflare Pages](https://sendbeam.io/docs/guides/contact-form-cloudflare-pages). - Send a welcome email automatically: create an [automation](https://sendbeam.io/docs/automations/triggers) on the *list joined* trigger. - Capture more than a name with [custom fields](https://sendbeam.io/docs/forms/creating#choosing-fields), which the component can send as extra keys once they are declared on the form. ## Starter kits This component, wired up in a site you can deploy, is in the starter kits. The Astro one is this guide as a running project; the others do the same thing in their own templating: - Astro [Astro on Cloudflare Pages No API key needed · Cloudflare Pages Signup and contact components, form IDs in PUBLIC_ environment variables, and a _headers file that lets Turnstile load. sendbeam-starters/astro-cloudflare-pages](https://github.com/sendbeam-io/sendbeam-starters/tree/main/astro-cloudflare-pages) - HTML and vanilla JS [Plain HTML No API key needed · Any static host One page, one script, no build step: paste the files onto any host and both forms work. sendbeam-starters/plain-html](https://github.com/sendbeam-io/sendbeam-starters/tree/main/plain-html) - Next.js (App Router) [Next.js on Vercel No API key needed · Vercel Client components that post straight to the form endpoint, so no route handler and no secret sit in the middle. sendbeam-starters/nextjs-vercel](https://github.com/sendbeam-io/sendbeam-starters/tree/main/nextjs-vercel) - Eleventy [Eleventy on Netlify No API key needed · Netlify Nunjucks includes and a global data file, with a netlify.toml that sets the Content-Security-Policy for you. sendbeam-starters/eleventy-netlify](https://github.com/sendbeam-io/sendbeam-starters/tree/main/eleventy-netlify) - Hugo [Hugo No API key needed · Any static host Two partials and one script in static/, with the form IDs read from site params in hugo.toml. sendbeam-starters/hugo](https://github.com/sendbeam-io/sendbeam-starters/tree/main/hugo) Every starter covers the same five things — newsletter signup, contact form, double opt-in, unsubscribe and spam protection — reads its form IDs from environment variables or site config, and is MIT licensed. Browse them all in [sendbeam-starters](https://github.com/sendbeam-io/sendbeam-starters). --- # Guides: Form to email without a backend Make a plain HTML form on any static host send its contents to your inbox: one endpoint, one small script, no server, no API key in the page. "Form to email" is the oldest job on the web and it is still awkward on a static site. There is no PHP `mail()` to call, and a `mailto:` action opens the visitor's mail client, which half of them do not have configured. What you want is for the browser to hand the fields to something that will email them to you. SendBeam's public form endpoint is that something. This guide is the framework-free version: an HTML page, a 40-line script, and a form in SendBeam that knows where to send the message. It works on GitHub Pages, Cloudflare Pages, Netlify, Vercel, S3, a shared host, or a folder served by nginx. ## How it works You create a *contact* form in SendBeam and give it a destination address. That form has a public URL of the shape `https://sendbeam.io/api/forms/`. Your page posts a JSON object to it. SendBeam records the submission, emails it to you from your workspace's sending address with the visitor as Reply-To, and answers with a thank-you message for the page to display. Nothing is added to your contacts, because writing in is not subscribing. The form ID is public by nature (anyone who can submit the form can see it), so there is nothing to protect in the page; abuse is handled at the endpoint, as described further down. ## Prerequisites - A SendBeam workspace. The Free plan covers a contact form. - A destination address that is either a member of the workspace or on a [verified sending domain](https://sendbeam.io/docs/getting-started/sending-domain). - A static site of any kind, hosted anywhere that serves HTML and JavaScript. ## Steps 1. **Create the form.** In SendBeam, open **Forms**, add a form, choose type *Contact*, and enter the receiving address under **Send messages to**. Set a thank-you message. Open the saved form and copy the ID from the **API Endpoint** line. If you prefer to skip the hand-written markup entirely, the same page offers a ready-made [embed snippet](https://sendbeam.io/docs/forms/embedding); the rest of this guide is for when you want your own HTML. 2. **Write the page.** The form needs an `email` and a `message`; `name` and `subject` are optional. The `data-endpoint` attribute is where the script posts to. Replace `YOUR_FORM_ID` and the fallback mailto address. ` Contact Contact Your name Your email Message Leave this empty Send Or email [hello@example.com](mailto:hello@example.com). ` 3. **Add the script.** It collects every named field into JSON, adds the render-time stamp, posts, and shows the result. It is generic: any form on the page with a `data-endpoint` attribute is wired up, so the newsletter form in the next step reuses it. The `:has()` selector used to hide the inputs on success is supported by every current browser; on an older one the inputs simply stay visible under the thank-you message. `// sendbeam-form.js — works for any form with a data-endpoint attribute (function () { document.querySelectorAll('form[data-endpoint]').forEach(function (form) { var status = form.querySelector('[role="status"]'); form.addEventListener('submit', function (event) { event.preventDefault(); // Every named field becomes a JSON key; SendBeam ignores keys the form does not declare. var payload = {}; new FormData(form).forEach(function (value, key) { payload[key] = value; }); // Turnstile, when present on the page, adds this hidden field; SendBeam reads it as the token. if (payload['cf-turnstile-response']) payload.turnstile_token = payload['cf-turnstile-response']; var button = form.querySelector('button[type="submit"]'); button.disabled = true; if (status) status.textContent = 'Sending…'; fetch(form.dataset.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) .then(function (res) { return res.json().then(function (json) { return { ok: res.ok, json: json }; }); }) .then(function (res) { if (res.ok && res.json.success) { form.querySelectorAll('p:not(:has([role="status"]))').forEach(function (p) { p.hidden = true; }); if (status) status.textContent = res.json.message; if (res.json.redirect_url) setTimeout(function () { location.assign(res.json.redirect_url); }, 1500); } else { if (status) status.textContent = res.json.error || 'Something went wrong.'; if (window.turnstile) window.turnstile.reset(); } }) .catch(function () { if (status) status.textContent = 'Could not reach the server. Please use the email link below.'; }) .finally(function () { button.disabled = false; }); }); }); })();` 4. **Keep a fallback.** The endpoint accepts JSON only, so a form cannot post to it without JavaScript. A `mailto:` link beside the form costs nothing and covers the rare visitor with scripts off, as well as the rare 503 when a notification cannot be sent. ` Or email [hello@example.com](mailto:hello@example.com).` 5. **Optionally, add a signup form with the same script.** Create a second form in SendBeam of type *Signup*, pointed at a list, and use its ID. The script does not change. ` Email Leave this empty Subscribe ` 6. **Deploy and set Allowed sites.** Upload the files. Then, in the form's **Protection** section in SendBeam, add your site's origin (for example `https://example.com`; add `https://www.example.com` too if both resolve). If your host sends a Content Security Policy header, add `https://sendbeam.io` to `connect-src`. ## Test it Open the deployed page and send yourself a message. You should see the thank-you text replace the form, and an email arrive at the destination address whose subject starts with the form's name and whose Reply-To is the address you typed. If you want to see the raw responses, curl is quicker than the browser: ``` curl -s -X POST https://sendbeam.io/api/forms/YOUR_FORM_ID \ -H "Content-Type: application/json" \ -d '{"name":"Test","email":"you@example.com","message":"Testing the form"}' # {"success":true,"message":"Thanks — your message has been sent. We'll reply by email.","redirect_url":null} ``` The errors you are most likely to meet while setting up, and what they mean: ``` # Missing message on a contact form # {"error":"A message is required"} HTTP 400 # Wrong origin when Allowed sites is set # {"error":"This form does not accept submissions from this site."} HTTP 403 # Too many submissions from one visitor in a short window # {"error":"Too many submissions. Please try again later."} HTTP 429 # Notification could not be delivered # {"error":"Your message could not be sent right now. Please try again or email us directly."} HTTP 503 ``` Each submission is also listed against the form in SendBeam, with whether the notification was delivered, so you can check there if an email seems to have gone missing. ## Dealing with spam Because there is no secret in the page, the endpoint protects itself in layers. The hidden field in the markup above is one of them: a submission carrying the signs of a script rather than a person is discarded. On top of that, every form is rate-limited per visitor and capped per form per day, with the caps shown and adjustable on the form's settings page. For anything more you switch on two settings in the form's Protection section. **Allowed sites** makes SendBeam check the browser's `Origin` header against the origins you list; browsers cannot forge it, so it ends drive-by embedding, though a script can still fake it. **Cloudflare Turnstile** closes that gap: add Cloudflare's script tag and a `` inside the form, paste the site key and secret into SendBeam, and every submission must carry a valid token or it is refused with a 403 before the message is read. The script above already forwards the token when the widget is present. Turnstile needs `https://challenges.cloudflare.com` allowed in `script-src`, `frame-src` and `connect-src` if you have a CSP. See the [Cloudflare Pages guide](https://sendbeam.io/docs/guides/contact-form-cloudflare-pages) for a complete Turnstile setup with a header file. ## Why not Formspree, Netlify Forms or Web3Forms? All three do form-to-email well and you would not be wrong to pick one. **Netlify Forms** is the least effort if you host on Netlify and stay under 100 submissions a month; it does not exist anywhere else and detects forms at build time from the HTML, which occasionally surprises people using client-side frameworks. **Formspree** is host-independent and mature, priced per form and per submission. **Web3Forms** is the closest in spirit to this guide: an access key in the page, JSON or form-encoded posts, and a generous free tier. What none of them do is anything after the email. If the same site also has a newsletter, or you want a welcome sequence, or you run four sites and want their forms, subscribers and sending domains in one account, that is the case for SendBeam. If a message in your inbox is the whole requirement, a forms-only service is the smaller and simpler tool. SendBeam's own constraints are the ones above: a JSON-only endpoint, the destination address rule, and the rate limits. ## Next steps - Do the same on a specific host, with Turnstile and a CSP header: [Contact form backend for Cloudflare Pages](https://sendbeam.io/docs/guides/contact-form-cloudflare-pages). - Framework versions of the form: the [Astro](https://sendbeam.io/integrations/astro), [Next.js](https://sendbeam.io/integrations/nextjs), [Hugo](https://sendbeam.io/integrations/hugo) and [Eleventy](https://sendbeam.io/integrations/eleventy) integration pages, and the [starter kits](https://github.com/sendbeam-io/sendbeam-starters). - Send notifications from your own domain instead of the shared address: [Verify a sending domain with Cloudflare DNS](https://sendbeam.io/docs/guides/sending-domain-cloudflare-dns). - The full contract for the endpoint is under Forms in the [API reference](https://sendbeam.io/docs/api). ## Starter kits If you would rather start from something that already runs, the starter kits have this form wired up. The plain HTML one is this guide with nothing added to it: - HTML and vanilla JS [Plain HTML No API key needed · Any static host One page, one script, no build step: paste the files onto any host and both forms work. sendbeam-starters/plain-html](https://github.com/sendbeam-io/sendbeam-starters/tree/main/plain-html) - Hugo [Hugo No API key needed · Any static host Two partials and one script in static/, with the form IDs read from site params in hugo.toml. sendbeam-starters/hugo](https://github.com/sendbeam-io/sendbeam-starters/tree/main/hugo) - Eleventy [Eleventy on Netlify No API key needed · Netlify Nunjucks includes and a global data file, with a netlify.toml that sets the Content-Security-Policy for you. sendbeam-starters/eleventy-netlify](https://github.com/sendbeam-io/sendbeam-starters/tree/main/eleventy-netlify) - Astro [Astro on Cloudflare Pages No API key needed · Cloudflare Pages Signup and contact components, form IDs in PUBLIC_ environment variables, and a _headers file that lets Turnstile load. sendbeam-starters/astro-cloudflare-pages](https://github.com/sendbeam-io/sendbeam-starters/tree/main/astro-cloudflare-pages) - Next.js (App Router) [Next.js on Vercel No API key needed · Vercel Client components that post straight to the form endpoint, so no route handler and no secret sit in the middle. sendbeam-starters/nextjs-vercel](https://github.com/sendbeam-io/sendbeam-starters/tree/main/nextjs-vercel) Every starter covers the same five things — newsletter signup, contact form, double opt-in, unsubscribe and spam protection — reads its form IDs from environment variables or site config, and is MIT licensed. Browse them all in [sendbeam-starters](https://github.com/sendbeam-io/sendbeam-starters). --- # Guides: Transactional email for a side project Send welcome emails, receipts and notifications from a Node script or serverless function with two API calls, on a domain you verify once, with no SMTP server to run. Every side project eventually needs to send one email to one person: a welcome note, a receipt, a "your export is ready". The traditional answer is SMTP, and the traditional experience is discovering that your VPS provider blocks port 25, that Gmail wants DKIM, SPF and a one-click unsubscribe header, and that nobody told you about bounces. This guide uses SendBeam's REST API instead. It is two HTTP calls: make sure the recipient exists as a contact, then send. Delivery, authentication for your domain, bounce handling and the unsubscribe headers are done by SendBeam's managed delivery, and every send shows up in the workspace's Activity page. ## What you get and what it costs Be clear about the plan gate before you write code. API keys exist on every plan, but on every plan they work. What the plan sets is how many writes you get an hour — **120 on Free**, **600 on Starter**, unlimited on Pro and Business — and going over answers `429` with a `Retry-After` rather than failing permanently. Reads are never counted. Plans cover an account, not a site, so one plan serves the API for every workspace you run. The send counts against the account's monthly email quota (60,000 on Pro) and an hourly sending ceiling, which is shown in the app under Billing. Details are on [Billing and plans](https://sendbeam.io/docs/admin/billing). In return there is nothing to host. The from-address is your workspace's default sender: the shared `ws-@mail.sendbeam.io` address until you verify a domain, and `you@yourdomain.com` after. Merge tags such as `{{first_name}}` are filled in from the contact. ## Prerequisites - A SendBeam workspace on any plan. API sending is metered per hour, not restricted by plan. - An API key from **Settings → API keys** with the `contacts:write` and `transactional:send` permissions. Only workspace admins can create keys, the full key is shown once, and it is stored hashed. Keep it in a server-side environment variable. - Node 18 or newer, or any runtime with `fetch` (Cloudflare Workers, Vercel and Netlify functions, Deno, Bun). - Recommended: a [verified sending domain](https://sendbeam.io/docs/guides/sending-domain-cloudflare-dns), so the email comes from you rather than a shared address. ## Steps 1. **Create the key and store it.** In **Settings → API keys**, create a key with `contacts:write` and `transactional:send`. Put it in your server environment. It must never reach a browser bundle; anyone holding it can send email as your workspace up to your quota. `# .env — server side only. Never ship this to the browser. SENDBEAM_API_KEY=sb_live_XXXXXXXX_YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY` 2. **Add a small client.** Sending targets a *contact*, not a bare address, so the helper first creates the contact (`POST /api/v1/contacts`) and, if it already exists, looks it up. A 409 also covers suppressed addresses: one that unsubscribed needs explicit new consent (`resubscribe: true`), and one that bounced or complained cannot be re-added at all. The helper treats all of those as "cannot mail" and lets your app decide what to do, which for a login link means showing it on screen instead. `// sendbeam.ts — a 40-line client for the two calls a side project needs. // Node 18+ (global fetch). Works unchanged in Cloudflare Workers, Vercel and Netlify functions. const BASE = 'https://sendbeam.io'; const KEY = process.env.SENDBEAM_API_KEY; if (!KEY) console.warn('SENDBEAM_API_KEY is not set; sends will fail with 401'); type Contact = { id: string; email: string; status: string }; async function api(method: string, path: string, body?: unknown): Promise { const res = await fetch(BASE + path, { method, headers: { 'x-api-key': KEY ?? '', 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); const data = (await res.json().catch(() => ({}))) as T; return { status: res.status, data }; } /** Find the contact for an address, creating it if needed. Returns null if it cannot be mailed. */ export async function ensureContact(email: string, firstName?: string): Promise { const created = await api('POST', '/api/v1/contacts', { email, first_name: firstName, source: 'app', }); if (created.status === 201 && created.data.contact) return created.data.contact; if (created.status === 409) { // Already exists (or suppressed). Look it up; q is a substring match, so compare exactly. const list = await api('GET', '/api/v1/contacts?q=' + encodeURIComponent(email) + '&limit=50'); const match = list.data.contacts?.find((c) => c.email.toLowerCase() === email.toLowerCase()); return match && match.status === 'subscribed' ? match : null; } throw new Error('SendBeam contact error ' + created.status + ': ' + (created.data.error ?? 'unknown')); } /** Send one email. Resolves to the message id (or null), or throws with SendBeam's error text. */ export async function sendEmail(contactId: string, subject: string, html: string, text?: string): Promise { const res = await api('POST', '/api/v1/send', { contact_id: contactId, subject, html_content: html, text_content: text, }); if (res.status === 200 && res.data.ok) return res.data.message_id ?? null; throw new Error('SendBeam send error ' + res.status + ': ' + (res.data.error ?? 'unknown')); }` 3. **Send from your application code.** Subject and HTML are yours; a plain-text alternative is optional but worth providing. The merge tags are resolved by SendBeam from the contact record, so a first name captured at signup appears without you interpolating it. `// welcome.ts — call this after a user signs up to your app import { ensureContact, sendEmail } from './sendbeam'; export async function sendWelcome(email: string, firstName: string, loginUrl: string) { const contact = await ensureContact(email, firstName); if (!contact) { // Unsubscribed, bounced or complained: SendBeam will not mail this address. Show the link in-app instead. return { sent: false, reason: 'address cannot be mailed' }; } const html = ` Hi {{first_name}}, Your account is ready. Sign in here: [${loginUrl}](${loginUrl}) If you did not create an account, you can ignore this email. `; const text = `Hi {{first_name}},\n\nYour account is ready. Sign in here: ${loginUrl}\n\nIf you did not create an account, you can ignore this email.`; const messageId = await sendEmail(contact.id, 'Welcome — your account is ready', html, text); return { sent: true, messageId }; }` 4. **Decide what happens when a send fails.** A 429 means the hourly ceiling; the response carries a `Retry-After` header with the number of seconds to wait, so queue and retry after it. A 403 for quota or a pause will not clear by retrying; log it and alert yourself. A 503 means delivery was refused for this message; the `error` text says why. None of these should block the user's action in your app: the email is a courtesy, the account was still created. > **Time-critical mail.** A magic link or a one-time code is fine at side-project > volume, but the hourly ceiling is per account, and a newsletter campaign running in the same > hour shares it. If login depends on the email arriving, keep a fallback path or keep marketing > sends on a different hour. > ## Test it Use curl with your own address first. The first call returns the contact with its `id`; the second sends to it. ``` curl -s -X POST https://sendbeam.io/api/v1/contacts \ -H "x-api-key: $SENDBEAM_API_KEY" \ -H "Content-Type: application/json" \ -d '{"email":"you@example.com","first_name":"Test","source":"app"}' # 201 → {"contact":{"id":"0f8c6d2e-…","email":"you@example.com","status":"subscribed", …}} ``` ``` curl -s -X POST https://sendbeam.io/api/v1/send \ -H "x-api-key: $SENDBEAM_API_KEY" \ -H "Content-Type: application/json" \ -d '{"contact_id":"0f8c6d2e-…","subject":"Test from curl","html_content":"

Hi {{first_name}}, it works.

","text_content":"Hi, it works."}' # 200 → {"ok":true,"message_id":"…"} ``` The email should arrive within seconds. Open **Activity** in SendBeam and filter by *API*: the send is listed with its delivery, open and click events, which is where you look when a user says they did not get something. Run the curl a second time to see the 409 on contact creation and confirm your helper's lookup path handles it. ## Limits, unsubscribes and errors Two behaviours surprise people who come from raw SMTP. First, only *subscribed* contacts can be mailed: a 422 on an unsubscribed contact is SendBeam refusing to send to someone who asked it not to, and it applies to transactional mail as much as campaigns. Second, every email SendBeam sends carries the RFC 8058 `List-Unsubscribe` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click` headers with a signed URL, and a signed unsubscribe link is appended to the HTML if your template has none. That is what Gmail and Yahoo require of bulk senders and it protects your domain's reputation, but it also means a recipient can unsubscribe from a receipt. Keep account-critical email short and infrequent, and do not rely on email as the only channel for anything the user cannot live without. The responses you will meet, in the order you are likely to meet them: ``` 401 {"error":"Unauthorized"} key missing, revoked or mistyped 429 {"error":"This workspace has used its 120 API writes for the hour…"} Retry-After: 3600 403 {"error":"Forbidden: transactional:send permission required"} key lacks the permission 403 {"error":"Monthly email limit reached (60,000 on the pro plan)."} 404 {"error":"Contact not found"} wrong contact_id, or another workspace's 422 {"error":"Cannot send to contact with status: unsubscribed"} 429 {"error":"Hourly send limit reached. Try again later."} Retry-After: 503 {"error":"…"} delivery failed; the reason is in error ``` ## Why not SMTP from the server? Running your own outbound mail is the cheapest option on paper and the most expensive in evenings. Many budget VPS providers block ports 25 and 587 outright, so the first step is often a support ticket. After that you own the DKIM key, the SPF record, a DMARC policy, the return path, bounce processing, complaint feedback loops, the one-click unsubscribe requirement, and an IP address whose reputation starts at zero. A managed relay behind SMTP fixes the ports and the IP but leaves the rest with you. SendBeam's trade-offs are real too. It is HTTP only, with no SMTP relay, so a legacy application that only speaks SMTP cannot use it without a small shim. API sending works on every plan, metered per hour. There are quotas. And the contact model means a first call before the first send. For a side project with a few hundred emails a month and no appetite for mail operations, that is usually the right side of the bargain; for a product sending millions, a dedicated transactional provider is the better fit. ## Next steps - Verify your domain so the email is from you: [Verify a sending domain with Cloudflare DNS](https://sendbeam.io/docs/guides/sending-domain-cloudflare-dns). - For a welcome sequence rather than a single email, let a signup form add the contact and use an [automation](https://sendbeam.io/docs/automations) instead of the send endpoint. No API key is needed for that. - Trigger sends from no-code tools: the [Zapier](https://sendbeam.io/integrations/zapier) and [Make](https://sendbeam.io/integrations/make) integration pages show the same two calls from a webhook step. - Everything the API accepts and returns, with schemas, is in the [API reference](https://sendbeam.io/docs/api); the OpenAPI document is at [/openapi.json](https://sendbeam.io/openapi.json). ## Starter kits The starters are front-end projects that use the public form endpoint rather than the API, so they are the quickest way to get the signup half working; the Next.js one already has a server-side place to put the send call from this guide: - Next.js (App Router) [Next.js on Vercel No API key needed · Vercel Client components that post straight to the form endpoint, so no route handler and no secret sit in the middle. sendbeam-starters/nextjs-vercel](https://github.com/sendbeam-io/sendbeam-starters/tree/main/nextjs-vercel) - Astro [Astro on Cloudflare Pages No API key needed · Cloudflare Pages Signup and contact components, form IDs in PUBLIC_ environment variables, and a _headers file that lets Turnstile load. sendbeam-starters/astro-cloudflare-pages](https://github.com/sendbeam-io/sendbeam-starters/tree/main/astro-cloudflare-pages) - HTML and vanilla JS [Plain HTML No API key needed · Any static host One page, one script, no build step: paste the files onto any host and both forms work. sendbeam-starters/plain-html](https://github.com/sendbeam-io/sendbeam-starters/tree/main/plain-html) - Eleventy [Eleventy on Netlify No API key needed · Netlify Nunjucks includes and a global data file, with a netlify.toml that sets the Content-Security-Policy for you. sendbeam-starters/eleventy-netlify](https://github.com/sendbeam-io/sendbeam-starters/tree/main/eleventy-netlify) - Hugo [Hugo No API key needed · Any static host Two partials and one script in static/, with the form IDs read from site params in hugo.toml. sendbeam-starters/hugo](https://github.com/sendbeam-io/sendbeam-starters/tree/main/hugo) Every starter covers the same five things — newsletter signup, contact form, double opt-in, unsubscribe and spam protection — reads its form IDs from environment variables or site config, and is MIT licensed. Browse them all in [sendbeam-starters](https://github.com/sendbeam-io/sendbeam-starters). --- # Guides: Double opt-in on a static site Collect confirmed newsletter subscribers from a page with no backend: what happens after the POST, what the visitor sees, and how to word the page so people actually confirm. Double opt-in sounds like something that needs a server: a pending state, a signed token, an email with a link, a page that flips the state when the link is clicked. On a static site you have none of that, and the temptation is to skip it. You do not have to. When a static page posts a signup to SendBeam, the confirmation flow runs entirely on SendBeam's side; your page's only job is to tell the visitor to look in their inbox. This guide shows the whole path from POST to confirmed member, how to set the list up, and how to test each step, so that you know exactly what your subscribers experience. ## What double opt-in changes Double opt-in is a property of the *list*, not of the form. When a form adds a contact to a list that requires it, the contact is created and joined to the list as **unconfirmed**, and SendBeam sends a confirmation email from your workspace's sender with the subject "Please confirm your subscription to *list name*" and a single button. The button's link points at `https://sendbeam.io/api/confirm-optin` with a signed token bound to that contact and list. When it is clicked the membership becomes confirmed, any *list joined* automation runs, and from then on campaigns to the list reach the person. Until then they do not. On the **Free** plan every list behaves this way whether or not the toggle is on; on paid plans you choose per list. The detailed behaviour is in [Lists → Double opt-in](https://sendbeam.io/docs/lists/double-optin). ## Prerequisites - A SendBeam workspace and a list. On paid plans, turn on **Double opt-in** when you create or edit the list. - A signup form pointed at that list. The [Astro guide](https://sendbeam.io/docs/guides/newsletter-signup-astro) and the [plain HTML guide](https://sendbeam.io/docs/guides/form-to-email-without-backend) both produce one; the steps below use plain HTML for brevity. - Ideally, a [verified sending domain](https://sendbeam.io/docs/getting-started/sending-domain). The confirmation email comes from your default sender, and people are more likely to click a button from `news@yourbrand.com` than from a shared address. ## Steps 1. **Make the list double opt-in.** Under **Lists**, create the list (or open it) and switch on *Double opt-in*. The Lists table shows which lists require confirmation. Nothing about the form changes when you do this: the form simply inherits it. 2. **Write the thank-you message for the inbox step.** On the form in SendBeam, set the **Thank You Message** to something like "Almost there. We have emailed you a confirmation link; open it to finish subscribing." This string is what the endpoint returns as `message`, so the page shows the right instruction without knowing anything about the list. Optionally set a **Redirect URL** to a dedicated thanks page, which gives you room for the spam-folder advice. 3. **Put the form on the page.** There is nothing double-opt-in-specific in the markup; that is the point. ` Email Leave this empty Subscribe ` 4. **Show the message SendBeam returns.** The script posts, then replaces the form with `message`. If you set a redirect URL, it follows it after a moment. `// signup.js (function () { var form = document.getElementById('signup'); var status = document.getElementById('signup-status'); form.addEventListener('submit', function (event) { event.preventDefault(); var email = form.email.value; fetch(form.dataset.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email, YOUR_FORM_CHECK_FIELD: form.YOUR_FORM_CHECK_FIELD.value }) }) .then(function (res) { return res.json().then(function (json) { return { ok: res.ok, json: json }; }); }) .then(function (res) { if (res.ok && res.json.success) { form.email.hidden = true; form.querySelector('label').hidden = true; form.querySelector('button').hidden = true; // The message comes from the form's settings in SendBeam. For a double opt-in list, // write it there as "Check your inbox for a confirmation email" — the page does not // need to know whether the list is single or double opt-in. status.textContent = res.json.message; if (res.json.redirect_url) setTimeout(function () { location.assign(res.json.redirect_url); }, 1500); } else { status.textContent = res.json.error || 'Something went wrong. Please try again.'; } }) .catch(function () { status.textContent = 'Could not reach the server. Please try again.'; }); }); })();` 5. **Optionally, add a thanks page.** Use it as the form's redirect URL. It is the right place for the two things that recover most lost confirmations: check spam, and the ten-minute resend rule. ` One more step We have sent a confirmation email. Open it and press the button to finish subscribing. Nothing arrived? Check your spam folder, and make sure you typed the address correctly. You can submit the form again; we send at most one confirmation every ten minutes.` 6. **Add a welcome automation, if you want one.** Create an [automation](https://sendbeam.io/docs/automations/triggers) on the *list joined* trigger for this list. Under double opt-in it fires on confirmation, not on submission, so the welcome email only ever goes to people who proved they own the address. > **Through the API it is the same flow.** Adding a contact to a double opt-in list > with `POST /api/v1/lists/{id}/contacts` creates a pending membership and > sends the confirmation; the response says so. > > ``` > # Adding to a double opt-in list through the API behaves the same way: > curl -s -X POST https://sendbeam.io/api/v1/lists/LIST_ID/contacts \ > -H "x-api-key: $SENDBEAM_API_KEY" \ > -H "Content-Type: application/json" \ > -d '{"contact_id":"0f8c6d2e-…"}' > # 201 → {"list_contact":{…,"confirmed":false},"double_optin_sent":true,"membership":"pending_confirmation","already_member":false} > ``` > ## Test it Walk the path once with an address you control, and watch SendBeam at each step. 1. Submit the form on the deployed page. The thank-you message (or redirect) appears. 2. In **Contacts**, the address exists with source *form*. In the list's members, it shows as *unconfirmed*. 3. The confirmation email arrives from your default sender. Note how it looks in a real inbox: this is the moment most people decide whether to click. 4. Click the button. The confirmation page loads, and the membership in SendBeam flips to confirmed. If you created a welcome automation, its first step runs now. 5. Submit the form again with the same address. No duplicate contact is created, and a second confirmation is not sent within ten minutes of the first. After ten minutes, a re-submission sends a fresh one, which is the "did not arrive" path. 6. Click the link a second time. It is single-use: an already-used or expired link shows an error page rather than re-confirming, which is what you want from a link that gets forwarded. Each confirmation email counts towards the monthly email allowance, so an address that never confirms costs you at most one email per ten minutes of someone's persistence, and usually exactly one. ## Dealing with spam and list-bombing Double opt-in is itself the main protection for a signup form. A bot that submits ten thousand strangers' addresses creates ten thousand unconfirmed memberships and nothing else: none of those people will ever receive a campaign, and the workspace's reputation is untouched because no marketing email was sent to anyone who did not ask. List-bombing, where one victim's address is fed to thousands of forms across the web, is blunted by the confirmation throttle (one confirmation email per address, however many submissions) and by the endpoint's ordinary layers: per-visitor rate limits and a daily cap per form, the automated-submission checks, *Allowed sites*, and Cloudflare Turnstile when a secret is set. Addresses that previously bounced or complained are accepted with the normal thank-you and then ignored entirely: no contact, no membership, no email. For a public signup form, allowed sites plus Turnstile is the sensible setting; both are switched on in the form's **Protection** section and need no change to the page beyond the Turnstile widget. ## Why not single opt-in, or a custom confirmation flow? **Single opt-in** converts better on the day, and on paid plans SendBeam lets you choose it per list. Its cost arrives later: typos and fake addresses bounce, people who forgot they subscribed hit "report spam", and each of those chips at the reputation of the domain you send everything from. For a list grown from a public page, confirmation is worth the drop-off. For a list of existing customers imported from your own records, single opt-in can be the right call, and you can run both kinds of list in one workspace. **A hand-rolled confirmation flow** (pending table, token, email, endpoint) is a weekend's work and a permanent maintenance item, and it only exists so that a static site can stay static. Since the flow has to live on a server somewhere, letting SendBeam be that server is the smaller system. What you give up is control over the confirmation email's exact design: it is a plain, single-button message sent from your sender, and the confirmation page is hosted on sendbeam.io. Both mention your list by name, not SendBeam's branding, except for the small "Sent with SendBeam" footer on the Free plan. Whichever you choose, the mail that follows carries the RFC 8058 one-click unsubscribe headers and a signed unsubscribe link, so a subscriber can always leave as easily as they joined. ## Next steps - Build the form for your framework: [Astro](https://sendbeam.io/docs/guides/newsletter-signup-astro), or the [integrations pages](https://sendbeam.io/integrations) for Next.js, Hugo, Eleventy and WordPress, and the [starter kits](https://github.com/sendbeam-io/sendbeam-starters). - Send the confirmation from your own domain: [Verify a sending domain with Cloudflare DNS](https://sendbeam.io/docs/guides/sending-domain-cloudflare-dns). - Export confirmed members with their subscription data from [Contacts → Export](https://sendbeam.io/docs/contacts/exporting) when you need a consent record. ## Starter kits Every starter has the signup side of this flow wired end to end, including the pending state and the wording that follows the POST, so you can watch a confirmation happen before you write your own: - HTML and vanilla JS [Plain HTML No API key needed · Any static host One page, one script, no build step: paste the files onto any host and both forms work. sendbeam-starters/plain-html](https://github.com/sendbeam-io/sendbeam-starters/tree/main/plain-html) - Astro [Astro on Cloudflare Pages No API key needed · Cloudflare Pages Signup and contact components, form IDs in PUBLIC_ environment variables, and a _headers file that lets Turnstile load. sendbeam-starters/astro-cloudflare-pages](https://github.com/sendbeam-io/sendbeam-starters/tree/main/astro-cloudflare-pages) - Hugo [Hugo No API key needed · Any static host Two partials and one script in static/, with the form IDs read from site params in hugo.toml. sendbeam-starters/hugo](https://github.com/sendbeam-io/sendbeam-starters/tree/main/hugo) - Eleventy [Eleventy on Netlify No API key needed · Netlify Nunjucks includes and a global data file, with a netlify.toml that sets the Content-Security-Policy for you. sendbeam-starters/eleventy-netlify](https://github.com/sendbeam-io/sendbeam-starters/tree/main/eleventy-netlify) - Next.js (App Router) [Next.js on Vercel No API key needed · Vercel Client components that post straight to the form endpoint, so no route handler and no secret sit in the middle. sendbeam-starters/nextjs-vercel](https://github.com/sendbeam-io/sendbeam-starters/tree/main/nextjs-vercel) Every starter covers the same five things — newsletter signup, contact form, double opt-in, unsubscribe and spam protection — reads its form IDs from environment variables or site config, and is MIT licensed. Browse them all in [sendbeam-starters](https://github.com/sendbeam-io/sendbeam-starters). --- # Guides: Verify a sending domain with Cloudflare DNS Send as your own domain: add the domain in SendBeam, put its CNAME records in your Cloudflare zone, and confirm the mail is signed and aligned. Mail that claims to come from your domain has to prove it, and the proof lives in your DNS: a signature the receiving server can check against a key published in your zone, and a bounce address on your domain so failures come back to the right place. Since 2024, Gmail, Yahoo and Microsoft have enforced this for bulk senders rather than merely preferring it, so a campaign from an unauthenticated domain is refused at the door rather than filtered into spam. This guide takes a domain whose DNS is on Cloudflare from nothing to verified, and shows how to prove afterwards that it worked. ## Why the records are needed SendBeam delivers your mail through its own managed infrastructure. That infrastructure can sign as `yourbrand.com` only if `yourbrand.com` publishes something that says so. The records do two jobs. The **signing records** publish the public half of the key your mail is signed with, so a receiving server can verify the signature and see `dkim=pass` for your domain. The **return-path record** gives your mail a bounce address on a subdomain of your own domain, which aligns the envelope sender with the From header and lets SendBeam collect bounces and complaints and suppress dead addresses for you. Everything you add is a **CNAME** pointing at a name under `dom.sendbeam.io`. SendBeam hosts the real values, so your zone never names a third party and those values can be rotated without you touching DNS again. ## Prerequisites - A SendBeam workspace, and the **admin** role in it — sender details and sending domains are admin-only. - A domain you control, either an apex (`yourbrand.com`) or a subdomain (`news.yourbrand.com`). Your plan sets how many sending domains your account may have. - Cloudflare as the DNS host: the zone exists in your account and the domain's nameservers point at it. If DNS lives elsewhere, make the changes there instead. ## Steps 1. **Add the domain in SendBeam.** Open **Settings > Email & Domains** (`/settings/domains`), and under *Add your domain* type the domain and press **Add Domain**. SendBeam registers it with its managed delivery and lists the records in a table with a live *found / not found* column, under the status **Add DNS records**. 2. **Read what you have been given.** Every row is a CNAME: the name is a label on your domain, the value the matching name under `dom.sendbeam.io`, derived from a stable hash of your domain so it never changes. There is nothing to add at the apex, no TXT record to merge and no MX record to touch. 3. **If you see "Connect with Cloudflare", press it.** This is Domain Connect, the open standard Cloudflare, GoDaddy, IONOS and others implement: you land on Cloudflare — signed in already, or after its own login — looking at the CNAME records with SendBeam's name on them. Approve, and Cloudflare adds them and sends you back; SendBeam re-checks straight away. SendBeam never sees a password or an API token, and the request is signed so Cloudflare knows it came from SendBeam. The button appears only once a provider has enabled SendBeam's template on its side, so if it is not there, add the records yourself — the step below is the same two records and takes a minute. 4. **Add them yourself.** In the Cloudflare dashboard open the zone, go to **DNS > Records > Add record**, choose type **CNAME**, copy the name and the value from SendBeam's table, set **Proxy status** to *DNS only*, leave TTL on Auto, and save. Repeat for each row. TypeName (on your domain)Value (target) CNAME`._domainkey.yourbrand.com``._domainkey..dom.sendbeam.io` CNAME`.yourbrand.com``..dom.sendbeam.io` Those are shapes, not values — copy the exact names from the page. 5. **Wait for verification.** A scheduler re-checks unverified domains every minute (at most once every five minutes per domain), so you need not sit on the page; the **Verify** button forces a check now. **Add DNS records** means at least one CNAME is still missing. **Verifying** means they all resolve and the delivery side is finishing its own check, which takes a few minutes and needs nothing from you. **Verified** means you can send, and **Failed** that the delivery side rejected the domain. Re-checks stop after 30 days unverified. 6. **Choose the address you send from.** On a verified domain the card offers a local part — `hello`, `news` — and a **Set as default** button. The from-name lives in *Sender details* at the top of the same page, and replies go to the from-address. > **The proxy must be off.** An orange-cloud CNAME resolves to Cloudflare's own > addresses, not the target you typed, so the record is invisible to mail verification even though > it looks right in the dashboard. Hiding the target is the point of the proxy for web records; > for mail records it is simply wrong. Grey cloud, always. > What the connect path does *not* do is worth stating plainly. It creates only the CNAMEs SendBeam listed — no DMARC, no SPF, no MX — and Cloudflare shows you exactly those before anything is written. It never edits or deletes a record you already have. If you decline at Cloudflare, nothing changes and the card says so; the manual route below is always there. ## Add a DMARC record DMARC is not part of verification and SendBeam will not add it for you, but it turns passing signatures into a policy: it tells receivers what to do with mail that claims to be you and fails, and it is where reports about mail sent in your name come from. If `_dmarc.yourbrand.com` does not exist yet, add it in Cloudflare as a TXT record: ``` Type: TXT Name: _dmarc (that is _dmarc.yourbrand.com) Value: v=DMARC1; p=none; rua=mailto:dmarc@yourbrand.com; fo=1 ``` Start at `p=none`. It changes nothing about how your mail is treated, which is the point: for a week or two the reports at the `rua` address tell you what else sends as your domain — a helpdesk, an invoicing tool, a form on an old site — before a policy can bounce any of it. Then move to `p=quarantine` and on to `p=reject`, as [Getting started > Sending domain](https://sendbeam.io/docs/getting-started/sending-domain) recommends. A strict policy on a domain you have never measured is how legitimate mail disappears quietly. ## Verify it worked 1. The domain shows **Verified** in **Settings > Email & Domains**, with the date, and your from-address is set on it. 2. Press **Send test email** in *Sender details*. It sends to your own login address from the address you configured. 3. Open the message's original or full headers. You are looking for a pass on your own domain, not on a shared one: `Authentication-Results: mx.example.com; dkim=pass header.d=yourbrand.com; spf=pass smtp.mailfrom=.yourbrand.com; dmarc=pass header.from=yourbrand.com` 4. For a second opinion, run the domain through [the sender check tool](https://sendbeam.io/tools/sender-check), which grades your public records as a receiver sees them. 5. If a record looks wrong, check it at the source, not in the dashboard: `# Is the record live, and does it point where SendBeam expects? dig +short CNAME .yourbrand.com # → ..dom.sendbeam.io. # A proxied record answers with addresses instead of the target — that is the bug: dig +short .yourbrand.com # → 104.21.x.x (grey-cloud the record)` ## Troubleshooting **The record ended up at the wrong name.** Cloudflare's Name field is relative to the zone, and pasting a full name into a panel that appends the zone gives you `send.yourbrand.com.yourbrand.com`. After saving, read the name Cloudflare shows in the record list — not what you typed — and confirm it matches SendBeam's table. It bites hardest when the sending domain is itself a subdomain: for `news.yourbrand.com` in the `yourbrand.com` zone, the record name carries the `news` label too. **You already have an SPF record.** SendBeam does not ask you to change it: the return path sits on a subdomain of your domain, so alignment is handled there and nothing is needed at the apex. Leave any `v=spf1 …` record you have for other senders alone — a domain may publish only one SPF record, and a second makes both invalid. If you ever do need another sender's include, merge it into the existing record; never add a second. **The record is proxied.** See the warning above. Set Proxy status to *DNS only*, and note that switching an existing record to grey cloud counts as a change that has to propagate. **Nothing has propagated yet.** Cloudflare publishes edits within seconds, but the resolver SendBeam queries can hold an earlier negative answer for the length of the old TTL. If the table still says *not found* after ten minutes and `dig` on your own machine sees the record, press **Verify** once more before assuming a mistake. **"That domain is already registered."** A sending domain belongs to one workspace across all of SendBeam. If it is set up in another workspace of yours, remove it there first or use a different subdomain here. Domains also match exactly: verifying `yourbrand.com` does not let a workspace send as `news.yourbrand.com`, and that check runs when you save the from-address and again on every send. **The status went to Failed.** The delivery side rejected the domain rather than a DNS lookup failing. Check the names once more, then remove the domain and add it again for a fresh set of records; if it fails again, [get in touch](https://sendbeam.io/contact) — that one is not fixable from your zone. ## Next steps - [Getting started > Sending domain](https://sendbeam.io/docs/getting-started/sending-domain) for the shared platform addresses, plan pools and from-address rules. - [Contacts > CSV import](https://sendbeam.io/docs/contacts/importing) to bring your audience in now that mail leaves as your own domain. - [Moving in from another platform](https://sendbeam.io/move-in) if the domain is already verified somewhere else and you are switching over. ## Starter kits Once the domain is verified, these starters send from it with no change beyond the form IDs. Each is a small site you can deploy and point at your workspace: - Astro [Astro on Cloudflare Pages No API key needed · Cloudflare Pages Signup and contact components, form IDs in PUBLIC_ environment variables, and a _headers file that lets Turnstile load. sendbeam-starters/astro-cloudflare-pages](https://github.com/sendbeam-io/sendbeam-starters/tree/main/astro-cloudflare-pages) - HTML and vanilla JS [Plain HTML No API key needed · Any static host One page, one script, no build step: paste the files onto any host and both forms work. sendbeam-starters/plain-html](https://github.com/sendbeam-io/sendbeam-starters/tree/main/plain-html) - Hugo [Hugo No API key needed · Any static host Two partials and one script in static/, with the form IDs read from site params in hugo.toml. sendbeam-starters/hugo](https://github.com/sendbeam-io/sendbeam-starters/tree/main/hugo) - Eleventy [Eleventy on Netlify No API key needed · Netlify Nunjucks includes and a global data file, with a netlify.toml that sets the Content-Security-Policy for you. sendbeam-starters/eleventy-netlify](https://github.com/sendbeam-io/sendbeam-starters/tree/main/eleventy-netlify) - Next.js (App Router) [Next.js on Vercel No API key needed · Vercel Client components that post straight to the form endpoint, so no route handler and no secret sit in the middle. sendbeam-starters/nextjs-vercel](https://github.com/sendbeam-io/sendbeam-starters/tree/main/nextjs-vercel) Every starter covers the same five things — newsletter signup, contact form, double opt-in, unsubscribe and spam protection — reads its form IDs from environment variables or site config, and is MIT licensed. Browse them all in [sendbeam-starters](https://github.com/sendbeam-io/sendbeam-starters).