Reference

API reference

Endpoints, parameters, and responses. Base URL https://api.sendara.dev.

All requests are made over HTTPS to https://api.sendara.dev and authenticated with a Bearer key. Timestamps are RFC 3339; monetary amounts are micro-dollars (1,000,000 = $1.00). Path parameters are shown in {braces}.

Send

Send messages across channels — one at a time or in batches.

Send a message

POST/v1/sendsend scope

Send a single email. Email is the only generally available send channel today.

Body parameters
channelstringRequired
email — the only supported send channel.
idempotency_keystringOptional
Optional; auto-generated if omitted. Reusing a key with a different body returns 409 idempotency_key_reused.
destinationobjectRequired
The recipient: { email }.
payloadobjectRequired
Email content: { subject, body_html, body_text }.
message_typestringOptional
transactional (default) or marketing.
metadataobjectOptional
Per-send options, e.g. { from_email }.
template_idstringOptional
Render a stored template instead of an inline payload.
template_varsobjectOptional
Variables interpolated into the template.
Example request
curl https://api.sendara.dev/v1/send \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "email",
    "idempotency_key": "evt_welcome_8f3a",
    "message_type": "transactional",
    "destination": { "email": "[email protected]" },
    "payload": {
      "subject": "Welcome to Acme",
      "body_html": "<h1>Welcome 🎉</h1>"
    },
    "metadata": { "from_email": "[email protected]" }
  }'
Response · 201
{
  "id": "msg_a1b2c3",
  "status": "queued",
  "channel": "email",
  "idempotency_key": "evt_welcome_8f3a",
  "created_at": "2026-06-14T10:00:00Z"
}

Send a batch

POST/v1/send/batchsend scope

Send many messages in one call. Each item is processed independently; the response preserves request order with per-item success or error. Partial success is normal.

Example request
curl https://api.sendara.dev/v1/send/batch \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '[
    { "channel": "email", "idempotency_key": "b1",
      "destination": { "email": "[email protected]" },
      "payload": { "subject": "Hi", "body_html": "<p>Hi</p>" } },
    { "channel": "email", "idempotency_key": "b2",
      "destination": { "email": "[email protected]" },
      "payload": { "subject": "Hi", "body_html": "<p>Hi</p>" } }
  ]'
Response · 200
[
  { "success": true,
    "response": { "id": "msg_1", "status": "queued", "channel": "email",
      "idempotency_key": "b1", "created_at": "2026-06-14T10:00:00Z" } },
  { "success": false,
    "error": { "code": "recipient_suppressed",
      "message": "Recipient is on the suppression list", "status": 409 } }
]

Validate an email address

POST/v1/validatesend scope

Pre-flight check of a single address — syntax plus domain MX — without sending. Returns validation_not_configured (503) when validation is unavailable.

Body parameters
emailstringRequired
The address to validate.
Example request
curl https://api.sendara.dev/v1/validate \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "email": "[email protected]" }'
Response · 200
{ "email": "[email protected]", "valid": true, "reason": "" }

Broadcasts

Bulk email campaigns to an audience or an inline recipient list.

Send a bulk email

POST/v1/send/bulksend scope

Send one email to many recipients in a single call. Provide an audience_list_id (a contact list) or an inline recipients array, plus inline content (subject + body_html) or a template_id rendered per recipient. Fans out asynchronously and returns a broadcast to poll. Suppressed and unsubscribed recipients are skipped; each recipient is idempotent.

Body parameters
from_emailstringRequired
Verified sending address.
subjectstringOptional
Inline subject (or use template_id).
body_htmlstringOptional
Inline HTML body; supports {{variables}}.
template_idstringOptional
Render a stored template per recipient instead of inline content.
audience_list_idstringOptional
A contact list (static or dynamic) to send to.
recipientsarrayOptional
Inline recipients [{ email, data }] — an alternative to audience_list_id.
message_typestringOptional
marketing (default) or transactional.
Example request
curl https://api.sendara.dev/v1/send/bulk \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "from_email": "[email protected]",
    "subject": "News from Acme",
    "body_html": "<h1>Hi {{first_name}}</h1>",
    "audience_list_id": "list_9f21"
  }'
Response · 202
{
  "id": "bc_a1b2c3",
  "name": "Bulk send",
  "status": "sending",
  "from_email": "[email protected]",
  "total_recipients": 0,
  "sent_count": 0,
  "failed_count": 0,
  "created_at": "2026-06-14T10:00:00Z"
}

List broadcasts

GET/v1/broadcastsread scope

Return the account's broadcasts, newest first.

Response · 200
{
  "broadcasts": [
    { "id": "bc_a1b2c3", "name": "October newsletter", "status": "sent",
      "from_email": "[email protected]", "total_recipients": 1840,
      "sent_count": 1840, "failed_count": 0, "created_at": "2026-06-14T10:00:00Z" }
  ]
}

Get a broadcast

GET/v1/broadcasts/{id}read scope

Return a broadcast with aggregate delivery stats.

Response · 200
{
  "broadcast": { "id": "bc_a1b2c3", "status": "sent", "total_recipients": 1840 },
  "stats": { "total": 1840, "sent": 1840, "delivered": 1798,
             "opened": 642, "bounced": 31, "complained": 2, "failed": 0 }
}

Messages

Read sent messages and their event timeline.

List messages

GET/v1/messagesread scope

Return a list of message summaries, filtered by channel, status, and time range.

