Email Campaigns
SendSeven separates email design from content and from the campaign that sends it. This guide walks you through the full workflow: optionally choosing a template, creating content, and launching a campaign.
Campaigns only reach opted-in, active subscribers. See the Email section for connection setup, opt-in / double opt-in, and subscriptions & unsubscribe.
Required Scopes
| Scope | Purpose |
|---|---|
campaigns:read | View campaigns |
campaigns:create | Create campaigns |
campaigns:update | Edit campaign content/settings and schedule |
campaigns:send | Send, pause, resume, and cancel campaigns |
email:read | List, read, preview and render email templates and content |
email:create | Create and duplicate email templates; create, update and duplicate email content |
email:update | Edit email templates |
email:delete | Delete email templates |
analytics:read | Campaign analytics |
Architecture
EmailTemplate (optional reusable MJML design)
| its MJML is copied into the content
EmailContent (the actual email: subject + body)
|
EmailCampaign (campaign instance with recipients and schedule)
A campaign always sends an email content. A template is an optional starting point for html content: you copy its MJML into the content, and later edits to the template do not change content that was created from it.
Pricing
Every plan is a fixed monthly base fee that includes a pool of usage shared across messages, campaign messages, AI credits, and campaign emails. Emails beyond that pool are billed per email:
| Plan | Base fee | Included pool / month | Per email beyond the pool |
|---|---|---|---|
| Basic | EUR 49 | 2,500 | EUR 0.010 |
| Professional | EUR 79 | 2,500 | EUR 0.010 |
| Scale | EUR 199 | 10,000 | EUR 0.0075 |
| Enterprise | EUR 499 | 40,000 | EUR 0.005 |
| API Only | EUR 9 per connected channel | 1,000 per connected channel | EUR 0.0025 |
Managed (SendSeven handles delivery) and BYOK (you connect your own SendGrid or Mailgun account) cost the same per email — BYOK is about owning your sending reputation and provider relationship, not a lower rate.
The included pool is applied to your most expensive usage first, so emails — the cheapest unit — are the last to draw from it. See the pricing page for current rates.
Step 1: Email Templates (optional)
An email template is a reusable design written in MJML (a responsive email markup language). Templates are only a starting point for html content. You don't need one for simple or plain_text content, or for html content you design yourself; skip to Step 2 in that case.
A template has no fixed slots. Its MJML contains placeholders in double curly braces, for example {{hero_title}} or {{cta_url}}. Names without a dot are the template's own variables, which each content fills through variable_values. Names with a dot (contact.first_name, company.name, system.year, ...) are filled automatically at send time. GET /api/v1/email-contents/variables lists them.
List Templates
curl -X GET "https://api.sendseven.com/api/v1/email-templates" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00"
The response is a plain array (no items wrapper and no pagination object). It contains your own templates and the read-only system templates (is_system_template: true), but not the MJML source:
[
{
"id": "8c1f2a4e-5b6d-4e7f-9a0b-1c2d3e4f5a6b",
"tenant_id": "your-workspace-id",
"name": "Standard Newsletter",
"description": "Single-column newsletter with header image, body, and footer",
"category": "promotional",
"thumbnail_url": null,
"is_system_template": false,
"is_active": true,
"is_hidden": false,
"version": 1,
"created_at": "2026-01-05T10:00:00Z",
"updated_at": "2026-01-05T10:00:00Z"
}
]
| Query parameter | Description |
|---|---|
category | Only templates of this category |
is_system_template | true for system templates only, false for your own only |
is_active | Filter by active status |
include_hidden | Include hidden templates (default false) |
limit / offset | Page size (1 to 100, default 50) and offset |
GET /api/v1/email-templates/{template_id} returns one template with its mjml_source and the compiled html_compiled.
Create a Template
curl -X POST "https://api.sendseven.com/api/v1/email-templates" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json" \
-d '{
"name": "Simple Announcement",
"description": "Clean single-column announcement layout",
"category": "promotional",
"mjml_source": "<mjml><mj-body><mj-section><mj-column><mj-image src=\"{{header_image}}\" /><mj-text>{{headline}}</mj-text><mj-text>{{body}}</mj-text><mj-button href=\"{{cta_url}}\">{{cta_text}}</mj-button></mj-column></mj-section></mj-body></mjml>"
}'
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Template name (max 255 characters) |
mjml_source | string | Yes | MJML with {{variable}} placeholders |
description | string | No | Description |
category | string | No | Free-text category, for example promotional or transactional |
color_palette | object | No | Brand colors, for example {"primary": "#0066CC"} |
font_family | string | No | Font stack, for example Arial, sans-serif |
thumbnail_url | string | No | Preview image URL |
The MJML is validated and compiled when you save. The response (201) is the full template, including mjml_source and html_compiled. Invalid MJML returns 400 with the compiler message in detail.
Other template endpoints:
| Endpoint | Scope | Description |
|---|---|---|
PATCH /api/v1/email-templates/{template_id} | email:update | Change any of the create fields |
DELETE /api/v1/email-templates/{template_id} | email:delete | Delete a template (204). Returns 409 while email content still references it |
POST /api/v1/email-templates/{template_id}/duplicate | email:create | Copy a template, including a system template, into your workspace. Body: {"new_name": "..."} |
POST /api/v1/email-templates/preview | email:read | Compile MJML without saving. Body: mjml_source, optional variable_values. Returns html |
System templates are read-only: changing or deleting one returns 403. Duplicate it to get an editable copy.
Use a template in content
Content does not inherit anything from a template at send time. To build html content from a template, read the template, copy its mjml_source into the content with editor_type: "mjml", and fill its own variables in variable_values:
curl -X POST "https://api.sendseven.com/api/v1/email-contents" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json" \
-d '{
"name": "March Newsletter",
"template_id": "8c1f2a4e-5b6d-4e7f-9a0b-1c2d3e4f5a6b",
"editor_type": "mjml",
"subject_line": "March highlights from Acme",
"mjml_source": "<mjml>... copied from the template ...</mjml>",
"variable_values": {
"headline": "March Highlights",
"cta_text": "See what is new",
"cta_url": "https://acme.com/blog/march-2026"
}
}'
template_id is optional and only records which template the content came from. It must be a template of your own workspace (an unknown ID or a system template ID returns 404; duplicate a system template first). It is ignored for simple and plain_text content.
Step 2: Email Content
Email content is the actual email: subject line plus body. There are three content types:
content_type | Body field | Use it for |
|---|---|---|
html (default) | compiled_html (designer export, editor_type: "stripo") or mjml_source (editor_type: "mjml") | Newsletters and designed marketing emails with images and layout |
simple | compiled_html holding a rich-text fragment | Personal-looking emails: formatted text and links only, best deliverability |
plain_text | text_body | Text-only emails |
Create Content
curl -X POST "https://api.sendseven.com/api/v1/email-contents" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json" \
-d '{
"name": "Quick follow-up",
"content_type": "simple",
"subject_line": "Quick question, {{contact.first_name}}",
"compiled_html": "<p>Hi {{contact.first_name}},</p><p>Do you have <strong>10 minutes</strong> this week? <a href=\"https://acme.com/book\">Pick a slot</a>.</p><p>Best,<br>Anna</p>"
}'
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Internal name |
content_type | string | No | html (default), simple or plain_text. Cannot be changed later. |
subject_line | string | No | Subject line, supports variables |
preview_text | string | No | Inbox preview text (max 150 characters) |
from_name / from_email | string | No | Default sender for campaigns using this content |
editor_type | string | No | html only: stripo (default) or mjml |
compiled_html | string | html (stripo) and simple | Designer HTML export, or the simple fragment |
mjml_source | string | html (mjml) | MJML source |
text_body | string | plain_text | Text body |
variable_values | object | No | Values for the content's own template variables |
footer_mode | string | No | inherit (default), both, unsubscribe_only or none |
auto_attach_signature | boolean | No | simple and plain_text only. Default false. See Signature in inbox replies |
The response is the full content object (201).
Signature in inbox replies
Email content can also be loaded into a reply in the SendSeven inbox (web and mobile app). Set auto_attach_signature: true on simple or plain_text content to have the inbox composer append the sending agent's email signature when the content is inserted.
- Omitted on create:
false. Omitted onPATCH: unchanged. htmlcontent always returnsfalse; a value you send forhtmlis ignored.- The flag has no effect on campaign sends, and
POST /api/v1/email-contents/{content_id}/renderdoes not add the signature: the inbox composer adds it. GET /api/v1/email-contentsandGET /api/v1/email-contents/{content_id}both return the field, and duplicating content copies it.
Simple content
simple is the right choice when the email should look like a message typed by a person. The body is a fragment with these tags only: p, br, strong/b, em/i, a (with href: http, https, mailto or a variable), ul, ol, li. On every create and update the server:
- sanitises the fragment: other tags are removed (their text is kept), as are
style,classand event attributes, images andjavascript:links - creates the text version itself and returns it in
text_body, with links written astext (URL). Anytext_body,mjml_sourceoreditor_typeyou send forsimpleis ignored - stores
editor_type: "tiptap"
The email goes out as a minimal HTML document (no layout, no web fonts) together with its text version.
| Status | detail | Cause |
|---|---|---|
| 422 | {"code": "simple_body_empty", "message": ...} | compiled_html is missing, or nothing is left after sanitising |
| 422 | {"code": "simple_body_too_large", "message": ...} | compiled_html is larger than 200 KB, or contains more than 10,000 markup characters (<, >, & together) |
To change a simple body, PATCH /api/v1/email-contents/{content_id} with a new compiled_html. The same rules and error codes apply.
GET /api/v1/email-contents?content_type=simple lists only one type (html, plain_text or simple). POST /api/v1/email-contents/{content_id}/preview returns the complete email for simple in html_content and the text version in text_content. POST /api/v1/email-contents/{content_id}/render returns the personalised fragment without a document wrapper, ready to insert into a reply.
Always use email_content_id when creating campaigns to reference your email content.
Searching email content
GET /api/v1/email-contents accepts a search query parameter that matches on content name and subject. When search is provided, pagination is skipped and the endpoint returns up to 50 matches across all pages in a single response — this keeps the Send-Message node's email-content picker snappy without forcing the caller to paginate.
curl -X GET "https://api.sendseven.com/api/v1/email-contents?search=march" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00"
For normal browse-style listing (no search parameter), the endpoint paginates as usual using page / page_size.
Personalization variables
Subject lines and bodies accept {{namespace.field}} placeholders. GET /api/v1/email-contents/variables (scope email:read) returns the catalogue for your workspace, grouped in this order:
| Category | Variables |
|---|---|
contact | contact.name, contact.first_name, contact.last_name, contact.email, contact.phone, contact.birthday |
custom_fields | contact.custom.<key> for each active custom field (omitted when you have none, or when your API key lacks custom_fields:read) |
company | company.name, company.legal_name, company.address, company.phone, company.email, company.website, company.logo_url, company.brand_color_primary, company.brand_color_secondary, company.brand_color_accent |
agent | agent.name, agent.first_name, agent.last_name, agent.email, agent.phone, agent.job_title, agent.role |
campaign | campaign.unsubscribe_url, campaign.preferences_url, campaign.name |
system | system.year, system.date |
Each variable carries name, label, description, example and contexts (where it resolves: conversation, campaign, flow, scheduled). Any placeholder without a value at send time renders as an empty string. In HTML bodies, contact, company and agent values are HTML-escaped.
Who is the agent: for a conversation reply or new email, the user making the request; for a campaign or scheduled send, the team member who created it; for a flow Send Email node, the agent assigned to the conversation. If that person is no longer an active member of the workspace, or anywhere else, agent.* is empty.
To render a piece of content for a real recipient, call POST /api/v1/email-contents/{content_id}/render (scope email:read):
curl -X POST "https://api.sendseven.com/api/v1/email-contents/{content_id}/render" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json" \
-d '{"conversation_id": "conv_uuid", "variables": {"hero_title": "Your order shipped"}}'
Pass contact_id or conversation_id (the conversation's contact is used); both are optional. variables fills the content's own template variables. The response has the same shape as /preview (html_content, text_content, subject_line, variables_used, content_type) plus unresolved_variables, the placeholders that rendered empty. An unknown contact or a conversation you cannot access returns 404. Contact values are only filled in when the caller also has contacts:read, or has conversations:read and the contact is that conversation's own contact; otherwise contact fields stay empty.
Step 3: Create and Send the Campaign
Create an Email Campaign
curl -X POST "https://api.sendseven.com/api/v1/email-campaigns" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json" \
-d '{
"name": "March Newsletter",
"subject_line": "What is new in March",
"mailbox_id": "mailbox_newsletter",
"email_content_id": "content_mar_newsletter",
"list_ids": ["list_newsletter"],
"scheduled_at": "2026-03-01T08:00:00Z"
}'
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Campaign name |
subject_line | string | Yes | Email subject |
mailbox_id | string | Yes* | Mailbox to send from. Fills in from_email, from_name and the sending provider. The mailbox must be linked to a sending provider (SendSeven Managed, SendGrid or Mailgun), otherwise 400. *Or send email_provider_config_id together with from_email and from_name. |
email_content_id | string | Yes | Email content to send |
list_ids | array | Yes* | Contact list IDs. *Not needed when you send audience_conditions (then it is ignored). |
audience_conditions | object | No | Audience Builder filter on the subscribers of one or more lists (see below) |
scheduled_at | string | No | ISO 8601 datetime for scheduled delivery |
tracking_enabled | boolean | No | Track opens. Omitted: your workspace default, or false when the content is simple |
track_clicks | boolean | No | Track link clicks. Omitted: your workspace default, or false when the content is simple |
Campaigns send only through a sending provider. email_integration_id is not accepted: a non-null value on create, update or recipient-count returns 422.
Audience Builder
audience_conditions narrows the subscribers of one or more lists with extra conditions. It uses the same format and rules as for messaging campaigns: a top-level AND that contains at least one list membership (has_optin, "value": true, the list_id of a static or newsletter list), plus any conditions that narrow the audience down or exclude contacts. Only contacts with an active email subscription on the membership lists receive the email. When it is set, the campaign's lists come from the membership lists and list_ids is ignored. On PATCH, omit it to leave it unchanged or send null to remove it.
POST /api/v1/email-campaigns/recipient-count accepts audience_conditions too, for a live count before you save. A request with audience_conditions needs contacts:read in addition to campaigns:read (403 otherwise). Invalid trees return 422 with audience_list_membership_required, audience_membership_list_invalid or audience_conditions_invalid.
Send Immediately
curl -X POST "https://api.sendseven.com/api/v1/email-campaigns/ecamp_march_01/send" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json"
Get Analytics
curl -X GET "https://api.sendseven.com/api/v1/email-campaigns/ecamp_march_01/analytics" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json"
{
"campaign_id": "ecamp_march_01",
"sent": 12500,
"delivered": 12210,
"opened": 4884,
"clicked": 1587,
"bounced": 290,
"complained": 5,
"unsubscribed": 23,
"delivery_rate": 97.68,
"open_rate": 40.00,
"click_rate": 13.00,
"bounce_rate": 2.32,
"complaint_rate": 0.04
}
Complete Workflow (Python)
import requests
BASE_URL = "https://api.sendseven.com/api/v1"
HEADERS = {
"Authorization": "Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00",
"Content-Type": "application/json",
}
# Step 1 (templates) is skipped: simple content needs no template.
# Step 2: Create email content
content = requests.post(
f"{BASE_URL}/email-contents",
headers=HEADERS,
json={
"name": "April Promo Content",
"content_type": "simple",
"subject_line": "Spring savings - 25% off this week only!",
"from_name": "Acme Store",
"from_email": "[email protected]",
"compiled_html": (
"<p>Hi {{contact.first_name}},</p>"
"<p>Save <strong>25%</strong> on all orders this week with code SPRING25.</p>"
'<p><a href="https://shop.acme.com/spring-sale">Shop the sale</a></p>'
),
},
).json()
print(f"Created content: {content['id']}")
# Step 3: Create and schedule the campaign
campaign = requests.post(
f"{BASE_URL}/email-campaigns",
headers=HEADERS,
json={
"name": "Spring Sale Email",
"email_content_id": content["id"],
"list_ids": ["list_newsletter", "list_active_customers"],
"scheduled_at": "2026-04-01T08:00:00Z",
},
).json()
print(f"Campaign scheduled: {campaign['id']} for {campaign['scheduled_at']}")
print(f"Recipients: {campaign['recipient_count']}")
JavaScript Example
const BASE_URL = "https://api.sendseven.com/api/v1";
const HEADERS = {
"Authorization": "Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00",
"Content-Type": "application/json",
};
// Create email content
const contentRes = await fetch(`${BASE_URL}/email-contents`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({
name: "Product Update Email",
content_type: "simple",
subject_line: "New feature: Automated Workflows",
from_name: "Acme Product Team",
from_email: "[email protected]",
compiled_html:
"<p>Set up triggers, conditions and actions to automate your messaging.</p>" +
'<p><a href="https://acme.com/blog/automated-workflows">Learn more</a></p>',
}),
});
const content = await contentRes.json();
// Create and send the campaign
const campaignRes = await fetch(`${BASE_URL}/email-campaigns`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({
name: "Workflows Announcement",
email_content_id: content.id,
list_ids: ["list_newsletter"],
}),
});
const campaign = await campaignRes.json();
// Send immediately
await fetch(`${BASE_URL}/email-campaigns/${campaign.id}/send`, {
method: "POST",
headers: HEADERS,
});
console.log("Campaign is sending!");
Error Responses
Most errors return detail as a message string. The cases with a machine-readable code are listed with it. See Error Handling for the response shapes.
| Status | detail | Description |
|---|---|---|
| 400 | "Cannot modify campaign with status: ..." | The campaign is no longer a draft or scheduled |
| 400 | "Cannot delete campaign that has been sent" | Sent campaigns can't be deleted |
| 401 | "Invalid or expired API token" | Token is invalid or expired |
| 402 | {"error": "insufficient_balance", "message": ..., "required_balance": ..., "current_balance": ..., "top_up_url": ...} | The account balance doesn't cover the send |
| 403 | "Missing required permission(s): ..." | Token lacks the required campaigns:* scope |
| 404 | message string | Unknown template, content or campaign ID |
| 409 | message string | The campaign is already being sent (double send, or a scheduled send already started) |
| 422 | {"code": "audience_list_membership_required", "message": ...} | audience_conditions has no list membership at the top level |
| 422 | {"code": "audience_membership_list_invalid", "message": ...} | A membership list is not a static or newsletter list in your workspace |
| 422 | {"code": "simple_body_empty", "message": ...} | A simple content body is empty after sanitising |
| 422 | {"code": "simple_body_too_large", "message": ...} | A simple content body is larger than 200 KB or has more than 10,000 markup characters |
| 422 | list of field errors | Invalid request body |
Next Steps
- Messaging Campaigns -- channel-based campaigns
- Email section -- connection types, opt-in, and unsubscribe handling
- Email Channel -- email channel setup
- Tags & Lists -- manage recipient lists