Query parameters
channelstringOptional
Filter by channel.
statusstringOptional
Filter by status, e.g. delivered or bounced.
fromstringOptional
RFC 3339 lower bound (inclusive).
tostringOptional
RFC 3339 upper bound (inclusive).
Response · 200
{
  "messages": [
    { "id": "msg_a1b2c3", "channel": "email", "status": "delivered",
      "message_type": "transactional", "created_at": "2026-06-14T10:00:00Z" }
  ]
}

Get a message

GET/v1/messages/{id}read scope

Retrieve a single message with its full event timeline.

Response · 200
{
  "id": "msg_a1b2c3",
  "channel": "email",
  "status": "delivered",
  "message_type": "transactional",
  "created_at": "2026-06-14T10:00:00Z",
  "events": [
    { "id": "ev_1", "type": "accepted", "occurred_at": "2026-06-14T10:00:00Z" },
    { "id": "ev_2", "type": "delivered", "occurred_at": "2026-06-14T10:00:12Z" }
  ]
}

Usage

Account usage and cost for a billing period.

Get usage

GET/v1/usageread scope

Return the send count and cost (in micro-dollars) for the period, broken down by channel.

Query parameters
periodstringOptional
Billing period as YYYY-MM. Defaults to the current period.
Response · 200
{
  "period": "2026-06",
  "total_send_count": 18420,
  "total_cost_micros": 9210000,
  "channels": [
    { "channel": "email", "send_count": 18420, "cost_micros": 9210000 }
  ]
}

Domains

Add and verify sending domains. Requires the admin scope.

Add a sending domain

POST/v1/domainsadmin scope

Register a domain and receive the DNS records to publish: three DKIM CNAMEs, a custom MAIL FROM (MX + SPF), and DMARC.

Body parameters
domainstringRequired
The domain or subdomain to send from, e.g. mail.acme.com.
Example request
curl https://api.sendara.dev/v1/domains \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "domain": "mail.acme.com" }'
Response · 201
{
  "id": "dom_1",
  "domain": "mail.acme.com",
  "dkim_status": "pending",
  "spf_status": "pending",
  "dmarc_status": "pending",
  "dns_records": [
    { "type": "CNAME", "name": "s1._domainkey.mail.acme.com",
      "value": "s1.dkim.sendara.dev" }
  ],
  "mail_from_domain": "mail.acme.com",
  "created_at": "2026-06-14T10:00:00Z"
}

Verify a domain

POST/v1/domains/{domain}/verifyadmin scope

Re-check DNS status and return the per-record verification result.

Response · 200
{
  "domain": "mail.acme.com",
  "fully_verified": true,
  "results": [
    { "field": "dkim", "type": "CNAME", "name": "s1._domainkey.mail.acme.com",
      "status": "verified", "detail": "" }
  ]
}

Get BIMI state

GET/v1/domains/{domain}/bimiread scope

Return the domain's BIMI state: the hosted logo URL, the TXT record to publish (null if no logo is set), whether DMARC is at enforcement with a recommended policy, whether the record is live, and a note on which providers require a VMC.

Response · 200
{
  "logo_url": "https://assets.sendara.dev/v1/bimi/mail.acme.com/logo.svg",
  "record": {
    "type": "TXT",
    "name": "default._bimi.mail.acme.com",
    "value": "v=BIMI1; l=https://assets.sendara.dev/v1/bimi/mail.acme.com/logo.svg;"
  },
  "dmarc": {
    "at_enforcement": false,
    "recommended": "v=DMARC1; p=quarantine; rua=mailto:[email protected]"
  },
  "bimi_published": false,
  "vmc_note": "Gmail and Apple Mail require a paid VMC tied to a registered trademark; Yahoo and Fastmail display the logo without one."
}

Set the BIMI logo URL

PUT/v1/domains/{domain}/bimiadmin scope

Point BIMI at a square SVG Tiny PS logo served over HTTPS. Sendara validates it and returns the same shape as the GET, including the generated TXT record.

Body parameters
logo_urlstringRequired
Public HTTPS URL of a square SVG Tiny PS logo.
Example request
curl -X PUT https://api.sendara.dev/v1/domains/mail.acme.com/bimi \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "logo_url": "https://cdn.acme.com/brand/logo-tiny-ps.svg" }'
Response · 200
{
  "logo_url": "https://cdn.acme.com/brand/logo-tiny-ps.svg",
  "record": {
    "type": "TXT",
    "name": "default._bimi.mail.acme.com",
    "value": "v=BIMI1; l=https://cdn.acme.com/brand/logo-tiny-ps.svg;"
  },
  "dmarc": { "at_enforcement": true, "recommended": "v=DMARC1; p=reject; rua=mailto:[email protected]" },
  "bimi_published": false,
  "vmc_note": "Gmail and Apple Mail require a paid VMC tied to a registered trademark; Yahoo and Fastmail display the logo without one."
}
POST/v1/domains/{domain}/bimi/logoadmin scope

Send a multipart/form-data request with a file part (SVG, max 1 MiB). Sendara validates the SVG Tiny PS profile, hosts the logo on a stable HTTPS URL, and returns the same shape as the GET. A non-compliant or oversized file returns invalid_request (400) or payload_too_large (413).

Example request
curl https://api.sendara.dev/v1/domains/mail.acme.com/bimi/logo \
  -H "Authorization: Bearer sk_live_xxx" \
  -F "[email protected]"
Response · 200
{
  "logo_url": "https://assets.sendara.dev/v1/bimi/mail.acme.com/logo.svg",
  "record": {
    "type": "TXT",
    "name": "default._bimi.mail.acme.com",
    "value": "v=BIMI1; l=https://assets.sendara.dev/v1/bimi/mail.acme.com/logo.svg;"
  },
  "dmarc": { "at_enforcement": true, "recommended": "v=DMARC1; p=reject; rua=mailto:[email protected]" },
  "bimi_published": false,
  "vmc_note": "Gmail and Apple Mail require a paid VMC tied to a registered trademark; Yahoo and Fastmail display the logo without one."
}

API keys

Create, rotate, and revoke API keys. Requires the admin scope.

Create an API key

POST/v1/keysadmin scope

Create a scoped key. The plaintext secret is returned exactly once — store it securely.

Body parameters
scopestringOptional
send, read, or admin.
test_modebooleanOptional
Issue a test-mode key (sk_test_…). Defaults to false.
Example request
curl https://api.sendara.dev/v1/keys \
  -H "Authorization: Bearer sk_live_admin_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "scope": "send", "test_mode": false }'
Response · 201
{
  "id": "key_1",
  "key": "sk_live_5f3a39ab8c2d4e6f",
  "key_prefix": "sk_live_5f3a",
  "scope": "send",
  "test_mode": false,
  "created_at": "2026-06-14T10:00:00Z"
}

Rotate an API key

POST/v1/keys/{id}/rotateadmin scope

Issue a new secret for the key and invalidate the old one. The new plaintext key is returned once.

Response · 200
{ "key": "sk_live_9b2c0a17e4d5f6a8" }

Revoke an API key

DELETE/v1/keys/{id}admin scope

Permanently revoke a key. Subsequent requests with it return 401.

Response · 204
# 204 No Content

Suppressions

Manage recipients that should never receive messages on a channel.

Suppress a recipient

POST/v1/suppressionssend scope

Block all future sends to a recipient on the given channel.

Body parameters
channelstringRequired
Channel to suppress on.
recipientstringRequired
Email address or phone number.
reasonstringOptional
Optional reason, e.g. hard_bounce or complaint.
Example request
curl https://api.sendara.dev/v1/suppressions \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "channel": "email", "recipient": "[email protected]", "reason": "hard_bounce" }'
Response · 201
{
  "channel": "email",
  "recipient": "[email protected]",
  "state": "suppressed",
  "reason": "hard_bounce",
  "updated_at": "2026-06-14T10:00:00Z"
}

Remove a suppression

DELETE/v1/suppressionssend scope

Un-suppress a recipient so they can receive messages again.

Query parameters
channelstringRequired
Channel the suppression is on.
recipientstringRequired
The suppressed recipient to remove.
Response · 204
# 204 No Content

Billing

Read the plan and open Polar checkout or the customer portal.

Get billing state

GET/v1/billingread scope

Return the account's current plan and subscription status.

Response · 200
{ "plan": "pro", "subscription_status": "active" }

Start a checkout

POST/v1/billing/checkoutadmin scope

Return a hosted Polar checkout URL for the chosen plan and billing period. Annual billing is two months free.

Body parameters
planstringRequired
The plan to subscribe to: starter, pro, or scale.
periodstringOptional
Billing period: month or year. Annual saves two months. Defaults to month.
Example request
curl https://api.sendara.dev/v1/billing/checkout \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "plan": "pro", "period": "year" }'
Response · 200
{ "url": "https://checkout.polar.sh/..." }

Open the customer portal

POST/v1/billing/portaladmin scope

Return a hosted Polar customer-portal URL to manage the subscription, payment method, and invoices.

Response · 200
{ "url": "https://polar.sh/acme/portal/..." }

Templates

Store reusable email content with mustache {{variables}}. Render a template inline on a send (template_id + template_vars) or preview it with the render endpoint.

Create a template

POST/v1/templatessend scope

Store an email template. Subject and body support {{ name }} variables; each variable can declare a sample, default, and whether it is required.

Body parameters
namestringRequired
Human-readable template name.
channelstringRequired
email (the supported channel today).
subjectstringOptional
Subject line; supports {{variables}}.
body_htmlstringOptional
HTML body; supports {{variables}}.
body_textstringOptional
Optional plain-text alternative.
body_jsonobjectOptional
Optional design JSON from the visual builder.
variablesarrayOptional
Variable metadata [{ name, sample, default, required }].
Example request
curl https://api.sendara.dev/v1/templates \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Welcome",
    "channel": "email",
    "subject": "Welcome, {{ first_name }}",
    "body_html": "<h1>Hi {{ first_name }}</h1>",
    "variables": [
      { "name": "first_name", "sample": "Ada", "required": true }
    ]
  }'
Response · 201
{
  "id": "tmpl_a1b2c3",
  "name": "Welcome",
  "channel": "email",
  "subject": "Welcome, {{ first_name }}",
  "body_html": "<h1>Hi {{ first_name }}</h1>",
  "body_text": null,
  "variables": [
    { "name": "first_name", "sample": "Ada", "required": true }
  ],
  "version": 1,
  "is_active": true,
  "created_at": "2026-06-14T10:00:00Z",
  "updated_at": "2026-06-14T10:00:00Z"
}

List templates

GET/v1/templatesread scope

Return the account's templates, newest first.

Response · 200
{
  "templates": [
    { "id": "tmpl_a1b2c3", "name": "Welcome", "channel": "email",
      "version": 1, "is_active": true, "created_at": "2026-06-14T10:00:00Z" }
  ]
}

Get a template

GET/v1/templates/{id}read scope

Retrieve a single template with its full body and variable metadata.

Response · 200
{
  "id": "tmpl_a1b2c3",
  "name": "Welcome",
  "channel": "email",
  "subject": "Welcome, {{ first_name }}",
  "body_html": "<h1>Hi {{ first_name }}</h1>",
  "variables": [{ "name": "first_name", "sample": "Ada", "required": true }],
  "version": 1,
  "is_active": true
}

Update a template

PUT/v1/templates/{id}send scope

Update content or metadata. Editing the body or variables creates a new version.

Body parameters
namestringOptional
New name.
subjectstringOptional
New subject.
body_htmlstringOptional
New HTML body.
body_textstringOptional
New plain-text body.
body_jsonobjectOptional
New design JSON.
variablesarrayOptional
Replacement variable metadata.
is_activebooleanOptional
Toggle whether the template can be used in sends.
Response · 200
{ "id": "tmpl_a1b2c3", "name": "Welcome back", "version": 2 }

Render a preview

POST/v1/templates/{id}/renderread scope

Render the template with a set of variables and return the resulting subject and bodies — without sending. A missing required variable returns missing_variable (400).

Body parameters
varsobjectRequired
Variable values to interpolate, e.g. { "first_name": "Ada" }.
Example request
curl https://api.sendara.dev/v1/templates/tmpl_a1b2c3/render \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "vars": { "first_name": "Ada" } }'
Response · 200
{
  "subject": "Welcome, Ada",
  "body_html": "<h1>Hi Ada</h1>",
  "body_text": null
}

Delete a template

DELETE/v1/templates/{id}send scope

Permanently delete a template.

Response · 204
# 204 No Content

Contacts & lists

Store contacts and organize them into static or dynamic lists. A list (audience) is the target of a broadcast via audience_list_id.

Create a contact

POST/v1/contactsadmin scope

Add a contact. An email address that already exists returns duplicate_contact (409).

Body parameters
emailstringOptional
Contact email address.
phone_numberstringOptional
E.164 phone number.
first_namestringOptional
Given name.
last_namestringOptional
Family name.
attributesobjectOptional
Arbitrary key/value data usable in {{variables}}.
tagsarrayOptional
String tags for segmentation.
email_consentstringOptional
subscribed, unsubscribed, or unknown.
Example request
curl https://api.sendara.dev/v1/contacts \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "first_name": "Ada",
    "tags": ["beta"],
    "attributes": { "plan": "pro" }
  }'
Response · 201
{
  "id": "ct_a1b2c3",
  "email": "[email protected]",
  "phone_number": null,
  "first_name": "Ada",
  "last_name": "",
  "attributes": { "plan": "pro" },
  "tags": ["beta"],
  "email_consent": "subscribed",
  "created_at": "2026-06-14T10:00:00Z",
  "updated_at": "2026-06-14T10:00:00Z"
}

List contacts

GET/v1/contactsread scope

Return contacts, newest first.

Response · 200
{
  "contacts": [
    { "id": "ct_a1b2c3", "email": "[email protected]",
      "first_name": "Ada", "tags": ["beta"],
      "email_consent": "subscribed", "created_at": "2026-06-14T10:00:00Z" }
  ]
}

Get a contact

GET/v1/contacts/{id}read scope

Retrieve a single contact.

Response · 200
{ "id": "ct_a1b2c3", "email": "[email protected]", "first_name": "Ada" }

Update a contact

PUT/v1/contacts/{id}admin scope

Update any contact field. Only the fields present in the body are changed.

Body parameters
emailstringOptional
New email address.
first_namestringOptional
New given name.
attributesobjectOptional
Replacement attributes.
tagsarrayOptional
Replacement tags.
email_consentstringOptional
subscribed, unsubscribed, or unknown.
Response · 200
{ "id": "ct_a1b2c3", "email": "[email protected]", "first_name": "Augusta" }

Delete a contact

DELETE/v1/contacts/{id}admin scope

Permanently delete a contact and remove it from all lists.

Response · 204
# 204 No Content

Import contacts from a file

POST/v1/contacts/importadmin scope

Bulk-import contacts from a previously uploaded CSV or JSON file in object storage. Existing emails are upserted.

Body parameters
s3_keystringRequired
Object-storage key of the uploaded import file (must belong to your account).
formatstringRequired
csv or json.
Example request
curl https://api.sendara.dev/v1/contacts/import \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "s3_key": "imports/acct_123/contacts.csv", "format": "csv" }'
Response · 200
{
  "success_count": 240,
  "error_count": 2,
  "errors": [
    { "row": 18, "message": "invalid email" }
  ]
}

Import contacts inline

POST/v1/contacts/import/inlineadmin scope

Import already-mapped rows directly in the request body — no file upload. Set dry_run to validate without writing, and optionally add every imported contact to a list.

Body parameters
rowsarrayRequired
Rows to import [{ email, phone_number, first_name, last_name, tags, attributes }].
list_idstringOptional
Optional list to add each imported contact to.
dry_runbooleanOptional
Validate and report without writing. Defaults to false.
Example request
curl https://api.sendara.dev/v1/contacts/import/inline \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "rows": [
      { "email": "[email protected]", "first_name": "Ada", "tags": ["beta"] }
    ],
    "list_id": "list_9f21",
    "dry_run": true
  }'
Response · 200
{
  "summary": {
    "total": 1,
    "valid": 1,
    "invalid": 0,
    "duplicates_existing": 0,
    "errors": []
  },
  "success_count": 0,
  "error_count": 0
}

Get contact usage

GET/v1/contacts/usageread scope

Return the current contact count against your marketing plan's audience limit (a limit of 0 means unlimited).

Response · 200
{ "count": 1840, "limit": 5000 }

Bulk contact action

POST/v1/contacts/bulkadmin scope

Apply one action to a set of contacts. Ids not owned by your account are silently skipped.

Body parameters
idsarrayRequired
Contact ids to act on.
actionstringRequired
add_tag, remove_tag, delete, set_consent, add_to_list, or remove_from_list.
tagstringOptional
Tag for add_tag / remove_tag.
list_idstringOptional
List for add_to_list / remove_from_list.
channelstringOptional
Channel for set_consent (e.g. email).
consentstringOptional
Consent state for set_consent.
Example request
curl https://api.sendara.dev/v1/contacts/bulk \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "ids": ["ct_a1b2c3", "ct_d4e5f6"], "action": "add_tag", "tag": "beta" }'
Response · 200
{ "affected": 2 }

Preview a segment

POST/v1/contacts/segments/previewread scope

Count the contacts that match a set of segment rules, without creating a list. Invalid rules return invalid_request (400).

Body parameters
segment_rulesobjectRequired
The segment rules to evaluate.
Response · 200
{ "count": 312 }

List a contact's lists

GET/v1/contacts/{id}/listsread scope

Return the lists a contact belongs to.

Response · 200
{
  "lists": [
    { "id": "list_9f21", "name": "Beta users", "list_type": "dynamic" }
  ]
}

Get a contact's activity

GET/v1/contacts/{id}/activityread scope

Return a contact's recent activity timeline (outbound sends and events, plus inbound emails), newest first.

Query parameters
limitintegerOptional
Maximum number of events to return.
Response · 200
{
  "events": [
    { "type": "opened", "channel": "email", "source": "broadcast",
      "subject": "October newsletter", "message_id": "msg_a1b2c3",
      "occurred_at": "2026-06-14T10:04:30Z" },
    { "type": "delivered", "channel": "email", "source": "broadcast",
      "subject": "October newsletter", "message_id": "msg_a1b2c3",
      "occurred_at": "2026-06-14T10:00:12Z" }
  ]
}

Create a list

POST/v1/contacts/listsadmin scope

Create an audience. A static list holds contacts you add explicitly; a dynamic list auto-includes contacts matching segment_rules (tags / attributes).

Body parameters
namestringRequired
List name.
list_typestringRequired
static or dynamic.
segment_rulesobjectOptional
For dynamic lists: { tags, attributes } a contact must match.
Example request
curl https://api.sendara.dev/v1/contacts/lists \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Beta users", "list_type": "dynamic",
        "segment_rules": { "tags": ["beta"] } }'
Response · 201
{
  "id": "list_9f21",
  "name": "Beta users",
  "list_type": "dynamic",
  "segment_rules": { "tags": ["beta"] },
  "created_at": "2026-06-14T10:00:00Z",
  "updated_at": "2026-06-14T10:00:00Z"
}

List lists

GET/v1/contacts/listsread scope

Return the account's contact lists.

Response · 200
{
  "lists": [
    { "id": "list_9f21", "name": "Beta users", "list_type": "dynamic",
      "created_at": "2026-06-14T10:00:00Z" }
  ]
}

Add a list member

POST/v1/contacts/lists/{id}/membersadmin scope

Add a contact to a static list. Adding a contact already on the list returns duplicate_member (409).

Body parameters
contact_idstringRequired
The contact to add.
Response · 201
{
  "id": "lm_1",
  "contact_list_id": "list_9f21",
  "contact_id": "ct_a1b2c3",
  "added_at": "2026-06-14T10:00:00Z"
}

List list members

GET/v1/contacts/lists/{id}/membersread scope

Return the contacts on a list.

Response · 200
{ "members": [ { "id": "ct_a1b2c3", "email": "[email protected]" } ] }

Remove a list member

DELETE/v1/contacts/lists/{id}/members/{contactId}admin scope

Remove a contact from a static list.

Response · 204
# 204 No Content

Webhooks

Subscribe an HTTPS endpoint to delivery events (delivered, bounced, complained, opened, …). Each subscription has a signing secret used to verify the Sendara-Signature header.

Create a webhook

POST/v1/webhooksadmin scope

Register an endpoint and the event types to deliver. The signing secret is returned once — store it to verify signatures.

Body parameters
endpoint_urlstringRequired
HTTPS URL that receives POSTed events.
event_typesarrayOptional
Event types to deliver, e.g. ["delivered", "bounced"]. Omit for all.
Example request
curl https://api.sendara.dev/v1/webhooks \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "endpoint_url": "https://acme.com/hooks/sendara",
        "event_types": ["delivered", "bounced", "complained"] }'
Response · 201
{
  "id": "wh_a1b2c3",
  "endpoint_url": "https://acme.com/hooks/sendara",
  "event_types": ["delivered", "bounced", "complained"],
  "signing_secret": "whsec_5f3a39ab8c2d4e6f9a0b1c2d3e4f5a6b",
  "is_active": true,
  "created_at": "2026-06-14T10:00:00Z"
}

List webhooks

GET/v1/webhooksread scope

Return the account's webhook subscriptions. The signing secret is not included.

Response · 200
{
  "webhooks": [
    { "id": "wh_a1b2c3", "endpoint_url": "https://acme.com/hooks/sendara",
      "event_types": ["delivered", "bounced"], "is_active": true,
      "created_at": "2026-06-14T10:00:00Z" }
  ]
}

Get a webhook

GET/v1/webhooks/{id}read scope

Retrieve a single webhook subscription.

Response · 200
{ "id": "wh_a1b2c3", "endpoint_url": "https://acme.com/hooks/sendara",
  "event_types": ["delivered", "bounced"], "is_active": true }

Update a webhook

PUT/v1/webhooks/{id}admin scope

Change the endpoint, event types, or active state.

Body parameters
endpoint_urlstringOptional
New endpoint URL.
event_typesarrayOptional
New event-type filter.
is_activebooleanOptional
Pause or resume deliveries.
Response · 200
{ "id": "wh_a1b2c3", "is_active": false }

Rotate the signing secret

POST/v1/webhooks/{id}/rotate-secretadmin scope

Issue a new signing secret and invalidate the old one. The new secret is returned once.

Response · 200
{ "signing_secret": "whsec_9b2c0a17e4d5f6a8b1c2d3e4f5a6b7c8" }

List deliveries

GET/v1/webhooks/{id}/deliveriesread scope

Inspect recent delivery attempts for a subscription, including status and response code.

Response · 200
{
  "deliveries": [
    { "id": "whd_1", "event_id": "evt_9c1f", "event_type": "delivered",
      "status": "succeeded", "response_code": 200, "attempt_count": 1,
      "created_at": "2026-06-14T10:00:00Z" }
  ]
}

Delete a webhook

DELETE/v1/webhooks/{id}admin scope

Permanently delete a subscription. No further events are delivered.

Response · 204
# 204 No Content

Test recipients

Register up to 3 of your own addresses, verify them by email, then send REAL emails to them for free (capped 10/address/day) with test_send: true — ideal for prod/UAT validation without billing.

Register a test recipient

POST/v1/test-recipientssend scope

Register an address and trigger a verification email. Registering a 4th address returns too_many_test_recipients (422).

Body parameters
emailstringRequired
An address you control. A verification link is emailed to it.
Example request
curl https://api.sendara.dev/v1/test-recipients \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "email": "[email protected]" }'
Response · 201
{
  "id": "tr_a1b2c3",
  "email": "[email protected]",
  "status": "pending",
  "verified_at": null,
  "created_at": "2026-06-14T10:00:00Z"
}

List test recipients

GET/v1/test-recipientsread scope

Return your registered test recipients and their verification status.

Response · 200
{
  "recipients": [
    { "id": "tr_a1b2c3", "email": "[email protected]", "status": "verified",
      "verified_at": "2026-06-14T10:05:00Z", "created_at": "2026-06-14T10:00:00Z" }
  ]
}

Resend verification

POST/v1/test-recipients/{id}/resendsend scope

Re-send the verification email for a pending test recipient.

Response · 204
# 204 No Content

Delete a test recipient

DELETE/v1/test-recipients/{id}send scope

Remove a registered test recipient, freeing one of your 3 slots.

Response · 204
# 204 No Content

Uploads

Host images for use in templates and emails. Upload a PNG, JPEG, GIF, or WebP (max 2 MiB) and receive a public URL.

Upload an image

POST/v1/uploadssend scope

Send a multipart/form-data request with a file part. Returns a public URL to embed in HTML. Files over 2 MiB return payload_too_large (413).

Example request
curl https://api.sendara.dev/v1/uploads \
  -H "Authorization: Bearer sk_live_xxx" \
  -F "[email protected]"
Response · 201
{
  "id": "asset_a1b2c3",
  "url": "https://assets.sendara.dev/v1/assets/asset_a1b2c3",
  "content_type": "image/png",
  "bytes": 48213
}

Signup forms

Hosted and embeddable signup forms that subscribe contacts to a list, with optional double opt-in. Public submit, hosted-page, and confirm routes are unauthenticated; the CRUD below requires a key.

Create a form

POST/v1/formsadmin scope

Create a signup form bound to a list. Returns the generated slug, public URL, and embed code.

Body parameters
namestringRequired
Form name.
list_idstringRequired
List that subscribers are added to (must belong to your account).
double_opt_inbooleanOptional
Require email confirmation before subscribing. Defaults to false.
fieldsobjectOptional
Per-field config { first_name, last_name, phone }, each { enabled, required }.
success_messagestringOptional
Message shown after a successful submission.
redirect_urlstringOptional
Optional http/https URL to redirect to instead of showing success_message.
settingsobjectOptional
Presentation { heading, description, button_text, branding }.
Example request
curl https://api.sendara.dev/v1/forms \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Newsletter signup",
    "list_id": "list_9f21",
    "double_opt_in": true,
    "fields": { "first_name": { "enabled": true, "required": false } }
  }'
Response · 201
{
  "id": "form_a1b2c3",
  "slug": "newsletter-signup",
  "name": "Newsletter signup",
  "list_id": "list_9f21",
  "double_opt_in": true,
  "fields": { "first_name": { "enabled": true, "required": false },
              "last_name": { "enabled": false, "required": false },
              "phone": { "enabled": false, "required": false } },
  "success_message": "Thanks for subscribing!",
  "redirect_url": "",
  "settings": { "heading": "", "description": "", "button_text": "", "branding": true },
  "status": "active",
  "public_url": "https://api.sendara.dev/v1/public/f/newsletter-signup",
  "embed_code": "<iframe src=\"https://api.sendara.dev/v1/public/f/newsletter-signup?embed=1\"></iframe>",
  "submission_count": 0,
  "created_at": "2026-06-14T10:00:00Z",
  "updated_at": "2026-06-14T10:00:00Z"
}

List forms

GET/v1/formsread scope

Return the account's signup forms.

Response · 200
{
  "forms": [
    { "id": "form_a1b2c3", "slug": "newsletter-signup", "name": "Newsletter signup",
      "status": "active", "submission_count": 128, "created_at": "2026-06-14T10:00:00Z" }
  ]
}

Get a form

GET/v1/forms/{id}read scope

Retrieve a single form with its full config.

Response · 200
{ "id": "form_a1b2c3", "slug": "newsletter-signup", "name": "Newsletter signup",
  "list_id": "list_9f21", "status": "active", "submission_count": 128 }

Update a form

PUT/v1/forms/{id}admin scope

Update any form field. Set status to active or inactive to publish or unpublish.

Body parameters
namestringOptional
New name.
list_idstringOptional
New target list.
double_opt_inbooleanOptional
Toggle double opt-in.
fieldsobjectOptional
Replacement field config.
success_messagestringOptional
New success message.
redirect_urlstringOptional
New redirect URL.
settingsobjectOptional
New presentation settings.
statusstringOptional
active or inactive.
Response · 200
{ "id": "form_a1b2c3", "status": "inactive" }

Delete a form

DELETE/v1/forms/{id}admin scope

Permanently delete a form.

Response · 204
# 204 No Content

Get public form config

GET/v1/public/forms/{slug}

Unauthenticated. Return the render-only config for a form — deliberately omits account, list, and submission internals.

Response · 200
{
  "name": "Newsletter signup",
  "heading": "Join the list",
  "description": "",
  "fields": { "first_name": { "enabled": true, "required": false },
              "last_name": { "enabled": false, "required": false },
              "phone": { "enabled": false, "required": false } },
  "button_text": "Subscribe",
  "success_message": "Thanks for subscribing!",
  "double_opt_in": true
}

Submit a public form

POST/v1/public/forms/{slug}/submit

Unauthenticated, honeypot-protected, IP rate-limited. Subscribe an email to the form's list. With double opt-in, returns pending until the contact confirms.

Body parameters
emailstringRequired
Subscriber email.
first_namestringOptional
If the form enables it.
last_namestringOptional
If the form enables it.
phonestringOptional
If the form enables it.
Response · 200
{ "status": "subscribed" }

Automations

Drip sequences triggered by a list add, tag add, or form signup. An automation is inert until activated, then enrolls matching contacts and walks them through wait/email steps.

Create an automation

POST/v1/automationsadmin scope

Create a drip automation. It starts in draft and sends nothing until you activate it.

Body parameters
namestringRequired
Automation name.
from_emailstringRequired
Verified sending address for the email steps.
trigger_typestringRequired
list_added, tag_added, or form_signup.
trigger_configobjectRequired
Trigger target: { list_id } / { tag } / { form_id } (must belong to your account).
stepsarrayOptional
Up to 100 steps. wait → { type: 'wait', hours }; email → { type: 'email', subject, body_html, body_text }.
Example request
curl https://api.sendara.dev/v1/automations \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Welcome series",
    "from_email": "[email protected]",
    "trigger_type": "list_added",
    "trigger_config": { "list_id": "list_9f21" },
    "steps": [
      { "type": "email", "subject": "Welcome", "body_html": "<h1>Hi</h1>" },
      { "type": "wait", "hours": 24 },
      { "type": "email", "subject": "Day two", "body_html": "<p>More</p>" }
    ]
  }'
Response · 201
{
  "id": "auto_a1b2c3",
  "name": "Welcome series",
  "trigger_type": "list_added",
  "trigger_config": { "list_id": "list_9f21" },
  "from_email": "[email protected]",
  "status": "draft",
  "steps": [
    { "type": "email", "subject": "Welcome", "body_html": "<h1>Hi</h1>" },
    { "type": "wait", "hours": 24 }
  ],
  "enrollment_count": 0,
  "created_at": "2026-06-14T10:00:00Z"
}

List automations

GET/v1/automationsread scope

Return the account's automations.

Response · 200
{
  "automations": [
    { "id": "auto_a1b2c3", "name": "Welcome series", "trigger_type": "list_added",
      "status": "active", "enrollment_count": 412, "created_at": "2026-06-14T10:00:00Z" }
  ]
}

Get an automation

GET/v1/automations/{id}read scope

Retrieve a single automation with its full step list.

Response · 200
{ "id": "auto_a1b2c3", "name": "Welcome series", "status": "active",
  "enrollment_count": 412 }

Update an automation

PUT/v1/automations/{id}admin scope

Update name, sender, trigger, or steps.

Body parameters
namestringOptional
New name.
from_emailstringOptional
New sender address.
trigger_typestringOptional
New trigger type.
trigger_configobjectOptional
New trigger target.
stepsarrayOptional
Replacement steps.
Response · 200
{ "id": "auto_a1b2c3", "name": "Welcome series v2" }

Delete an automation

DELETE/v1/automations/{id}admin scope

Permanently delete an automation.

Response · 204
# 204 No Content

Activate an automation

POST/v1/automations/{id}/activateadmin scope

Move an automation to active so it starts enrolling matching contacts.

Response · 200
{ "id": "auto_a1b2c3", "status": "active" }

Pause an automation

POST/v1/automations/{id}/pauseadmin scope

Pause an active automation. Enrolled contacts stop advancing until it is reactivated.

Response · 200
{ "id": "auto_a1b2c3", "status": "paused" }

Analytics

Read-only marketing aggregates: an account-wide overview, a per-broadcast report, and per-contact engagement.

Get the overview

GET/v1/analytics/overviewread scope

Account-wide totals, rates, a daily timeseries, deliverability health, and top campaigns.

Query parameters
daysintegerOptional
Lookback window in days. Defaults to a recent window.
Response · 200
{
  "days": 30,
  "totals": { "sent": 18420, "delivered": 18100, "opened": 6420,
              "clicked": 1840, "bounced": 220, "complained": 8, "unsubscribed": 42 },
  "rates": { "delivery_rate": 0.9826, "open_rate": 0.3547, "click_rate": 0.1017,
             "click_to_open_rate": 0.2866, "bounce_rate": 0.0119,
             "complaint_rate": 0.0004, "unsubscribe_rate": 0.0023 },
  "timeseries": [ { "date": "2026-06-14", "sent": 640, "opened": 228, "clicked": 65 } ],
  "deliverability": { "bounce_rate": 0.0119, "complaint_rate": 0.0004,
                      "health": "good", "notes": [] },
  "top_campaigns": [
    { "id": "bc_a1b2c3", "name": "October newsletter", "sent_at": "2026-06-14T10:00:00Z",
      "sent": 1840, "delivered": 1798, "opened": 642, "clicked": 190,
      "open_rate": 0.3571, "click_rate": 0.1057 }
  ]
}

Get a broadcast report

GET/v1/analytics/broadcasts/{id}read scope

Totals, rates, a timeseries, and top clicked links for one broadcast.

Query parameters
daysintegerOptional
Lookback window in days.
Response · 200
{
  "broadcast_id": "bc_a1b2c3",
  "totals": { "sent": 1840, "delivered": 1798, "opened": 642, "clicked": 190 },
  "rates": { "delivery_rate": 0.9772, "open_rate": 0.3571, "click_rate": 0.1057 },
  "timeseries": [ { "date": "2026-06-14", "sent": 1840, "opened": 642, "clicked": 190 } ],
  "top_links": [ { "url": "https://acme.com/launch", "clicks": 120, "unique_clicks": 98 } ]
}

Get contact engagement

GET/v1/analytics/contacts/{id}read scope

A single contact's lifetime engagement and computed score.

Response · 200
{
  "contact_id": "ct_a1b2c3",
  "sent": 24,
  "opened": 18,
  "clicked": 6,
  "open_rate": 0.75,
  "click_rate": 0.25,
  "last_engaged_at": "2026-06-14T10:04:30Z",
  "score": 82,
  "status": "engaged"
}

Errors

Every error returns a consistent envelope: an HTTP status, a stable code you can branch on, and a human-readable message.

{
  "error": {
    "code": "from_not_verified",
    "message": "The from address is not on a verified domain",
    "status": 422
  }
}
Error codes
CodeStatusDescription
unauthorized401Missing or invalid API key. Check the Authorization: Bearer header. Recovery: supply a valid sk_live_/sk_test_ key.
forbidden403The key's scope does not permit this operation. Recovery: use a key with the send, read, or admin scope the endpoint requires.
invalid_request400The request body or parameters are malformed or missing a required field. Recovery: fix the field named in the message and retry.
not_found404The resource does not exist or belongs to another account. Recovery: check the id.
recipient_suppressed409The recipient is on the suppression list for this channel. Fires on send. Recovery: remove the suppression or send to a different address.
idempotency_key_reused409The idempotency_key was reused with a different request body. Recovery: use a new key, or replay the exact original body to get the cached result.
from_not_verified422The from address is not on a verified domain. Fires on send (also 403 in some paths). Recovery: verify the sending domain first.
missing_variable400A template render is missing a required {{variable}}. Recovery: supply every required variable in template_vars / vars.
invalid_template400The template syntax is invalid or the referenced template is inactive. Recovery: fix the template body or activate it.
invalid_token400A verification or action token is malformed or expired. Recovery: request a fresh link.
invalid_signature401Webhook signature verification failed (Sendara-Signature mismatch). Recovery: recompute HMAC-SHA256 over "<timestamp>.<rawBody>" with the subscription's signing secret.
rate_limit_exceeded429Too many requests in the window. Response carries Retry-After and X-RateLimit-* headers. Recovery: back off until X-RateLimit-Reset, then retry.
spend_cap_exceeded402The account's spend cap for the period has been reached. Recovery: raise the cap in billing or wait for the next period.
billing_not_configured400A billing-gated action was attempted before billing is set up. Recovery: complete checkout to configure billing.
duplicate_contact409A contact with this email already exists. Recovery: update the existing contact instead of creating a new one.
duplicate_member409The contact is already a member of this list. Recovery: treat as already-added; no action needed.
too_many_test_recipients422You already have the maximum of 3 registered test recipients. Recovery: delete one before adding another.
recipient_not_verified403A test_send target is not a verified test recipient for this account. Recovery: register and verify the address first.
test_send_daily_limit429The 10-per-address-per-day test-send cap was reached. Recovery: wait until the next UTC day or use a different verified recipient.
payload_too_large413The request body or upload exceeds the size limit (uploads cap at 2 MiB). Recovery: shrink the payload.
internal_error500An unexpected server error. Recovery: retry with backoff; if it persists, contact support.