Email

A domain, then the first send. Every call is on this page.

Start

Verify a domain, then send the first email.

Open this guide on its own page

Email starts with a domain you own. Add it under Domains, publish the DNS records, and wait until it verifies. Live sends use a from address on that domain.

A template is optional. You can send HTML or text in the request, or save the message on Templates and pass its id. The Quickstart is the full first-send walkthrough, including the API key.

Quickstart

Create a key, verify a domain, and send your first email.

Open this guide on its own page

You can send your first email in minutes: create an API key, verify a domain, and make a POST request.

1. Create an API key

Go to API Keys. Choose a preset — Full, Sending, or Read only — or narrow it further. Copy the key when shown.

Scope keys tightly

A server that only sends mail should use a Sending key. Keys can never manage other keys, team, or billing.

2. Verify a sending domain

Add your domain under Domains and publish the generated DNS records. Verification runs automatically.

No domain yet?

While DNS propagates you can still send test emails to your own address from the dashboard onboarding flow — no verified domain required. Test sends confirm the pipeline works; live API sends need a verified domain.

3. Send

Send an email in one call — a from address on your verified domain, a recipient, a subject, and either inline HTML or a template ID. The from accepts a bare address or a display name ("Acme <you@yourdomain.com>").

cURL
curl https://www.unitpost.com/api/v1/email \
  -H "Authorization: Bearer $UNITPOST_API_KEY" \
  -H "Content-Type: application/json" \
  -H "User-Agent: my-app/1.0" \
  -d '{
    "from": "Acme <you@yourdomain.com>",
    "to": "customer@acme.com",
    "subject": "Welcome aboard",
    "html": "<p>Thanks for signing up!</p>"
  }'

The response includes the new email's id. Use GET /api/v1/email/{id} (or unitpost.email.get(id)) to read its current status and lifecycle events, or watch it live on the Emails page in the dashboard.

Templates & variables

Reference a saved template and pass per-recipient data.

Open this guide on its own page

Rather than inlining HTML on every send, save a template once and reference it by ID. Pass template.variables and any {{variable}} in the template is substituted at send time.

cURL
curl https://www.unitpost.com/api/v1/email \
  -H "Authorization: Bearer $UNITPOST_API_KEY" \
  -H "Content-Type: application/json" \
  -H "User-Agent: my-app/1.0" \
  -d '{
    "from": "you@yourdomain.com",
    "to": "customer@acme.com",
    "template": {
      "id": "tmpl_123",
       "variables": { "plan": "Pro" }
    }
  }'

When you send to a known contact, that contact's custom fields merge into the variables automatically — so a template can reference {{first_name}} without you re-sending it on every request. Variables also work in the subject and in the From name: "{{company_name}} Team <hi@yourdomain.com>". A variable with no value fails the send instead of going out as written.

A custom field can carry a default (set it under Contacts → Custom fields, or as default_value on the contact-fields API). A field's default is the value used for any contact that has none: when an email is sent, so {{plan}} renders it, and when segments are evaluated. It's never written onto contacts, so changing it takes effect everywhere at once.

Drafts can't be sent

A template must be published before the API will send it. Referencing a draft returns a 422 with code template_not_published. Publish it in the editor first.

Marketing templates go to a contact

A template in the marketing category sends to one recipient (no cc or bcc), because its unsubscribe link and List-Unsubscribe header belong to that person. If the address isn't a contact yet, the send adds it to Contacts first, so the recipient can opt out and you can find and segment them later. On the Free plan the new contact counts toward your contact limit; at the limit the send is refused with a limit error instead. An address that already unsubscribed or reported spam is added as unsubscribed, and the email is suppressed. Transactional templates never create contacts.

Author templates in the editor under Templates, or through the API in one of three editors. A Design template (the default for anything branded) is our components as TSX in tsx; wrap them in one <Email> element to set the page (backdrop, card, width, radius, border, fonts, colors, preview text) in the same call, and it stays editable in the Design tab. A Simple template is a plain, personal email in text. A Code template is your own HTML in html, kept as-is. Every read says which editor a template is in; PATCH with editor alone converts it. Every block and layout is documented at /components.

Sending domains

Add SPF, DKIM, and DMARC so mail sends from your domain.

Open this guide on its own page

To send from your own domain you publish a few DNS records so mailbox providers trust the mail is really from you. Add the domain under Domains and copy the generated records to your DNS provider.

  • DKIM — a TXT record that lets us cryptographically sign your mail.
  • MAIL FROM — a custom return-path subdomain for SPF alignment.
  • DMARC — a policy record that tells receivers what to do with unaligned mail.

Verification is continuous, not one-shot. A domain is Verified, Degraded, or Unverified, and we keep re-checking the records over time.

Degraded still sends

You can send from a Verified or Degraded domain. Degraded means some records drifted but mail still flows — fix the flagged records to return to Verified. You cannot send from an Unverified domain.

Opted-in mail only

Unitpost is for transactional mail and people who opted in. Cold outreach isn't allowed, B2B included. See the Acceptable Use Policy.

Open & click tracking

Control engagement tracking per send, template, or category.

Open this guide on its own page

With tracking on, we inject a 1×1 open pixel and rewrite links so opens and clicks land in the email's event timeline and feed the engagement rates on your dashboard.

How the default is resolved

Tracking is resolved per send in a clear order, most specific first:

  1. An explicit tracking flag on the request wins.
  2. Otherwise the referenced template's default applies.
  3. Otherwise the category default applies.

Sending a receipt or password reset?

Set tracking to false to deliver a clean, untracked transactional message. Many providers and recipients prefer no pixel on sensitive mail.

Bounces, complaints & suppression

How bad addresses are handled and where to review them.

Open this guide on its own page

When an address hard bounces or complains, we add it to your suppression list. It's enforced on every send (single, batch, and campaign), including CC and BCC recipients.

  • Hard bounce — the address is invalid or rejected permanently; it's suppressed (reason: bounced).
  • Complaint — the recipient marked the mail as spam; it's suppressed (reason: complained).
  • Soft bounce — a temporary failure (full mailbox, greylisting); delivery is retried and the address isn't suppressed. Three soft bounces in a row suppress it (reason: bounced), and so does one to a domain that doesn't exist or accepts no mail.

Suppressed recipients are excluded, not failed — surfaced as a "Recipients excluded" event on the timeline. Review the list on the Suppressions tab. You can remove an address there to make it mailable again — but re-sending to bad addresses hurts deliverability.

Suppression is separate from marketing unsubscribe

The suppression list is distinct from a contact's marketing preference. An address can be unsubscribed from marketing but mailable for transactional sends, or suppressed outright. Manage blocks on the Suppressions page; manage preferences on the contact.

Before a big send

Before a campaign goes out, the Send dialog (and warnings[] in the validate response of the campaigns API) may show a few warnings. They never block the send; they point at the habits that most often get mail throttled or filtered. A scheduled campaign also warns when your scheduled sends may outrun the credits left this period, since a campaign that runs short pauses until you top up.

New domains and sudden volume

Mailbox providers judge a sending domain by its history. A domain that verified recently, or has never sent, has none, and a large first send looks like a bulk or compromised sender. Start with your most engaged contacts, send a few hundred emails on day one, and grow the volume over about a week. The same goes for a send far larger than your usual days: split it into a few sends over several days.

DMARC

Gmail and Yahoo require a DMARC record for bulk senders. Without one, more of your mail lands in spam or is refused. Add the DMARC record shown on your domain page; a policy of p=none is enough to start.

Sending frequency

Mailing the same people several times in a few days drives unsubscribes and spam complaints, which hurt every later send. Space marketing campaigns out, and leave out recipients who just heard from you.

The send time

A campaign's saved send time is armed when you press Send. If that time has already passed, Send sends the campaign right away, and the Send dialog (and warnings[] in the validate response) says so first. To schedule it instead, set a new time (Edit, or scheduled_at on the API's Send). Times are stored and returned in UTC; the API takes ISO 8601 with Z or an offset such as -07:00.

Topics and unsubscribes

Without a subscription topic, anyone who unsubscribes from a campaign stops getting all of your marketing email. Scope recurring mail (a newsletter, product updates) to a topic so people can leave just that one and keep the rest, which keeps more of your list reachable.

Use your own unsubscribe on lifecycle email

Onboarding sequences, digests and alerts you render yourself and send as html/text are not marketing-category sends: we add no footer and no unsubscribe header to them. If you run your own unsubscribe page and list, put your headers on the send and Gmail shows its native Unsubscribe button (it is built from the List-Unsubscribe header, not from a link in the body — which is also what Google Postmaster Tools checks for one-click). It works on every plan, right away: no setting to turn on and nothing to request. The same headers work on a single send, each batch item, SMTP, and the MCP email.send tool.

  • List-Unsubscribe — one or more <…> entries separated by commas, each an https:// URL or a mailto: address. A mailto-only header is fine. Plain http://, other schemes and non-ASCII characters are refused with a 422: percent-encode non-ASCII characters in the URL and write an international domain in its punycode (xn--…) form. Keep the header to 870 characters or fewer.
  • List-Unsubscribe-Post — exactly List-Unsubscribe=One-Click, for one-click. It needs at least one https:// entry in List-Unsubscribe (the inbox POSTs to it); mailto alone can't take a one-click.
  • Set each header once. Header names are case-insensitive, so List-Unsubscribe and list-unsubscribe on the same send is refused.
  • Keep an unsubscribe link in the body too: inboxes that don't render the header still need one. Our content review reads the header the way it reads the body; it never holds or slows a send because of the header.
One-click (https + mailto)
curl https://www.unitpost.com/api/v1/email \
  -H "Authorization: Bearer $UNITPOST_API_KEY" \
  -H "Content-Type: application/json" \
  -H "User-Agent: my-app/1.0" \
  -d '{
    "from": "Jobs <jobs@mail.acme.com>",
    "to": "ada@example.com",
    "subject": "Your weekly digest",
    "html": "<p>New jobs for you. <a href=\"https://acme.com/u/abc\">Unsubscribe</a></p>",
    "headers": {
      "List-Unsubscribe": "<https://acme.com/u/abc>, <mailto:unsub@acme.com>",
      "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
    }
  }'
Over SMTP (Nodemailer)
await transporter.sendMail({
  from: "Jobs <jobs@mail.acme.com>",
  to: "ada@example.com",
  subject: "Your weekly digest",
  html: digestHtml,
  headers: {
    "List-Unsubscribe": "<https://acme.com/u/abc>, <mailto:unsub@acme.com>",
    "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
  },
});

Over SMTP the two headers are read from the message itself. Mail libraries fold long header lines on their own; that's fine, we unfold them before checking.

Mailto only
curl https://www.unitpost.com/api/v1/email \
  -H "Authorization: Bearer $UNITPOST_API_KEY" \
  -H "Content-Type: application/json" \
  -H "User-Agent: my-app/1.0" \
  -d '{
    "from": "Jobs <jobs@mail.acme.com>",
    "to": "ada@example.com",
    "subject": "Your weekly digest",
    "text": "Jobs for you this week. Reply to this message to unsubscribe.",
    "headers": { "List-Unsubscribe": "<mailto:unsub@acme.com?subject=unsubscribe>" }
  }'

Marketing templates keep our unsubscribe

A send from a marketing-category template refuses these two headers (422): Unitpost adds its own per-recipient List-Unsubscribe and footer there, and records the opt-out on the contact. Your own headers are for mail whose unsubscribe you handle yourself — those opt-outs land on your endpoint and your list, not on the contact. Opt-outs recorded with us still don't block transactional sends.

When a campaign pauses itself

We watch every email campaign while it sends. If too many of its addresses don't exist, or too many recipients mark it as spam, we pause that campaign, not your account: your password resets, receipts and other campaigns keep sending. The campaign page shows what happened in plain numbers, and the campaign.paused webhook carries the same facts.

SignalJudged once there areWarning atPaused at
Addresses that don't exist (hard bounces), per message sent5 hard bounces—10%
Addresses that don't exist (hard bounces), per message sent10 hard bounces2%5%
Spam complaints, per message delivered3 complaints0.05%0.1%

Small numbers get a margin: we act only when we're 95% sure the real rate is at or above the line. So 3 bounces out of 10 sends doesn't pause yet, but 5 out of 20 does, and so do 98 out of 1,400. Temporary failures (a full mailbox, greylisting) never count. While the platform's overall bounce rate is high, the 5% line tightens to 3%. Our delivery reviewer, Unit, can also pause a campaign earlier when the pattern is clearly bad (for example, most of the first sends bouncing). It can only add caution; it never keeps a campaign sending past these lines. We may also tighten these lines for a while, for example during an incident; the campaign page and the webhook always show the line that applied.

  • The first time, you can resume it yourself: download or remove the affected addresses (they're already suppressed), confirm the list was opted in and cleaned, and resume. Only sends made after you resume count toward the lines, so old bounces won't pause it again.
  • A repeat needs a quick review by our team: this campaign was paused automatically before (at any time), or another of your campaigns was in the last 90 days. Resume answers 409 review_required and the page offers Request review. A third goes to an account review.
  • Cancel the rest stops the campaign for good; nothing more is sent.

In the API, a paused campaign carries pause_reason: a code (hard_bounce_rate, complaint_rate, quality_review, or a non-quality reason such as user, billing or failure_streak), the plain message, and for an automatic quality pause the metric, value, threshold, sample, window, decided_by and resume (self_serve, review_required or account_review).

Get these as webhooks

Every lifecycle event is available in-app today, and you can also have us POST them to your own endpoint in real time. See the Webhooks guide to register an endpoint and verify the signed payloads.

Emails

Send, schedule, retrieve, and cancel transactional email. Send one email or a batch of up to 100, track each message's delivery lifecycle, and pull aggregate deliverability stats for your workspace.

List sent emails

GET/api/v1/email

GET /email
curl -X GET 'https://www.unitpost.com/api/v1/email' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "object": "email",
      "id": "id_123",
      "to": [
        "customer@example.com"
      ],
      "from": "you@yourdomain.com",
      "subject": "Welcome to Acme",
      "template_id": "template_123",
      "campaign_id": "campaign_123",
      "status": "scheduled",
      "last_event": "string",
      "scheduled_at": "2026-01-01T00:00:00.000Z",
      "sent_at": "2026-01-01T00:00:00.000Z",
      "delivered_at": "2026-01-01T00:00:00.000Z",
      "opened_at": "2026-01-01T00:00:00.000Z",
      "clicked_at": "2026-01-01T00:00:00.000Z",
      "canceled_at": "2026-01-01T00:00:00.000Z",
      "last_error": "string",
      "created_at": "2026-01-01T00:00:00.000Z"
    }
  ]
}

Retrieve a list of emails your workspace has sent or scheduled. Results are newest-first and cursor-paginated, and each row is a reference to a single email (status and lifecycle timestamps, not the rendered body). Narrow the list with the status, campaign_id, batch_id, and created_after/created_before filters. Use an email's id with Retrieve an email to read its full record, or Cancel or reschedule an email to change a scheduled send. For aggregate deliverability numbers over a window, see Email deliverability stats. Requires the emails:read capability.

Parameters8 fields
FieldTypeDescription
limitinteger · queryMax rows to return (default 20, max 100).
afterstring · queryReturn rows after this id (forward paging).
beforestring · queryReturn rows before this id (backward paging).
statusstring · queryFilter by send status.
campaign_idstring · queryOnly emails produced by this campaign's fan-out.
batch_idstring · queryOnly emails sent as part of this batch.
created_afterstring · queryOnly emails created at/after this ISO 8601 time.
created_beforestring · queryOnly emails created at/before this ISO 8601 time.
Response · 200 fields17 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
torequiredstring[]—
fromrequiredstring—
subjectrequiredstring—
template_idrequiredstring | null—
campaign_idrequiredstring | null—
statusrequiredenumscheduled | queued | sent | delivered | bounced | complained | canceled | failed—
last_eventrequiredstring—
scheduled_atrequiredstring | null—
sent_atrequiredstring | null—
delivered_atrequiredstring | null—
opened_atrequiredstring | null—
clicked_atrequiredstring | null—
canceled_atrequiredstring | null—
last_errorrequiredstring | null—
created_atrequiredstring—
200401403422

Send or schedule an email

POST/api/v1/email

POST /email
curl -X POST 'https://www.unitpost.com/api/v1/email' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0' \
  -H 'Content-Type: application/json' \
  -d '{
    "from": "you@yourdomain.com",
    "to": "customer@example.com",
    "subject": "Welcome to Acme",
    "html": "<h1>Hello</h1>"
  }'
Response · 200
{
  "id": "id_123",
  "status": "queued",
  "scheduled_at": "2026-01-01T00:00:00.000Z"
}

Send a single transactional email — now, or at a future time by passing scheduled_at (ISO 8601 with Z or an offset, normalized to UTC; up to 90 days ahead, fewer on some plans). A scheduled_at in the past or under 30 seconds ahead sends immediately: check that the response status is scheduled. Provide the content inline as html/text, or reference a saved template with template (create one with Create a template). Pass an Idempotency-Key header to make retries safe: the same key replays the original result instead of sending twice. The response returns the new email's id — read its delivery status back with Retrieve an email, change a scheduled send with Cancel or reschedule an email, or send many at once with Send a batch of emails. A template in the marketing category sends to one recipient (no cc/bcc) and adds them to your contacts if they aren't one yet, because the unsubscribe link belongs to a contact: the new contact counts toward a Free plan's contact limit (at the limit the send is refused) and fires contact.created with source: "send". Requires the emails:send capability.

Parameters1 field
FieldTypeDescription
Idempotency-Keystring · headerAny unique string (1–256 characters, e.g. a UUID). Retrying with the same key and the same body within 24 hours returns the first result instead of sending again; the same key with a different body is `409 invalid_idempotent_request`, and one still in flight is `409 concurrent_idempotent_requests`.
Request body16 fields
FieldTypeDescription
fromrequiredstringSender address on one of your verified domains. Accepts a bare address ("hi@acme.com") or a display name ("Acme <hi@acme.com>") — the name is what inbox clients show next to the message.
torequiredstring | string[]—
subjectstring—
ccstring | string[]—
bccstring | string[]—
reply_tostring | string[]—
in_reply_tostringId of a received email (`inb_…`) this send replies to: Unitpost sets In-Reply-To / References and prefixes `Re:`. Not a Message-ID — a raw header goes in `headers`.
headersobjectCustom headers (your own `X-*` and threading headers). Envelope (`From`/`To`/`Cc`/`Bcc`/`Reply-To`/`Subject`/`Date`/`Message-ID`) and signing/transport headers are managed for you and rejected with 422. On a non-marketing send you may set your own `List-Unsubscribe` (one or more `<…>` entries, each `https://` or `mailto:`) and `List-Unsubscribe-Post` (exactly `List-Unsubscribe=One-Click`, which needs at least one `https://` entry). A marketing-template send refuses both: Unitpost adds its own per recipient.
tagsobject[]—
templateobjectPublished template plus variable values. Mutually exclusive with `html` and `text`.
htmlstringHTML MIME body. It is sanitized for email safety and may be supplied with `text` to create a multipart alternative. This field does not parse Markdown.
textstringPlain-text MIME body. Newline characters are preserved; Markdown and rich-text styling are not interpreted. It may be supplied with `html` as the fallback alternative. When `html` is sent without `text`, a plain-text alternative is derived from the visible HTML content; an explicit `text` value is always sent unchanged.
attachmentsobject[]—
open_trackingboolean—
click_trackingboolean—
scheduled_atstringFuture send time: ISO 8601 with `Z` or a `±hh:mm` offset, normalized to UTC (reads return UTC). Up to 90 days ahead, fewer on some plans. A time in the past or under 30 seconds ahead sends immediately, so check the response `status`.
Response · 200 fields3 fields
FieldTypeDescription
idrequiredstring—
statusrequiredenumqueued | scheduled—
scheduled_atrequiredstring—
200400401403404409422429

Send a batch of emails

POST/api/v1/email/batch

POST /email/batch
curl -X POST 'https://www.unitpost.com/api/v1/email/batch' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0' \
  -H 'Content-Type: application/json' \
  -d '{
    "emails": [
      {
        "from": "you@yourdomain.com",
        "to": "a@example.com",
        "subject": "Hi A",
        "html": "<p>Hi A</p>"
      },
      {
        "from": "you@yourdomain.com",
        "to": "b@example.com",
        "subject": "Hi B",
        "html": "<p>Hi B</p>"
      }
    ]
  }'
Response · 200
{
  "object": "list",
  "batch_id": "batch_123",
  "status": "queued",
  "scheduled_at": "2026-01-01T00:00:00.000Z",
  "data": [
    {
      "id": "id_123"
    }
  ],
  "failed": [
    {
      "index": 0,
      "error": "string"
    }
  ]
}

Send up to 100 transactional emails in a single request. Validation is all-or-nothing: if any item is invalid the whole batch is rejected and nothing is queued, so you never end up with a partial send. To schedule, use the object form { emails, scheduled_at } for the whole batch: an item's own scheduled_at is ignored, so that item goes out with the batch (now, unless the batch is scheduled). An item with attachments is rejected (422). Pass an Idempotency-Key header to make retries safe — the same key replays the original result instead of re-sending. The response returns a batch_id; track the batch's per-status rollup with Retrieve a batch and stop the rest of it with Cancel a batch. To send one email, use Send or schedule an email. Requires the emails:send capability.

Parameters1 field
FieldTypeDescription
Idempotency-Keystring · headerAny unique string (1–256 characters, e.g. a UUID). Retrying with the same key and the same body within 24 hours returns the first result instead of sending again; the same key with a different body is `409 invalid_idempotent_request`, and one still in flight is `409 concurrent_idempotent_requests`.
Response · 200 fields1 field
FieldTypeDescription
idrequiredstring—
200400401403409422429

Retrieve a batch

GET/api/v1/email/batches/{id}

GET /email/batches/{id}
curl -X GET 'https://www.unitpost.com/api/v1/email/batches/batch_123' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "object": "batch",
  "id": "id_123",
  "count": 1,
  "scheduled_at": "2026-01-01T00:00:00.000Z",
  "created_at": "2026-01-01T00:00:00.000Z",
  "status_counts": {},
  "cancelable": 0
}

Retrieve a batch and its live, per-status rollup (how many of its messages are queued, sent, delivered, bounced, and so on). The counts update as the batch progresses. Create a batch with Send a batch of emails, or stop its remaining messages with Cancel a batch. Requires the emails:read capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Response · 200 fields7 fields
FieldTypeDescription
objectrequiredenumbatch—
idrequiredstring—
countrequiredinteger—
scheduled_atrequiredstring—
created_atrequiredstring—
status_countsrequiredobjectLive child counts keyed by status (e.g. { delivered: 98, failed: 2 }).
cancelablerequiredinteger—
200401403404

Cancel a batch

POST/api/v1/email/batches/{id}/cancel

POST /email/batches/{id}/cancel
curl -X POST 'https://www.unitpost.com/api/v1/email/batches/batch_123/cancel' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "object": "batch",
  "id": "id_123",
  "count": 1,
  "scheduled_at": "2026-01-01T00:00:00.000Z",
  "created_at": "2026-01-01T00:00:00.000Z",
  "status_counts": {},
  "cancelable": 0
}

Cancel every still-cancelable message in a batch in one call. Messages that have already been sent are left untouched — only queued or scheduled ones are stopped. Check what remains cancelable first with Retrieve a batch. Requires the emails:send capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Response · 200 fields7 fields
FieldTypeDescription
objectrequiredenumbatch—
idrequiredstring—
countrequiredinteger—
scheduled_atrequiredstring—
created_atrequiredstring—
status_countsrequiredobjectLive child counts keyed by status (e.g. { delivered: 98, failed: 2 }).
cancelablerequiredinteger—
200401403404409

Retrieve an email

GET/api/v1/email/{id}

GET /email/{id}
curl -X GET 'https://www.unitpost.com/api/v1/email/email_123' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "object": "email",
  "id": "id_123",
  "to": [
    "customer@example.com"
  ],
  "from": "you@yourdomain.com",
  "subject": "Welcome to Acme",
  "template_id": "template_123",
  "campaign_id": "campaign_123",
  "status": "scheduled",
  "last_event": "string",
  "scheduled_at": "2026-01-01T00:00:00.000Z",
  "sent_at": "2026-01-01T00:00:00.000Z",
  "delivered_at": "2026-01-01T00:00:00.000Z",
  "opened_at": "2026-01-01T00:00:00.000Z",
  "clicked_at": "2026-01-01T00:00:00.000Z",
  "canceled_at": "2026-01-01T00:00:00.000Z",
  "last_error": "string",
  "created_at": "2026-01-01T00:00:00.000Z"
}

Retrieve a single email by id, including its current send status and lifecycle timestamps (created, scheduled, sent, delivered, and so on). Find an id by browsing List sent emails. Messages submitted over **SMTP** can also be retrieved by the RFC 5322 Message-ID your mail library stamped on them (URL-encode it, e.g. /email/%3Cabc%40example.com%3E; angle brackets optional) — the id in the SMTP 250 Queued as … reply works too. To change a scheduled or queued email, use Cancel or reschedule an email. Requires the emails:read capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Response · 200 fields17 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
torequiredstring[]—
fromrequiredstring—
subjectrequiredstring—
template_idrequiredstring | null—
campaign_idrequiredstring | null—
statusrequiredenumscheduled | queued | sent | delivered | bounced | complained | canceled | failed—
last_eventrequiredstring—
scheduled_atrequiredstring | null—
sent_atrequiredstring | null—
delivered_atrequiredstring | null—
opened_atrequiredstring | null—
clicked_atrequiredstring | null—
canceled_atrequiredstring | null—
last_errorrequiredstring | null—
created_atrequiredstring—
200401403404

Cancel or reschedule an email

PATCH/api/v1/email/{id}

PATCH /email/{id}
curl -X PATCH 'https://www.unitpost.com/api/v1/email/email_123' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0' \
  -H 'Content-Type: application/json' \
  -d '{
    "scheduled_at": null
  }'
Response · 200
{
  "object": "email",
  "id": "id_123",
  "to": [
    "customer@example.com"
  ],
  "from": "you@yourdomain.com",
  "subject": "Welcome to Acme",
  "template_id": "template_123",
  "campaign_id": "campaign_123",
  "status": "scheduled",
  "last_event": "string",
  "scheduled_at": "2026-01-01T00:00:00.000Z",
  "sent_at": "2026-01-01T00:00:00.000Z",
  "delivered_at": "2026-01-01T00:00:00.000Z",
  "opened_at": "2026-01-01T00:00:00.000Z",
  "clicked_at": "2026-01-01T00:00:00.000Z",
  "canceled_at": "2026-01-01T00:00:00.000Z",
  "last_error": "string",
  "created_at": "2026-01-01T00:00:00.000Z"
}

Cancel a scheduled or queued email, or move it to a new send time. Only emails that haven't been handed off for delivery yet can be changed — once an email is sent this returns 409. Look up the email's current state first with Retrieve an email. Requires the emails:send capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Request body2 fields
FieldTypeDescription
cancelboolean—
scheduled_atstring—
Response · 200 fields17 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
torequiredstring[]—
fromrequiredstring—
subjectrequiredstring—
template_idrequiredstring | null—
campaign_idrequiredstring | null—
statusrequiredenumscheduled | queued | sent | delivered | bounced | complained | canceled | failed—
last_eventrequiredstring—
scheduled_atrequiredstring | null—
sent_atrequiredstring | null—
delivered_atrequiredstring | null—
opened_atrequiredstring | null—
clicked_atrequiredstring | null—
canceled_atrequiredstring | null—
last_errorrequiredstring | null—
created_atrequiredstring—
200401403404409422

Email deliverability stats

GET/api/v1/email/stats

GET /email/stats
curl -X GET 'https://www.unitpost.com/api/v1/email/stats' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "windowDays": 0,
  "clampedToRetentionDays": 0,
  "totals": {
    "sent": 0,
    "delivered": 0,
    "opened": 0,
    "clicked": 0,
    "bounced": 0,
    "complained": 0,
    "failed": 0
  },
  "rates": {
    "delivery": 0,
    "open": 0,
    "click": 0,
    "bounce": 0,
    "complaint": 0
  }
}

Retrieve aggregate deliverability and engagement metrics for your workspace over a rolling window. You get totals (sent, delivered, opened, clicked, bounced, complained, failed) alongside the rates that matter — delivery, open, click, bounce, and complaint. Open and click rates count only emails where that tracking was enabled, so turning tracking off for transactional mail won't skew them. Control the window with the days parameter (1–365, default 30). For the individual emails behind these numbers, see List sent emails. Requires the emails:read capability.

Parameters1 field
FieldTypeDescription
daysinteger · queryWindow size in days (1–365). Defaults to 30; values above 365 are rejected.
Response · 200 fields4 fields
FieldTypeDescription
windowDaysrequiredintegerThe rolling window, in days.
clampedToRetentionDaysintegerNon-null when the requested window exceeded the plan's log retention and was clamped to this many days (data past the retention horizon is purged).
totalsrequiredobject—
ratesrequiredobjectPercentages (one decimal). delivery/bounce/complaint are vs sent; open/click are vs delivered.
200401403422

Inbound emails

Read email your receiving-enabled domains have received — list messages, retrieve full bodies, and download attachments via short-lived signed URLs.

List received emails

GET/api/v1/email/received

GET /email/received
curl -X GET 'https://www.unitpost.com/api/v1/email/received' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "object": "received_email",
      "id": "id_123",
      "from": "you@yourdomain.com",
      "from_name": "string",
      "to": [
        "customer@example.com"
      ],
      "cc": [
        "string"
      ],
      "bcc": [
        "string"
      ],
      "message_id": "message_123",
      "subject": "Welcome to Acme",
      "spam_verdict": "string",
      "virus_verdict": "string",
      "spf_verdict": "string",
      "dkim_verdict": "string",
      "dmarc_verdict": "string",
      "size": 1,
      "received_at": "2026-01-01T00:00:00.000Z",
      "created_at": "2026-01-01T00:00:00.000Z",
      "attachment_count": 1
    }
  ]
}

Retrieve a list of inbound emails received on your receiving-enabled domains, newest-first and cursor-paginated. Rows are summaries (sender, subject, timestamps) without bodies — use a message's id with Retrieve a received email to read the full content and attachment metadata. Requires the emails:read capability.

Parameters3 fields
FieldTypeDescription
limitinteger · queryMax rows to return (default 20, max 100).
afterstring · queryReturn rows after this id (forward paging).
beforestring · queryReturn rows before this id (backward paging).
Response · 200 fields18 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
fromrequiredstring—
from_namerequiredstring | null—
torequiredstring[]—
ccrequiredstring[]—
bccrequiredstring[]—
message_idrequiredstring | null—
subjectrequiredstring | null—
spam_verdictrequiredstring | null—
virus_verdictrequiredstring | null—
spf_verdictrequiredstring | null—
dkim_verdictrequiredstring | null—
dmarc_verdictrequiredstring | null—
sizerequirednumber—
received_atrequiredstring—
created_atrequiredstring—
attachment_countrequirednumber—
200401403422

Retrieve a received email

GET/api/v1/email/received/{id}

GET /email/received/{id}
curl -X GET 'https://www.unitpost.com/api/v1/email/received/email_123' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "object": "received_email",
  "id": "id_123",
  "from": "you@yourdomain.com",
  "from_name": "string",
  "to": [
    "customer@example.com"
  ],
  "cc": [
    "string"
  ],
  "bcc": [
    "string"
  ],
  "message_id": "message_123",
  "subject": "Welcome to Acme",
  "text": "string",
  "html": "string",
  "spam_verdict": "string",
  "virus_verdict": "string",
  "spf_verdict": "string",
  "dkim_verdict": "string",
  "dmarc_verdict": "string",
  "size": 1,
  "attachments": [
    {
      "object": "received_attachment",
      "id": "id_123",
      "filename": "string",
      "content_type": "string",
      "content_disposition": "string",
      "content_id": "content_123",
      "size": 1
    }
  ],
  "received_at": "2026-01-01T00:00:00.000Z",
  "created_at": "2026-01-01T00:00:00.000Z"
}

Retrieve a full inbound message by id, including its HTML and text bodies plus metadata for every attachment. Attachment bytes aren't inlined — download each one via Get an attachment download URL. Browse your inbound mail with List received emails. Requires the emails:read capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Response · 200 fields20 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
fromrequiredstring—
from_namerequiredstring | null—
torequiredstring[]—
ccrequiredstring[]—
bccrequiredstring[]—
message_idrequiredstring | null—
subjectrequiredstring | null—
textrequiredstring | null—
htmlrequiredstring | null—
spam_verdictrequiredstring | null—
virus_verdictrequiredstring | null—
spf_verdictrequiredstring | null—
dkim_verdictrequiredstring | null—
dmarc_verdictrequiredstring | null—
sizerequirednumber—
attachmentsrequiredobject[]—
received_atrequiredstring—
created_atrequiredstring—
200401403404

Get an attachment download URL

GET/api/v1/email/received/{id}/attachments/{attachmentId}

GET /email/received/{id}/attachments/{attachmentId}
curl -X GET 'https://www.unitpost.com/api/v1/email/received/email_123/attachments/att_789' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "object": "received_attachment_url",
  "url": "https://example.com",
  "expires_at": "2026-01-01T00:00:00.000Z"
}

Get a short-lived, signed URL for downloading one inbound attachment's bytes. Fetch the URL and download promptly — it expires quickly (the expiry is included in the response). Find attachment ids on the message via Retrieve a received email. Requires the emails:read capability.

Parameters2 fields
FieldTypeDescription
idrequiredstring · path—
attachmentIdrequiredstring · path—
Response · 200 fields3 fields
FieldTypeDescription
objectrequiredenumreceived_attachment_url—
urlrequiredstringA signed, expiring URL to download the attachment bytes.
expires_atrequiredstringISO 8601 expiry of the signed URL (~5 minutes out).
200401403404503

Topics

Subscription categories (e.g. Promotions) recipients can opt out of without leaving your list, independent of segment membership. Scope a campaign to a topic and opted-out contacts are skipped automatically.

List topics

GET/api/v1/email/topics

GET /email/topics
curl -X GET 'https://www.unitpost.com/api/v1/email/topics' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "object": "topic",
      "id": "id_123",
      "name": "Example",
      "description": "string",
      "channel": "email",
      "default_opt_in": false,
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ]
}

Retrieve a list of your subscription topics, cursor-paginated. A topic is a category of email (e.g. Product updates, Promotions) that recipients can opt out of without leaving your list entirely. Pass ?archived=true to list archived topics instead. Create one with Create a topic, or check a contact's preferences with List a contact's topic subscriptions. Requires the topics:read capability.

Parameters4 fields
FieldTypeDescription
limitinteger · queryMax rows to return (default 20, max 100).
afterstring · queryReturn rows after this id (forward paging).
beforestring · queryReturn rows before this id (backward paging).
archivedboolean · queryWhen true, list only archived topics instead of active ones.
Response · 200 fields8 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
namerequiredstring—
descriptionrequiredstring | null—
channelrequiredenumemail | smsWhich channel the topic belongs to. Email topics are managed under `/email/topics`, SMS topics under `/sms/topics`.
default_opt_inrequiredboolean—
created_atrequiredstring—
updated_atrequiredstring—
200401422

Create a topic

POST/api/v1/email/topics

POST /email/topics
curl -X POST 'https://www.unitpost.com/api/v1/email/topics' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Product updates",
    "default_opt_in": true
  }'
Response · 201
{
  "object": "topic",
  "id": "id_123",
  "name": "Example",
  "description": "string",
  "channel": "email",
  "default_opt_in": false,
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}

Create a subscription topic — a category recipients can opt out of on their own (e.g. Promotions) while staying on your list. default_opt_in decides whether contacts with no explicit preference are treated as subscribed. Scope a campaign to a topic so opted-out contacts are skipped automatically via Create a campaign, and set an individual contact's preference with Set a contact's topic preference. Topics differ from segments: a segment decides who a campaign targets, a topic lets recipients opt out. Requires the topics:write capability.

Request body3 fields
FieldTypeDescription
namerequiredstring—
descriptionstring—
default_opt_inboolean—
Response · 201 fields8 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
namerequiredstring—
descriptionrequiredstring | null—
channelrequiredenumemail | smsWhich channel the topic belongs to. Email topics are managed under `/email/topics`, SMS topics under `/sms/topics`.
default_opt_inrequiredboolean—
created_atrequiredstring—
updated_atrequiredstring—
201401409422

Retrieve a topic

GET/api/v1/email/topics/{id}

GET /email/topics/{id}
curl -X GET 'https://www.unitpost.com/api/v1/email/topics/top_123' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "object": "topic",
  "id": "id_123",
  "name": "Example",
  "description": "string",
  "channel": "email",
  "default_opt_in": false,
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}

Retrieve a single topic by id, including its name and default_opt_in setting. Change it with Update a topic, or list every topic with List topics. Requires the topics:read capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Response · 200 fields8 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
namerequiredstring—
descriptionrequiredstring | null—
channelrequiredenumemail | smsWhich channel the topic belongs to. Email topics are managed under `/email/topics`, SMS topics under `/sms/topics`.
default_opt_inrequiredboolean—
created_atrequiredstring—
updated_atrequiredstring—
200401404

Update a topic

PATCH/api/v1/email/topics/{id}

PATCH /email/topics/{id}
curl -X PATCH 'https://www.unitpost.com/api/v1/email/topics/top_123' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0' \
  -H 'Content-Type: application/json' \
  -d '{ }'
Response · 200
{
  "object": "topic",
  "id": "id_123",
  "name": "Example",
  "description": "string",
  "channel": "email",
  "default_opt_in": false,
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}

Update a topic's name or default_opt_in behavior, or pass { "archived": true | false } to archive or restore it. Archiving a topic hides it and stops it from being targeted, but is blocked with 409 while an active campaign still targets it. To manage a single contact's preference, use Set a contact's topic preference. Requires the topics:write capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Request body3 fields
FieldTypeDescription
namestring—
descriptionstring | null—
default_opt_inboolean—
Response · 200 fields8 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
namerequiredstring—
descriptionrequiredstring | null—
channelrequiredenumemail | smsWhich channel the topic belongs to. Email topics are managed under `/email/topics`, SMS topics under `/sms/topics`.
default_opt_inrequiredboolean—
created_atrequiredstring—
updated_atrequiredstring—
200401404409422

Delete a topic

DELETE/api/v1/email/topics/{id}

DELETE /email/topics/{id}
curl -X DELETE 'https://www.unitpost.com/api/v1/email/topics/top_123' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'

Archive a topic (a soft delete — existing subscription records are preserved so you can restore it later). It's blocked with 409 while an active campaign still targets it. Restore an archived topic by sending { "archived": false } to Update a topic. Requires the topics:write capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
204401404409

List a contact's topic subscriptions

GET/api/v1/contacts/{id}/topics

GET /contacts/{id}/topics
curl -X GET 'https://www.unitpost.com/api/v1/contacts/con_123/topics' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "object": "contact_topic",
      "topic_id": "topic_123",
      "channel": "email",
      "subscribed": false,
      "status": "subscribed",
      "source": "user"
    }
  ]
}

Retrieve a contact's effective subscription state for every active topic, cursor-paginated. Each row resolves the contact's explicit preference against the topic's default_opt_in, so subscribed is always the answer to "would this contact receive a campaign scoped to this topic?". Change a preference with Set a contact's topic preference. Requires the topics:read capability.

Parameters4 fields
FieldTypeDescription
idrequiredstring · path—
limitinteger · queryMax rows to return (default 20, max 100).
afterstring · queryReturn rows after this id (forward paging).
beforestring · queryReturn rows before this id (backward paging).
Response · 200 fields6 fields
FieldTypeDescription
objectrequiredstring—
topic_idrequiredstring—
channelrequiredenumemail | smsThe topic's channel: manage it under `/email/topics` or `/sms/topics`.
subscribedrequiredboolean—
statusrequiredenumsubscribed | unsubscribed—
sourcerequiredenumuser | manual | import | api—
200401404422

Set a contact's topic preference

POST/api/v1/contacts/{id}/topics

POST /contacts/{id}/topics
curl -X POST 'https://www.unitpost.com/api/v1/contacts/con_123/topics' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0' \
  -H 'Content-Type: application/json' \
  -d '{
    "topic_id": "top_123",
    "subscribed": false
  }'
Response · 201
{
  "object": "contact_topic",
  "topic_id": "topic_123",
  "channel": "email",
  "subscribed": false,
  "status": "subscribed",
  "source": "user"
}

Subscribe or unsubscribe a contact for one topic. This records an explicit preference that overrides the topic's default_opt_in, and it's idempotent — setting the same preference twice succeeds. PUT behaves identically. Read the contact's current state with List a contact's topic subscriptions. Requires the topics:write capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Request body3 fields
FieldTypeDescription
topic_idstring—
topicstring—
subscribedrequiredboolean—
Response · 201 fields6 fields
FieldTypeDescription
objectrequiredstring—
topic_idrequiredstring—
channelrequiredenumemail | smsThe topic's channel: manage it under `/email/topics` or `/sms/topics`.
subscribedrequiredboolean—
statusrequiredenumsubscribed | unsubscribed—
sourcerequiredenumuser | manual | import | api—
201401404422

Set a contact's topic preference (alias of POST)

PUT/api/v1/contacts/{id}/topics

PUT /contacts/{id}/topics
# API: no generated example for PUT https://www.unitpost.com/api/v1/contacts/:id/topics
Response · 201
{
  "object": "contact_topic",
  "topic_id": "topic_123",
  "channel": "email",
  "subscribed": false,
  "status": "subscribed",
  "source": "user"
}

Identical to Set a contact's topic preference — provided so clients that prefer PUT semantics for an upsert can use it interchangeably. Requires the topics:write capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Request body3 fields
FieldTypeDescription
topic_idstring—
topicstring—
subscribedrequiredboolean—
Response · 201 fields6 fields
FieldTypeDescription
objectrequiredstring—
topic_idrequiredstring—
channelrequiredenumemail | smsThe topic's channel: manage it under `/email/topics` or `/sms/topics`.
subscribedrequiredboolean—
statusrequiredenumsubscribed | unsubscribed—
sourcerequiredenumuser | manual | import | api—
201401404422

Campaigns

One-to-many sends to a segment or an inline recipient list. Draft, validate, schedule, send, pause, resume, and cancel — with a pre-send report that catches problems before anything goes out.

List campaigns

GET/api/v1/email/campaigns

GET /email/campaigns
curl -X GET 'https://www.unitpost.com/api/v1/email/campaigns' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "object": "campaign",
      "id": "id_123",
      "name": "Example",
      "template_id": "template_123",
      "segment_id": "segment_123",
      "topic_id": "topic_123",
      "from": "you@yourdomain.com",
      "subject": "Welcome to Acme",
      "preview_text": "string",
      "open_tracking": false,
      "click_tracking": false,
      "status": "draft",
      "scheduled_at": "2026-01-01T00:00:00.000Z",
      "variable_fallbacks": {},
      "excluded_contact_ids": [
        "string"
      ],
      "total_recipients": 1,
      "projected_recipients": {
        "total": 1,
        "reachable": 0,
        "skipped": 0
      },
      "sent_count": 1,
      "failed_count": 1,
      "pause_reason": {
        "code": "user",
        "message": "string",
        "metric": "hard_bounce_rate",
        "value": 0,
        "threshold": 0,
        "sample": {
          "events": 0,
          "of": 0
        },
        "window": {
          "since": "string"
        },
        "decided_by": "floor",
        "resume": "self_serve",
        "paused_at": "2026-01-01T00:00:00.000Z"
      },
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ]
}

Retrieve a list of your campaigns, cursor-paginated. Each row is a reference to one campaign and its current status (draft, scheduled, sending, sent, …). Use a campaign's id with Retrieve a campaign for the full record, or with List sent emails (?campaign_id=) to see the individual emails it produced. Requires the campaigns:read capability.

Parameters3 fields
FieldTypeDescription
limitinteger · queryMax rows to return (default 20, max 100).
afterstring · queryReturn rows after this id (forward paging).
beforestring · queryReturn rows before this id (backward paging).
Response · 200 fields22 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
namerequiredstring—
template_idrequiredstring—
segment_idrequiredstring—
topic_idrequiredstring | null—
fromrequiredstring—
subjectrequiredstring | null—
preview_textrequiredstring | null—
open_trackingrequiredboolean | null—
click_trackingrequiredboolean | null—
statusrequiredenumdraft | scheduled | sending | paused | sent | canceled | failed—
scheduled_atrequiredstring | null—
variable_fallbacksrequiredobject—
excluded_contact_idsrequiredstring[]—
total_recipientsrequirednumberRecipients the engine admitted when the campaign started sending. 0 while it is `draft` or `scheduled`: read `projected_recipients`, or `sendable` from validate.
projected_recipientsobject | nullThe audience counted when the campaign was sent or scheduled: `total` segment members, `reachable` who will get it, `skipped` (unsubscribed, suppressed, excluded). Null for a draft that was never sent: read validate. Read-only.
sent_countrequirednumber—
failed_countrequirednumber—
pause_reasonrequiredobject | null—
created_atrequiredstring—
updated_atrequiredstring—
200401422

Create a campaign

POST/api/v1/email/campaigns

POST /email/campaigns
curl -X POST 'https://www.unitpost.com/api/v1/email/campaigns' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "June newsletter",
    "subject": "What's new",
    "from": "you@yourdomain.com",
    "template_id": "tmpl_123",
    "segment_id": "seg_123"
  }'
Response · 201
{
  "object": "campaign",
  "id": "id_123",
  "name": "Example",
  "template_id": "template_123",
  "segment_id": "segment_123",
  "topic_id": "topic_123",
  "from": "you@yourdomain.com",
  "subject": "Welcome to Acme",
  "preview_text": "string",
  "open_tracking": false,
  "click_tracking": false,
  "status": "draft",
  "scheduled_at": "2026-01-01T00:00:00.000Z",
  "variable_fallbacks": {},
  "excluded_contact_ids": [
    "string"
  ],
  "total_recipients": 1,
  "projected_recipients": {
    "total": 1,
    "reachable": 0,
    "skipped": 0
  },
  "sent_count": 1,
  "failed_count": 1,
  "pause_reason": {
    "code": "user",
    "message": "string",
    "metric": "hard_bounce_rate",
    "value": 0,
    "threshold": 0,
    "sample": {
      "events": 0,
      "of": 0
    },
    "window": {
      "since": "string"
    },
    "decided_by": "floor",
    "resume": "self_serve",
    "paused_at": "2026-01-01T00:00:00.000Z"
  },
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}

Create a campaign in draft. Target either a saved segment (segment_id, see Create a segment) or an inline recipients list, and optionally scope it to a subscription topic (topic_id) so contacts who opted out of that topic are skipped automatically. Content comes from a published template_id (campaigns have no inline html/text). Set scheduled_at (ISO 8601 with Z or an offset such as 2026-10-27T08:00:00-07:00; stored and returned in UTC; inside your plan's scheduling window, at most 90 days) to save a send time. Without one, or once it has passed, the campaign goes out the moment you send it (validate warns SCHEDULE_PASSED). Nothing is sent yet — edit the draft with Update a campaign, check it with Validate a campaign, then launch it with Send a campaign. Requires the campaigns:write capability.

Request body13 fields
FieldTypeDescription
namerequiredstring—
template_idrequiredstring—
segment_idstring—
recipientsstring[]—
topic_idstring—
fromrequiredstring—
subjectstring—
preview_textstring—
scheduled_atstringISO 8601 with `Z` or a `±hh:mm` offset; normalized to UTC (reads return the UTC time). A time without a timezone, a bare date, or natural language is a `422`.
open_trackingboolean | null—
click_trackingboolean | null—
variable_fallbacksobject—
excluded_contact_idsstring[]—
Response · 201 fields22 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
namerequiredstring—
template_idrequiredstring—
segment_idrequiredstring—
topic_idrequiredstring | null—
fromrequiredstring—
subjectrequiredstring | null—
preview_textrequiredstring | null—
open_trackingrequiredboolean | null—
click_trackingrequiredboolean | null—
statusrequiredenumdraft | scheduled | sending | paused | sent | canceled | failed—
scheduled_atrequiredstring | null—
variable_fallbacksrequiredobject—
excluded_contact_idsrequiredstring[]—
total_recipientsrequirednumberRecipients the engine admitted when the campaign started sending. 0 while it is `draft` or `scheduled`: read `projected_recipients`, or `sendable` from validate.
projected_recipientsobject | nullThe audience counted when the campaign was sent or scheduled: `total` segment members, `reachable` who will get it, `skipped` (unsubscribed, suppressed, excluded). Null for a draft that was never sent: read validate. Read-only.
sent_countrequirednumber—
failed_countrequirednumber—
pause_reasonrequiredobject | null—
created_atrequiredstring—
updated_atrequiredstring—
201401403404409422

Retrieve a campaign

GET/api/v1/email/campaigns/{id}

GET /email/campaigns/{id}
curl -X GET 'https://www.unitpost.com/api/v1/email/campaigns/cmp_123' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "object": "campaign",
  "id": "id_123",
  "name": "Example",
  "template_id": "template_123",
  "segment_id": "segment_123",
  "topic_id": "topic_123",
  "from": "you@yourdomain.com",
  "subject": "Welcome to Acme",
  "preview_text": "string",
  "open_tracking": false,
  "click_tracking": false,
  "status": "draft",
  "scheduled_at": "2026-01-01T00:00:00.000Z",
  "variable_fallbacks": {},
  "excluded_contact_ids": [
    "string"
  ],
  "total_recipients": 1,
  "projected_recipients": {
    "total": 1,
    "reachable": 0,
    "skipped": 0
  },
  "sent_count": 1,
  "failed_count": 1,
  "pause_reason": {
    "code": "user",
    "message": "string",
    "metric": "hard_bounce_rate",
    "value": 0,
    "threshold": 0,
    "sample": {
      "events": 0,
      "of": 0
    },
    "window": {
      "since": "string"
    },
    "decided_by": "floor",
    "resume": "self_serve",
    "paused_at": "2026-01-01T00:00:00.000Z"
  },
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}

Retrieve a single campaign by id, including its status, targeting, content references, and schedule. To see the individual emails a sent campaign produced, filter List sent emails by campaign_id; for a pre-send readiness report, use Validate a campaign. Requires the campaigns:read capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Response · 200 fields22 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
namerequiredstring—
template_idrequiredstring—
segment_idrequiredstring—
topic_idrequiredstring | null—
fromrequiredstring—
subjectrequiredstring | null—
preview_textrequiredstring | null—
open_trackingrequiredboolean | null—
click_trackingrequiredboolean | null—
statusrequiredenumdraft | scheduled | sending | paused | sent | canceled | failed—
scheduled_atrequiredstring | null—
variable_fallbacksrequiredobject—
excluded_contact_idsrequiredstring[]—
total_recipientsrequirednumberRecipients the engine admitted when the campaign started sending. 0 while it is `draft` or `scheduled`: read `projected_recipients`, or `sendable` from validate.
projected_recipientsobject | nullThe audience counted when the campaign was sent or scheduled: `total` segment members, `reachable` who will get it, `skipped` (unsubscribed, suppressed, excluded). Null for a draft that was never sent: read validate. Read-only.
sent_countrequirednumber—
failed_countrequirednumber—
pause_reasonrequiredobject | null—
created_atrequiredstring—
updated_atrequiredstring—
200401404

Update a campaign

PATCH/api/v1/email/campaigns/{id}

PATCH /email/campaigns/{id}
curl -X PATCH 'https://www.unitpost.com/api/v1/email/campaigns/cmp_123' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0' \
  -H 'Content-Type: application/json' \
  -d '{ }'
Response · 200
{
  "object": "campaign",
  "id": "id_123",
  "name": "Example",
  "template_id": "template_123",
  "segment_id": "segment_123",
  "topic_id": "topic_123",
  "from": "you@yourdomain.com",
  "subject": "Welcome to Acme",
  "preview_text": "string",
  "open_tracking": false,
  "click_tracking": false,
  "status": "draft",
  "scheduled_at": "2026-01-01T00:00:00.000Z",
  "variable_fallbacks": {},
  "excluded_contact_ids": [
    "string"
  ],
  "total_recipients": 1,
  "projected_recipients": {
    "total": 1,
    "reachable": 0,
    "skipped": 0
  },
  "sent_count": 1,
  "failed_count": 1,
  "pause_reason": {
    "code": "user",
    "message": "string",
    "metric": "hard_bounce_rate",
    "value": 0,
    "threshold": 0,
    "sample": {
      "events": 0,
      "of": 0
    },
    "window": {
      "since": "string"
    },
    "decided_by": "floor",
    "resume": "self_serve",
    "paused_at": "2026-01-01T00:00:00.000Z"
  },
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}

Edit a campaign while it's still in draft (content edits after that return 409). Beyond content and targeting, you can: send archived to archive/restore it; set variable_fallbacks (fill-only per-variable defaults) and/or excluded_contact_ids to resolve a send blocked by missing variables — the API equivalent of the dashboard's pre-send fix; set topic_id to scope it to a subscription topic (or null to clear); set open_tracking/click_tracking (tri-state; null inherits the template/domain default); and set or clear (null) the saved scheduled_at. Other fields (name, subject, from, template, segment) are not changed here and are ignored: edit them in the dashboard, and read the response to see what applied. See what's blocking a send with Validate a campaign. Requires the campaigns:write capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Request body7 fields
FieldTypeDescription
archivedboolean—
topic_idstring | null—
open_trackingboolean | null—
click_trackingboolean | null—
variable_fallbacksobject—
excluded_contact_idsstring[]—
scheduled_atstring | null—
Response · 200 fields22 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
namerequiredstring—
template_idrequiredstring—
segment_idrequiredstring—
topic_idrequiredstring | null—
fromrequiredstring—
subjectrequiredstring | null—
preview_textrequiredstring | null—
open_trackingrequiredboolean | null—
click_trackingrequiredboolean | null—
statusrequiredenumdraft | scheduled | sending | paused | sent | canceled | failed—
scheduled_atrequiredstring | null—
variable_fallbacksrequiredobject—
excluded_contact_idsrequiredstring[]—
total_recipientsrequirednumberRecipients the engine admitted when the campaign started sending. 0 while it is `draft` or `scheduled`: read `projected_recipients`, or `sendable` from validate.
projected_recipientsobject | nullThe audience counted when the campaign was sent or scheduled: `total` segment members, `reachable` who will get it, `skipped` (unsubscribed, suppressed, excluded). Null for a draft that was never sent: read validate. Read-only.
sent_countrequirednumber—
failed_countrequirednumber—
pause_reasonrequiredobject | null—
created_atrequiredstring—
updated_atrequiredstring—
200401404409422

Delete a campaign

DELETE/api/v1/email/campaigns/{id}

DELETE /email/campaigns/{id}
curl -X DELETE 'https://www.unitpost.com/api/v1/email/campaigns/cmp_123' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'

Archive a campaign that's in draft or a terminal state (sent, canceled, failed): it leaves the list and is permanently deleted after 30 days. Restore it before then with PATCH { "archived": false }. A campaign that's mid-flight can't be archived — stop it first with Cancel a campaign. Requires the campaigns:write capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
204401404409

Send a campaign

POST/api/v1/email/campaigns/{id}/send

POST /email/campaigns/{id}/send
curl -X POST 'https://www.unitpost.com/api/v1/email/campaigns/cmp_123/send' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0' \
  -H 'Content-Type: application/json' \
  -d '{ }'
Response · 200
{
  "object": "campaign",
  "id": "id_123",
  "name": "Example",
  "template_id": "template_123",
  "segment_id": "segment_123",
  "topic_id": "topic_123",
  "from": "you@yourdomain.com",
  "subject": "Welcome to Acme",
  "preview_text": "string",
  "open_tracking": false,
  "click_tracking": false,
  "status": "draft",
  "scheduled_at": "2026-01-01T00:00:00.000Z",
  "variable_fallbacks": {},
  "excluded_contact_ids": [
    "string"
  ],
  "total_recipients": 1,
  "projected_recipients": {
    "total": 1,
    "reachable": 0,
    "skipped": 0
  },
  "sent_count": 1,
  "failed_count": 1,
  "pause_reason": {
    "code": "user",
    "message": "string",
    "metric": "hard_bounce_rate",
    "value": 0,
    "threshold": 0,
    "sample": {
      "events": 0,
      "of": 0
    },
    "window": {
      "since": "string"
    },
    "decided_by": "floor",
    "resume": "self_serve",
    "paused_at": "2026-01-01T00:00:00.000Z"
  },
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}

Validate a draft campaign and then send it now or schedule it. scheduled_at in the body schedules it for that time (ISO 8601 with Z or an offset; in the future, inside your plan's scheduling window); null sends it now; omit it to use the draft's saved scheduled_at while that is still in the future — **otherwise the campaign is sent immediately** (except right after a dashboard Unschedule, which cleared its time: then it answers 409 with details[].code send_time_required and sends nothing, so name the time or pass null). The response's scheduled_at is the resolved time in UTC (null when it is sending now). A misnamed time field (scheduledAt, send_at, deliver_at, …) is a 422 naming scheduled_at, and a body that isn't JSON (a form-encoded one included) is a 400; nothing is sent in either case. Other fields are ignored and a JSON null body means no options. If anything blocks the send (unverified domain, missing template variables, empty audience, …) you get a 409 with each blocking reason itemized in error.details[] (code is the reason, as in the validate report's blockers[].code). To see the full pre-send report without attempting a send, use Validate a campaign; fix variable issues via Update a campaign. A launched campaign can be paused or canceled, and a scheduled one rescheduled. Requires the campaigns:send capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Request body1 field
FieldTypeDescription
scheduled_atstring | null—
Response · 200 fields22 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
namerequiredstring—
template_idrequiredstring—
segment_idrequiredstring—
topic_idrequiredstring | null—
fromrequiredstring—
subjectrequiredstring | null—
preview_textrequiredstring | null—
open_trackingrequiredboolean | null—
click_trackingrequiredboolean | null—
statusrequiredenumdraft | scheduled | sending | paused | sent | canceled | failed—
scheduled_atrequiredstring | null—
variable_fallbacksrequiredobject—
excluded_contact_idsrequiredstring[]—
total_recipientsrequirednumberRecipients the engine admitted when the campaign started sending. 0 while it is `draft` or `scheduled`: read `projected_recipients`, or `sendable` from validate.
projected_recipientsobject | nullThe audience counted when the campaign was sent or scheduled: `total` segment members, `reachable` who will get it, `skipped` (unsubscribed, suppressed, excluded). Null for a draft that was never sent: read validate. Read-only.
sent_countrequirednumber—
failed_countrequirednumber—
pause_reasonrequiredobject | null—
created_atrequiredstring—
updated_atrequiredstring—
200400401402403404409422429502503

Cancel a campaign

POST/api/v1/email/campaigns/{id}/cancel

POST /email/campaigns/{id}/cancel
curl -X POST 'https://www.unitpost.com/api/v1/email/campaigns/cmp_123/cancel' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "object": "campaign",
  "id": "id_123",
  "name": "Example",
  "template_id": "template_123",
  "segment_id": "segment_123",
  "topic_id": "topic_123",
  "from": "you@yourdomain.com",
  "subject": "Welcome to Acme",
  "preview_text": "string",
  "open_tracking": false,
  "click_tracking": false,
  "status": "draft",
  "scheduled_at": "2026-01-01T00:00:00.000Z",
  "variable_fallbacks": {},
  "excluded_contact_ids": [
    "string"
  ],
  "total_recipients": 1,
  "projected_recipients": {
    "total": 1,
    "reachable": 0,
    "skipped": 0
  },
  "sent_count": 1,
  "failed_count": 1,
  "pause_reason": {
    "code": "user",
    "message": "string",
    "metric": "hard_bounce_rate",
    "value": 0,
    "threshold": 0,
    "sample": {
      "events": 0,
      "of": 0
    },
    "window": {
      "since": "string"
    },
    "decided_by": "floor",
    "resume": "self_serve",
    "paused_at": "2026-01-01T00:00:00.000Z"
  },
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}

Cancel a draft, scheduled, or sending campaign. Cancelation is terminal — the campaign can't be restarted afterwards. Messages already sent to recipients are not recalled. If you only want to stop temporarily, use Pause a campaign instead. Requires the campaigns:send capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Response · 200 fields22 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
namerequiredstring—
template_idrequiredstring—
segment_idrequiredstring—
topic_idrequiredstring | null—
fromrequiredstring—
subjectrequiredstring | null—
preview_textrequiredstring | null—
open_trackingrequiredboolean | null—
click_trackingrequiredboolean | null—
statusrequiredenumdraft | scheduled | sending | paused | sent | canceled | failed—
scheduled_atrequiredstring | null—
variable_fallbacksrequiredobject—
excluded_contact_idsrequiredstring[]—
total_recipientsrequirednumberRecipients the engine admitted when the campaign started sending. 0 while it is `draft` or `scheduled`: read `projected_recipients`, or `sendable` from validate.
projected_recipientsobject | nullThe audience counted when the campaign was sent or scheduled: `total` segment members, `reachable` who will get it, `skipped` (unsubscribed, suppressed, excluded). Null for a draft that was never sent: read validate. Read-only.
sent_countrequirednumber—
failed_countrequirednumber—
pause_reasonrequiredobject | null—
created_atrequiredstring—
updated_atrequiredstring—
200401404409

Pause a campaign

POST/api/v1/email/campaigns/{id}/pause

POST /email/campaigns/{id}/pause
curl -X POST 'https://www.unitpost.com/api/v1/email/campaigns/cmp_123/pause' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "object": "campaign",
  "id": "id_123",
  "name": "Example",
  "template_id": "template_123",
  "segment_id": "segment_123",
  "topic_id": "topic_123",
  "from": "you@yourdomain.com",
  "subject": "Welcome to Acme",
  "preview_text": "string",
  "open_tracking": false,
  "click_tracking": false,
  "status": "draft",
  "scheduled_at": "2026-01-01T00:00:00.000Z",
  "variable_fallbacks": {},
  "excluded_contact_ids": [
    "string"
  ],
  "total_recipients": 1,
  "projected_recipients": {
    "total": 1,
    "reachable": 0,
    "skipped": 0
  },
  "sent_count": 1,
  "failed_count": 1,
  "pause_reason": {
    "code": "user",
    "message": "string",
    "metric": "hard_bounce_rate",
    "value": 0,
    "threshold": 0,
    "sample": {
      "events": 0,
      "of": 0
    },
    "window": {
      "since": "string"
    },
    "decided_by": "floor",
    "resume": "self_serve",
    "paused_at": "2026-01-01T00:00:00.000Z"
  },
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}

Pause a scheduled or sending campaign. A paused send stops cleanly between recipients — already-sent messages are untouched, and no partial email is ever produced. Continue where it left off with Resume a campaign, or stop it for good with Cancel a campaign. Requires the campaigns:send capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Response · 200 fields22 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
namerequiredstring—
template_idrequiredstring—
segment_idrequiredstring—
topic_idrequiredstring | null—
fromrequiredstring—
subjectrequiredstring | null—
preview_textrequiredstring | null—
open_trackingrequiredboolean | null—
click_trackingrequiredboolean | null—
statusrequiredenumdraft | scheduled | sending | paused | sent | canceled | failed—
scheduled_atrequiredstring | null—
variable_fallbacksrequiredobject—
excluded_contact_idsrequiredstring[]—
total_recipientsrequirednumberRecipients the engine admitted when the campaign started sending. 0 while it is `draft` or `scheduled`: read `projected_recipients`, or `sendable` from validate.
projected_recipientsobject | nullThe audience counted when the campaign was sent or scheduled: `total` segment members, `reachable` who will get it, `skipped` (unsubscribed, suppressed, excluded). Null for a draft that was never sent: read validate. Read-only.
sent_countrequirednumber—
failed_countrequirednumber—
pause_reasonrequiredobject | null—
created_atrequiredstring—
updated_atrequiredstring—
200401404409

Resume a campaign

POST/api/v1/email/campaigns/{id}/resume

POST /email/campaigns/{id}/resume
curl -X POST 'https://www.unitpost.com/api/v1/email/campaigns/cmp_123/resume' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "object": "campaign",
  "id": "id_123",
  "name": "Example",
  "template_id": "template_123",
  "segment_id": "segment_123",
  "topic_id": "topic_123",
  "from": "you@yourdomain.com",
  "subject": "Welcome to Acme",
  "preview_text": "string",
  "open_tracking": false,
  "click_tracking": false,
  "status": "draft",
  "scheduled_at": "2026-01-01T00:00:00.000Z",
  "variable_fallbacks": {},
  "excluded_contact_ids": [
    "string"
  ],
  "total_recipients": 1,
  "projected_recipients": {
    "total": 1,
    "reachable": 0,
    "skipped": 0
  },
  "sent_count": 1,
  "failed_count": 1,
  "pause_reason": {
    "code": "user",
    "message": "string",
    "metric": "hard_bounce_rate",
    "value": 0,
    "threshold": 0,
    "sample": {
      "events": 0,
      "of": 0
    },
    "window": {
      "since": "string"
    },
    "decided_by": "floor",
    "resume": "self_serve",
    "paused_at": "2026-01-01T00:00:00.000Z"
  },
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}

Resume a paused campaign. Sending continues with the recipients that hadn't been reached yet — only them: contacts who joined the segment after the campaign started are not added, and anyone already delivered to is skipped, so nobody receives the campaign twice. After an automatic quality pause (pause_reason.code hard_bounce_rate, complaint_rate or quality_review) the quality check measures only sends made after the resume. A campaign whose scheduled_at is still in the future waits for it; otherwise it sends immediately. Resume runs the same checks as Send a campaign and answers with the same errors; the campaign stays paused when one fails. Pause a send with Pause a campaign. Requires the campaigns:send capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Response · 200 fields22 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
namerequiredstring—
template_idrequiredstring—
segment_idrequiredstring—
topic_idrequiredstring | null—
fromrequiredstring—
subjectrequiredstring | null—
preview_textrequiredstring | null—
open_trackingrequiredboolean | null—
click_trackingrequiredboolean | null—
statusrequiredenumdraft | scheduled | sending | paused | sent | canceled | failed—
scheduled_atrequiredstring | null—
variable_fallbacksrequiredobject—
excluded_contact_idsrequiredstring[]—
total_recipientsrequirednumberRecipients the engine admitted when the campaign started sending. 0 while it is `draft` or `scheduled`: read `projected_recipients`, or `sendable` from validate.
projected_recipientsobject | nullThe audience counted when the campaign was sent or scheduled: `total` segment members, `reachable` who will get it, `skipped` (unsubscribed, suppressed, excluded). Null for a draft that was never sent: read validate. Read-only.
sent_countrequirednumber—
failed_countrequirednumber—
pause_reasonrequiredobject | null—
created_atrequiredstring—
updated_atrequiredstring—
200401402403404409429

Reschedule a campaign

POST/api/v1/email/campaigns/{id}/reschedule

POST /email/campaigns/{id}/reschedule
curl -X POST 'https://www.unitpost.com/api/v1/email/campaigns/cmp_123/reschedule' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0' \
  -H 'Content-Type: application/json' \
  -d '{
    "scheduled_at": "2026-07-01T15:00:00Z"
  }'
Response · 200
{
  "object": "campaign",
  "id": "id_123",
  "name": "Example",
  "template_id": "template_123",
  "segment_id": "segment_123",
  "topic_id": "topic_123",
  "from": "you@yourdomain.com",
  "subject": "Welcome to Acme",
  "preview_text": "string",
  "open_tracking": false,
  "click_tracking": false,
  "status": "draft",
  "scheduled_at": "2026-01-01T00:00:00.000Z",
  "variable_fallbacks": {},
  "excluded_contact_ids": [
    "string"
  ],
  "total_recipients": 1,
  "projected_recipients": {
    "total": 1,
    "reachable": 0,
    "skipped": 0
  },
  "sent_count": 1,
  "failed_count": 1,
  "pause_reason": {
    "code": "user",
    "message": "string",
    "metric": "hard_bounce_rate",
    "value": 0,
    "threshold": 0,
    "sample": {
      "events": 0,
      "of": 0
    },
    "window": {
      "since": "string"
    },
    "decided_by": "floor",
    "resume": "self_serve",
    "paused_at": "2026-01-01T00:00:00.000Z"
  },
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}

Move a scheduled campaign to a new send time: scheduled_at in ISO 8601 with Z or an offset, in the future and inside your plan's scheduling window; the response returns it in UTC (a time without a timezone is still read as UTC, deprecated). A draft is scheduled with Send a campaign instead. Only campaigns that haven't started sending can be rescheduled — once a send is in flight, pause or cancel it instead. Requires the campaigns:send capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Request body1 field
FieldTypeDescription
scheduled_atrequiredstringISO 8601 with `Z` or a `±hh:mm` offset; returned in UTC. A time without a timezone is read as UTC (deprecated: include the zone).
Response · 200 fields22 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
namerequiredstring—
template_idrequiredstring—
segment_idrequiredstring—
topic_idrequiredstring | null—
fromrequiredstring—
subjectrequiredstring | null—
preview_textrequiredstring | null—
open_trackingrequiredboolean | null—
click_trackingrequiredboolean | null—
statusrequiredenumdraft | scheduled | sending | paused | sent | canceled | failed—
scheduled_atrequiredstring | null—
variable_fallbacksrequiredobject—
excluded_contact_idsrequiredstring[]—
total_recipientsrequirednumberRecipients the engine admitted when the campaign started sending. 0 while it is `draft` or `scheduled`: read `projected_recipients`, or `sendable` from validate.
projected_recipientsobject | nullThe audience counted when the campaign was sent or scheduled: `total` segment members, `reachable` who will get it, `skipped` (unsubscribed, suppressed, excluded). Null for a draft that was never sent: read validate. Read-only.
sent_countrequirednumber—
failed_countrequirednumber—
pause_reasonrequiredobject | null—
created_atrequiredstring—
updated_atrequiredstring—
200401404409422502

Validate a campaign

GET/api/v1/email/campaigns/{id}/validate

GET /email/campaigns/{id}/validate
curl -X GET 'https://www.unitpost.com/api/v1/email/campaigns/cmp_123/validate' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "object": "campaign_validation",
  "total_members": 1,
  "sendable": 0,
  "suppressed": 0,
  "excluded": 0,
  "missing_data": [
    {
      "contact_id": "contact_123",
      "email": "customer@example.com",
      "missing": [
        "string"
      ]
    }
  ],
  "unresolved_variables": [
    "string"
  ],
  "variable_coverage": [
    {
      "name": "Example",
      "missing_count": 1,
      "eligible": 0,
      "missing_for_all": false,
      "has_default": false
    }
  ],
  "blockers": [
    {
      "code": "string",
      "message": "string"
    }
  ],
  "can_send": false,
  "warnings": [
    {
      "code": "QUOTA_OVERSUBSCRIBED",
      "severity": "info",
      "title": "string",
      "message": "string",
      "guide_url": "https://example.com",
      "fix_url": "https://example.com"
    }
  ]
}

Run a non-mutating pre-send check and get the full readiness report: how many contacts will receive the campaign (sendable), how many are suppressed or excluded, which contacts are missing template variables, variable_coverage[] per variable (missing_for_all: a variable_fallbacks default is required to send; otherwise a default is optional and only fills the blanks), blockers, warnings, and a final can_send verdict. Nothing is sent or changed. Show the user sendable, the blockers and the warnings before sending. Fix reported variable gaps with Update a campaign (variable_fallbacks / excluded_contact_ids), then launch with Send a campaign. Requires the campaigns:read capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Response · 200 fields11 fields
FieldTypeDescription
objectrequiredenumcampaign_validation—
total_membersrequiredinteger—
sendablerequiredinteger—
suppressedrequiredinteger—
excludedrequiredinteger—
missing_datarequiredobject[]—
unresolved_variablesrequiredstring[]—
variable_coverageobject[]Per template variable that some recipients lack, missing-for-all first. `missing_for_all`: no recipient has it, so a `variable_fallbacks` default is REQUIRED to send. Otherwise a default is optional and only fills the `missing_count` blanks (it never replaces a recipient's own value); or exclude those contacts. `has_default`: the campaign already sets one.
blockersobject[]What refuses Send right now (draft template, archived segment, unverified domain, …). The same codes as a blocked send's `error.details[].code`.
can_sendrequiredboolean—
warningsrequiredobject[]Deliverability warnings: advice that never blocks `can_send` (credits that may run short, a saved send time that has passed so Send sends now, no subscription topic; and, once switched on, a young sending domain, a send far above your usual volume, no DMARC, recipients mailed in the last few days). Most severe first, at most three. Show them to whoever is about to send.
200202401404

Templates

Reusable email HTML with per-recipient {{variables}} filled at send time. The dashboard visual editor is a separate surface and still uses the structured document internally.

List templates

GET/api/v1/email/templates

GET /email/templates
curl -X GET 'https://www.unitpost.com/api/v1/email/templates' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "object": "template",
      "id": "id_123",
      "name": "Example",
      "subject": "Welcome to Acme",
      "status": "draft",
      "editor": "simple",
      "kind": "simple",
      "preview_text": "string",
      "html": "string",
      "tsx": "string",
      "warnings": [
        "string"
      ],
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ]
}

Retrieve a list of your email templates, cursor-paginated. Each row says which editor it's authored in (design, simple or code). List rows omit html and tsx to keep responses small — fetch a single template with Retrieve a template to get it. Reference a template's id when sending with Send or schedule an email or when creating a campaign. Requires the templates:read capability.

Parameters3 fields
FieldTypeDescription
limitinteger · queryMax rows to return (default 20, max 100).
afterstring · queryReturn rows after this id (forward paging).
beforestring · queryReturn rows before this id (backward paging).
Response · 200 fields13 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
namerequiredstring—
subjectrequiredstring | null—
statusrequiredenumdraft | published—
editorenumsimple | design | codeWhich editor this template is authored in — and so which body field updates it. `design`: built from @unitpost/email components (the dashboard's Design tab) — update with `tsx`. `simple`: a plain, personal Gmail-style email (the Simple tab) — update with `text`. `code`: raw HTML (the Code tab) — update with `html`. Keep the editor unless the user asked to switch; to switch, send the other body field or `editor` alone to convert the current body.
kindrequiredenumsimple | designedThe coarse kind: `simple` (editor `simple`) or `designed` (editor `design` or `code`). Prefer `editor`.
preview_textstringInbox preview text: the snippet most inboxes show after the subject. Empty when unset.
htmlstring | null—
tsxstring | nullA `design` template's body as @unitpost/email component TSX, wrapped in the `<Email …>` element that carries its theme, preview text and category — edit it and send it back as `tsx`. Null for `simple` and `code` templates. Present on single reads; omitted from list rows.
warningsstring[]Create or update only: what changed that you didn't write, in plain words — markup the Simple format removed from `text`, `<Email>` attributes or theme values that were ignored, what an `editor` conversion flattened or couldn't keep, a `tsx` write that switched the template's editor. Absent when nothing did. Tell the user.
created_atrequiredstring—
updated_atrequiredstring—
200401422

Create a template

POST/api/v1/email/templates

POST /email/templates
curl -X POST 'https://www.unitpost.com/api/v1/email/templates' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Welcome",
    "subject": "Welcome!",
    "html": "<h1>Hi Mike</h1>"
  }'
Response · 201
{
  "object": "template",
  "id": "id_123",
  "name": "Example",
  "subject": "Welcome to Acme",
  "status": "draft",
  "editor": "simple",
  "kind": "simple",
  "preview_text": "string",
  "html": "string",
  "tsx": "string",
  "warnings": [
    "string"
  ],
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}

Create a reusable email template from ONE body field, which picks its editor in the dashboard. tsx (the default for anything branded) makes a **Design** template from @unitpost/email components; wrap them in one <Email …> element, or pass theme, to set the page style, so one call makes the finished, branded template. text makes a **Simple** email: a plain, personal note typed like Gmail. html makes a **Code** template: your raw HTML, kept as-is. {{variables}} fill per-recipient at send time. Anything that changed that you didn't write comes back in warnings. Once created, send with it via Send or schedule an email, and iterate with Update a template. Requires the templates:write capability.

Request body8 fields
FieldTypeDescription
namerequiredstring—
subjectstring—
tsxstringA Design email body (editor `design`) — the DEFAULT for anything branded: newsletters, announcements, promos, welcome and product emails, receipts. @unitpost/email component TSX: <Section>, <Row>/<Column>, <Heading>, <Text>, <Button href>, <Image src alt>, <List>, <Divider>, <Spacer>, <Link>, <Code> (props and ready-made layout bands: the `design_library` MCP tool or unitpost.com/components). Wrap the blocks in ONE <Email …> element to set the page in the same call: background-color, body-color, content-width, body-padding, body-radius, body-border-width/-style/-color, font-family, text-color, link-color, preview-text, category. <Section> holds leaf blocks and nested <Section>s (never a <Row>); <Row> holds only <Column>s. Inline <strong>/<em>/<a href> inside <Text>; no Markdown; no <html>/<body>. Images: hosted https URLs or `/img/{id}` library images only. Don't write the unsubscribe / postal-address footer — marketing templates get it at send. `{{variables}}` fill at send. Pass exactly one of `tsx`, `html` or `text`.
htmlstringA Code email body (editor `code`): raw HTML kept as-is in one HTML block — the Design editor can show it but not edit it piece by piece. Use it only when the user asks for HTML or brings their own; for a branded email send `tsx` instead. Write simple, email-safe HTML: tables for layout, inline styles, hosted https images. `{{variables}}` fill at send. Pass exactly one of `tsx`, `html` or `text`.
textstringA Simple email body (editor `simple`) — only when the user wants a plain, personal note that reads like it was typed in Gmail. Plain text exactly as you'd type it into Gmail: lines, a blank line between paragraphs (the only spacing), '- ' or '1. ' list items, inline <b>, <i>, <u>, <s>, <a href="…">, <br>, and <span style="color:#hex; background-color:#hex; font-size:13px|18px|24px; font-family:Georgia"> for colour, highlight, size and font; <div style="text-align:center">…</div> around one line to centre it; <img src="/img/{id}" alt="…" width="400"> alone on a line for an image; {{variables}}. Not supported (removed): headings, layout/sections/buttons, block HTML tags (p, div, table, ul), other CSS, and Markdown (stays literal). Sent as a bare mail-client fragment with no layout; marketing templates get a one-line unsubscribe footer at send time. Max 20,000 characters. Pass exactly one of `tsx`, `html` or `text`.
themeobjectPage-level look of a Design template (`tsx`) — the same settings as the `<Email>` element's attributes. Keys you leave out keep the template's current value (a new template starts from the Unitpost brand theme). Not accepted with `html` (style the HTML itself) or `text` (Simple emails use the reader's own style).
preview_textstringInbox preview text: the snippet shown after the subject. A short line that complements the subject. Wins over a `preview-text` on <Email>.
categoryenumtransactional | marketing—
Response · 201 fields13 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
namerequiredstring—
subjectrequiredstring | null—
statusrequiredenumdraft | published—
editorenumsimple | design | codeWhich editor this template is authored in — and so which body field updates it. `design`: built from @unitpost/email components (the dashboard's Design tab) — update with `tsx`. `simple`: a plain, personal Gmail-style email (the Simple tab) — update with `text`. `code`: raw HTML (the Code tab) — update with `html`. Keep the editor unless the user asked to switch; to switch, send the other body field or `editor` alone to convert the current body.
kindrequiredenumsimple | designedThe coarse kind: `simple` (editor `simple`) or `designed` (editor `design` or `code`). Prefer `editor`.
preview_textstringInbox preview text: the snippet most inboxes show after the subject. Empty when unset.
htmlstring | null—
tsxstring | nullA `design` template's body as @unitpost/email component TSX, wrapped in the `<Email …>` element that carries its theme, preview text and category — edit it and send it back as `tsx`. Null for `simple` and `code` templates. Present on single reads; omitted from list rows.
warningsstring[]Create or update only: what changed that you didn't write, in plain words — markup the Simple format removed from `text`, `<Email>` attributes or theme values that were ignored, what an `editor` conversion flattened or couldn't keep, a `tsx` write that switched the template's editor. Absent when nothing did. Tell the user.
created_atrequiredstring—
updated_atrequiredstring—
201401422

Retrieve a template

GET/api/v1/email/templates/{id}

GET /email/templates/{id}
curl -X GET 'https://www.unitpost.com/api/v1/email/templates/tmpl_123' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "object": "template",
  "id": "id_123",
  "name": "Example",
  "subject": "Welcome to Acme",
  "status": "draft",
  "editor": "simple",
  "kind": "simple",
  "preview_text": "string",
  "html": "string",
  "tsx": "string",
  "warnings": [
    "string"
  ],
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}

Retrieve a single template by id, including the rendered html and, for a Design template, its tsx with the <Email> theme element (List templates omits both). Use this to read the body in its own editor, then save changes with Update a template. Requires the templates:read capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Response · 200 fields13 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
namerequiredstring—
subjectrequiredstring | null—
statusrequiredenumdraft | published—
editorenumsimple | design | codeWhich editor this template is authored in — and so which body field updates it. `design`: built from @unitpost/email components (the dashboard's Design tab) — update with `tsx`. `simple`: a plain, personal Gmail-style email (the Simple tab) — update with `text`. `code`: raw HTML (the Code tab) — update with `html`. Keep the editor unless the user asked to switch; to switch, send the other body field or `editor` alone to convert the current body.
kindrequiredenumsimple | designedThe coarse kind: `simple` (editor `simple`) or `designed` (editor `design` or `code`). Prefer `editor`.
preview_textstringInbox preview text: the snippet most inboxes show after the subject. Empty when unset.
htmlstring | null—
tsxstring | nullA `design` template's body as @unitpost/email component TSX, wrapped in the `<Email …>` element that carries its theme, preview text and category — edit it and send it back as `tsx`. Null for `simple` and `code` templates. Present on single reads; omitted from list rows.
warningsstring[]Create or update only: what changed that you didn't write, in plain words — markup the Simple format removed from `text`, `<Email>` attributes or theme values that were ignored, what an `editor` conversion flattened or couldn't keep, a `tsx` write that switched the template's editor. Absent when nothing did. Tell the user.
created_atrequiredstring—
updated_atrequiredstring—
200401404

Update a template

PATCH/api/v1/email/templates/{id}

PATCH /email/templates/{id}
curl -X PATCH 'https://www.unitpost.com/api/v1/email/templates/tmpl_123' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0' \
  -H 'Content-Type: application/json' \
  -d '{
    "subject": "Welcome aboard!"
  }'
Response · 200
{
  "object": "template",
  "id": "id_123",
  "name": "Example",
  "subject": "Welcome to Acme",
  "status": "draft",
  "editor": "simple",
  "kind": "simple",
  "preview_text": "string",
  "html": "string",
  "tsx": "string",
  "warnings": [
    "string"
  ],
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}

Update a template's metadata and/or body. Changes take effect for future sends only — emails already sent or queued keep the body they were rendered with. Send the body in the template's own editor (tsx for design, text for simple, html for code); another body field switches the editor. editor alone converts the current body (lossy steps are listed in warnings), and theme alone restyles a Design template. Read the current body first with Retrieve a template. Requires the templates:write capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Request body9 fields
FieldTypeDescription
namestring—
subjectstring | null—
statusenumdraft | published—
tsxstringA Design email body (editor `design`) — the DEFAULT for anything branded: newsletters, announcements, promos, welcome and product emails, receipts. @unitpost/email component TSX: <Section>, <Row>/<Column>, <Heading>, <Text>, <Button href>, <Image src alt>, <List>, <Divider>, <Spacer>, <Link>, <Code> (props and ready-made layout bands: the `design_library` MCP tool or unitpost.com/components). Wrap the blocks in ONE <Email …> element to set the page in the same call: background-color, body-color, content-width, body-padding, body-radius, body-border-width/-style/-color, font-family, text-color, link-color, preview-text, category. <Section> holds leaf blocks and nested <Section>s (never a <Row>); <Row> holds only <Column>s. Inline <strong>/<em>/<a href> inside <Text>; no Markdown; no <html>/<body>. Images: hosted https URLs or `/img/{id}` library images only. Don't write the unsubscribe / postal-address footer — marketing templates get it at send. `{{variables}}` fill at send. Pass exactly one of `tsx`, `html` or `text`.
htmlstringA Code email body (editor `code`): raw HTML kept as-is in one HTML block — the Design editor can show it but not edit it piece by piece. Use it only when the user asks for HTML or brings their own; for a branded email send `tsx` instead. Write simple, email-safe HTML: tables for layout, inline styles, hosted https images. `{{variables}}` fill at send. Pass exactly one of `tsx`, `html` or `text`.
textstringA Simple email body (editor `simple`) — only when the user wants a plain, personal note that reads like it was typed in Gmail. Plain text exactly as you'd type it into Gmail: lines, a blank line between paragraphs (the only spacing), '- ' or '1. ' list items, inline <b>, <i>, <u>, <s>, <a href="…">, <br>, and <span style="color:#hex; background-color:#hex; font-size:13px|18px|24px; font-family:Georgia"> for colour, highlight, size and font; <div style="text-align:center">…</div> around one line to centre it; <img src="/img/{id}" alt="…" width="400"> alone on a line for an image; {{variables}}. Not supported (removed): headings, layout/sections/buttons, block HTML tags (p, div, table, ul), other CSS, and Markdown (stays literal). Sent as a bare mail-client fragment with no layout; marketing templates get a one-line unsubscribe footer at send time. Max 20,000 characters. Pass exactly one of `tsx`, `html` or `text`.
themeobjectPage-level look of a Design template (`tsx`) — the same settings as the `<Email>` element's attributes. Keys you leave out keep the template's current value (a new template starts from the Unitpost brand theme). Not accepted with `html` (style the HTML itself) or `text` (Simple emails use the reader's own style).
preview_textstringInbox preview text: the snippet shown after the subject. A short line that complements the subject. Wins over a `preview-text` on <Email>.
editorenumsimple | design | codeConvert the template to this editor. Alone (no body field) it converts the CURRENT body: simple → design keeps every line; design → code renders it to HTML (looks the same, no longer editable block by block); code → design rebuilds components where the HTML maps and keeps the rest as HTML; design or code → simple flattens to plain lines. What a conversion changes comes back in `warnings`. With a body field it must name that field's editor (`tsx` = design, `html` = code, `text` = simple). Only when the user asked to switch.
Response · 200 fields13 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
namerequiredstring—
subjectrequiredstring | null—
statusrequiredenumdraft | published—
editorenumsimple | design | codeWhich editor this template is authored in — and so which body field updates it. `design`: built from @unitpost/email components (the dashboard's Design tab) — update with `tsx`. `simple`: a plain, personal Gmail-style email (the Simple tab) — update with `text`. `code`: raw HTML (the Code tab) — update with `html`. Keep the editor unless the user asked to switch; to switch, send the other body field or `editor` alone to convert the current body.
kindrequiredenumsimple | designedThe coarse kind: `simple` (editor `simple`) or `designed` (editor `design` or `code`). Prefer `editor`.
preview_textstringInbox preview text: the snippet most inboxes show after the subject. Empty when unset.
htmlstring | null—
tsxstring | nullA `design` template's body as @unitpost/email component TSX, wrapped in the `<Email …>` element that carries its theme, preview text and category — edit it and send it back as `tsx`. Null for `simple` and `code` templates. Present on single reads; omitted from list rows.
warningsstring[]Create or update only: what changed that you didn't write, in plain words — markup the Simple format removed from `text`, `<Email>` attributes or theme values that were ignored, what an `editor` conversion flattened or couldn't keep, a `tsx` write that switched the template's editor. Absent when nothing did. Tell the user.
created_atrequiredstring—
updated_atrequiredstring—
200401404422

Delete a template

DELETE/api/v1/email/templates/{id}

DELETE /email/templates/{id}
curl -X DELETE 'https://www.unitpost.com/api/v1/email/templates/tmpl_123' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'

Permanently delete a template. Emails already sent with it are unaffected, but future sends that reference its id will fail — update any code or campaigns that still point at it first (see List campaigns). Requires the templates:write capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
204401404

Send a test of a template

POST/api/v1/email/templates/{id}/test-send

POST /email/templates/{id}/test-send
curl -X POST 'https://www.unitpost.com/api/v1/email/templates/tmpl_123/test-send' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0' \
  -H 'Content-Type: application/json' \
  -d '{ }'
Response · 200
{
  "object": "template_test_send",
  "email_id": "email_123",
  "status": "queued",
  "dispatched": false,
  "unresolved_variables": [
    "string"
  ]
}

Render the template with sample variables and send ONE test email to to, exactly as a recipient would get it (footer included). With a verified sending domain it comes from test@<your domain> and may go to anyone; with none it comes from Unitpost's shared test address and only to your workspace's members (403 forbidden otherwise). unresolved_variables lists the ones with no sample value. Rate limited per workspace. Requires the emails:send capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Request body3 fields
FieldTypeDescription
torequiredstring—
subjectstring—
variablesobject—
Response · 200 fields5 fields
FieldTypeDescription
objectrequiredenumtemplate_test_send—
email_idrequiredstringThe test email (`email_…`); retrieve it like any email.
statusrequiredstringThe test email's status, usually `queued`.
dispatchedrequiredbooleanFalse when this environment records sends without delivering them.
unresolved_variablesrequiredstring[]Variables with no sample value, sent as a literal `{{name}}`.
200401403404422429502

Brand kits

Read-only Brand Kit profiles (voice, labeled colors, pinned library graphics, core pages) for on-brand template authoring. Writes and URL import stay in the dashboard (Settings → Brand).

List brand kits

GET/api/v1/brand-kits

GET /brand-kits
curl -X GET 'https://www.unitpost.com/api/v1/brand-kits' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "object": "list",
  "data": [
    {
      "object": "brand_kit",
      "id": "id_123",
      "name": "Example",
      "is_default": false,
      "website_url": "https://example.com",
      "description": "string",
      "tone": "NEUTRAL",
      "voice_notes": "string",
      "industry": "string",
      "core_pages": [
        {
          "url": "https://example.com",
          "title": "string"
        }
      ],
      "extracted": {
        "colors": [
          {
            "hex": "string",
            "label": "string"
          }
        ],
        "logo_urls": [
          "string"
        ],
        "background_urls": [
          "string"
        ],
        "fonts": [
          "string"
        ]
      },
      "graphics": [
        {
          "library_image_id": "library_image_123",
          "role": "logo",
          "label": "string"
        }
      ],
      "source": "string",
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "default_id": "default_123"
}

Retrieve every Brand Kit profile in the workspace (up to five named profiles). Each profile holds tone, about, voice notes, labeled colors, pinned logo/background library images, and core pages — the primary brand context for on-brand template authoring. Pass ?name= to filter to one profile by display name (case-insensitive). The response includes default_id for the Default profile. Fetch one by id with Retrieve a brand kit. Writes and URL import stay in the dashboard (Settings → Brand). Requires the templates:read capability.

Parameters1 field
FieldTypeDescription
namestring · queryFilter to a single profile by display name (case-insensitive).
Response · 200 fields14 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
namerequiredstring—
is_defaultrequiredboolean—
website_urlrequiredstring | null—
descriptionrequiredstring | null—
tonerequiredenum | null—
voice_notesrequiredstring | null—
industryrequiredstring | null—
core_pagesrequiredobject[]—
extractedrequiredobject—
graphicsrequiredobject[]—
sourcerequiredstring | null—
updated_atrequiredstring | null—
200401403

Retrieve a brand kit

GET/api/v1/brand-kits/{id}

GET /brand-kits/{id}
curl -X GET 'https://www.unitpost.com/api/v1/brand-kits/123' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "object": "brand_kit",
  "id": "id_123",
  "name": "Example",
  "is_default": false,
  "website_url": "https://example.com",
  "description": "string",
  "tone": "NEUTRAL",
  "voice_notes": "string",
  "industry": "string",
  "core_pages": [
    {
      "url": "https://example.com",
      "title": "string"
    }
  ],
  "extracted": {
    "colors": [
      {
        "hex": "string",
        "label": "string"
      }
    ],
    "logo_urls": [
      "string"
    ],
    "background_urls": [
      "string"
    ],
    "fonts": [
      "string"
    ]
  },
  "graphics": [
    {
      "library_image_id": "library_image_123",
      "role": "logo",
      "label": "string"
    }
  ],
  "source": "string",
  "updated_at": "2026-01-01T00:00:00.000Z"
}

Retrieve one Brand Kit profile by id (bk_…), including voice, labeled colors, pinned graphics (library_image_id for /img/{id} in design TSX), and core pages. List every profile with List brand kits. Requires the templates:read capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Response · 200 fields14 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
namerequiredstring—
is_defaultrequiredboolean—
website_urlrequiredstring | null—
descriptionrequiredstring | null—
tonerequiredenum | null—
voice_notesrequiredstring | null—
industryrequiredstring | null—
core_pagesrequiredobject[]—
extractedrequiredobject—
graphicsrequiredobject[]—
sourcerequiredstring | null—
updated_atrequiredstring | null—
200401403404

Domains

The domains you send from. Add a domain, publish its DNS records, verify it, and manage its tracking and TLS defaults — sending is blocked until the domain verifies.

List domains

GET/api/v1/email/domains

GET /email/domains
curl -X GET 'https://www.unitpost.com/api/v1/email/domains' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "object": "domain",
      "id": "id_123",
      "name": "Example",
      "status": "pending",
      "records": [
        {
          "type": "string",
          "name": "Example",
          "value": "string",
          "priority": 0
        }
      ],
      "dkim_selector": "string",
      "open_tracking": false,
      "click_tracking": false,
      "tls": "opportunistic",
      "tracking_subdomain": "string",
      "tracking_verified_at": "2026-01-01T00:00:00.000Z",
      "verified_at": "2026-01-01T00:00:00.000Z",
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ]
}

Retrieve a list of your sending domains, cursor-paginated, with each domain's verification status. Add a new one with Add a domain, or fetch a single domain (including its DNS records) with Retrieve a domain. Requires the domains:read capability.

Parameters3 fields
FieldTypeDescription
limitinteger · queryMax rows to return (default 20, max 100).
afterstring · queryReturn rows after this id (forward paging).
beforestring · queryReturn rows before this id (backward paging).
Response · 200 fields14 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
namerequiredstring—
statusrequiredenumpending | verifying | verified | degraded | failed—
recordsrequiredobject[] | null—
dkim_selectorrequiredstring | null—
open_trackingrequiredboolean | null—
click_trackingrequiredboolean | null—
tlsrequiredenumopportunistic | enforced—
tracking_subdomainrequiredstring | null—
tracking_verified_atrequiredstring | nullWhen tracked links and open pixels started using the custom tracking host (`tracking_subdomain`). Non-null only while sends actually use it: the domain is verified (or degraded) and the host's last check proved it answers over HTTPS. Otherwise null, and links use the default tracking host. A host that stops answering, or a domain that's no longer verified, goes back to null.
verified_atrequiredstring | null—
created_atrequiredstring—
updated_atrequiredstring—
200401422

Add a domain

POST/api/v1/email/domains

POST /email/domains
curl -X POST 'https://www.unitpost.com/api/v1/email/domains' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "yourdomain.com"
  }'
Response · 201
{
  "object": "domain",
  "id": "id_123",
  "name": "Example",
  "status": "pending",
  "records": [
    {
      "type": "string",
      "name": "Example",
      "value": "string",
      "priority": 0
    }
  ],
  "dkim_selector": "string",
  "open_tracking": false,
  "click_tracking": false,
  "tls": "opportunistic",
  "tracking_subdomain": "string",
  "tracking_verified_at": "2026-01-01T00:00:00.000Z",
  "verified_at": "2026-01-01T00:00:00.000Z",
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}

Add a domain you'll send email from. The response includes the DNS records (SPF, DKIM, and so on) to publish with your DNS provider. Once they're published, trigger a check with Verify a domain — sending from the domain is blocked until it verifies. Requires the domains:write capability.

Request body1 field
FieldTypeDescription
namerequiredstring—
Response · 201 fields14 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
namerequiredstring—
statusrequiredenumpending | verifying | verified | degraded | failed—
recordsrequiredobject[] | null—
dkim_selectorrequiredstring | null—
open_trackingrequiredboolean | null—
click_trackingrequiredboolean | null—
tlsrequiredenumopportunistic | enforced—
tracking_subdomainrequiredstring | null—
tracking_verified_atrequiredstring | nullWhen tracked links and open pixels started using the custom tracking host (`tracking_subdomain`). Non-null only while sends actually use it: the domain is verified (or degraded) and the host's last check proved it answers over HTTPS. Otherwise null, and links use the default tracking host. A host that stops answering, or a domain that's no longer verified, goes back to null.
verified_atrequiredstring | null—
created_atrequiredstring—
updated_atrequiredstring—
201401409422

Retrieve a domain

GET/api/v1/email/domains/{id}

GET /email/domains/{id}
curl -X GET 'https://www.unitpost.com/api/v1/email/domains/dom_123' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "object": "domain",
  "id": "id_123",
  "name": "Example",
  "status": "pending",
  "records": [
    {
      "type": "string",
      "name": "Example",
      "value": "string",
      "priority": 0
    }
  ],
  "dkim_selector": "string",
  "open_tracking": false,
  "click_tracking": false,
  "tls": "opportunistic",
  "tracking_subdomain": "string",
  "tracking_verified_at": "2026-01-01T00:00:00.000Z",
  "verified_at": "2026-01-01T00:00:00.000Z",
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}

Retrieve a single sending domain by id, including its current verification status and the DNS records you need to publish. Re-run the DNS check with Verify a domain, or change its tracking/TLS settings with Update a domain. Requires the domains:read capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Response · 200 fields14 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
namerequiredstring—
statusrequiredenumpending | verifying | verified | degraded | failed—
recordsrequiredobject[] | null—
dkim_selectorrequiredstring | null—
open_trackingrequiredboolean | null—
click_trackingrequiredboolean | null—
tlsrequiredenumopportunistic | enforced—
tracking_subdomainrequiredstring | null—
tracking_verified_atrequiredstring | nullWhen tracked links and open pixels started using the custom tracking host (`tracking_subdomain`). Non-null only while sends actually use it: the domain is verified (or degraded) and the host's last check proved it answers over HTTPS. Otherwise null, and links use the default tracking host. A host that stops answering, or a domain that's no longer verified, goes back to null.
verified_atrequiredstring | null—
created_atrequiredstring—
updated_atrequiredstring—
200401404

Update a domain

PATCH/api/v1/email/domains/{id}

PATCH /email/domains/{id}
curl -X PATCH 'https://www.unitpost.com/api/v1/email/domains/dom_123' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0' \
  -H 'Content-Type: application/json' \
  -d '{
    "click_tracking": true,
    "open_tracking": true
  }'
Response · 200
{
  "object": "domain",
  "id": "id_123",
  "name": "Example",
  "status": "pending",
  "records": [
    {
      "type": "string",
      "name": "Example",
      "value": "string",
      "priority": 0
    }
  ],
  "dkim_selector": "string",
  "open_tracking": false,
  "click_tracking": false,
  "tls": "opportunistic",
  "tracking_subdomain": "string",
  "tracking_verified_at": "2026-01-01T00:00:00.000Z",
  "verified_at": "2026-01-01T00:00:00.000Z",
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}

Update a domain's sending options: open/click tracking defaults, TLS enforcement, and the tracking subdomain. These act as the domain-level defaults that individual emails and campaigns can override (see Update a campaign). A tracking_subdomain can be set at any time, so you can publish its CNAME along with the domain's other records. It is only checked once the domain is verified, and tracked links switch to it once its CNAME is published and it answers over HTTPS; until then they use the default tracking host. After a change, links keep using the previous subdomain until the new one answers over HTTPS (keep the previous CNAME in place). Changing it is limited to a few times an hour per domain, and to a few new subdomains a day per workspace. Requires the domains:write capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Request body4 fields
FieldTypeDescription
open_trackingboolean | null—
click_trackingboolean | null—
tlsenumopportunistic | enforced—
tracking_subdomainstring | null—
Response · 200 fields14 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
namerequiredstring—
statusrequiredenumpending | verifying | verified | degraded | failed—
recordsrequiredobject[] | null—
dkim_selectorrequiredstring | null—
open_trackingrequiredboolean | null—
click_trackingrequiredboolean | null—
tlsrequiredenumopportunistic | enforced—
tracking_subdomainrequiredstring | null—
tracking_verified_atrequiredstring | nullWhen tracked links and open pixels started using the custom tracking host (`tracking_subdomain`). Non-null only while sends actually use it: the domain is verified (or degraded) and the host's last check proved it answers over HTTPS. Otherwise null, and links use the default tracking host. A host that stops answering, or a domain that's no longer verified, goes back to null.
verified_atrequiredstring | null—
created_atrequiredstring—
updated_atrequiredstring—
200401404422429

Delete a domain

DELETE/api/v1/email/domains/{id}

DELETE /email/domains/{id}
curl -X DELETE 'https://www.unitpost.com/api/v1/email/domains/dom_123' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'

Remove a sending domain from your workspace. Sends that reference an address on this domain will be rejected afterwards, so update your from addresses first. Re-add it any time with Add a domain (you'll need to verify it again). Requires the domains:write capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
204401404

Diagnose a domain's DNS

GET/api/v1/email/domains/{id}/diagnose

GET /email/domains/{id}/diagnose
curl -X GET 'https://www.unitpost.com/api/v1/email/domains/dom_123/diagnose' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "object": "domain_diagnosis",
  "domain_id": "domain_123",
  "domain": "string",
  "status": "queued",
  "checked_at": "2026-01-01T00:00:00.000Z",
  "last_checked_at": "2026-01-01T00:00:00.000Z",
  "would_verify": false,
  "zone": "string",
  "nameservers": [
    {
      "host": "string",
      "ips": [
        "string"
      ]
    }
  ],
  "provider": {
    "configured": false,
    "identity_exists": false,
    "dkim_status": "string",
    "mail_from_status": "string",
    "error": "string"
  },
  "records": [
    {}
  ],
  "findings": [
    {
      "code": "string",
      "severity": "blocking",
      "title": "string",
      "detail": "string",
      "fix": "string"
    }
  ]
}

Explain why a domain is not verified, or what could break it. Every authoritative nameserver is asked about every expected record, the email provider's view of the identity is read, and findings list what is wrong, most serious first, each with the concrete fix. Read-only: it never changes the domain; Verify a domain does. Shares verify's per-workspace DNS rate limit. Requires the domains:read capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Response · 200 fields12 fields
FieldTypeDescription
objectrequiredenumdomain_diagnosis—
domain_idrequiredstring—
domainrequiredstring—
statusrequiredstringThe domain's stored status, next to the live diagnosis.
checked_atrequiredstring—
last_checked_atstringThe last verify check, which this call does not change.
would_verifyrequiredbooleanWhether a verify run now would mark the domain verified.
zonestringThe zone whose nameservers were asked; null when none was found.
nameserversobject[]—
providerobjectThe email provider's view of the sending identity.
recordsrequiredobject[]Every expected record, with each nameserver's answer and a `verdict` (`ok`, `partial`, `wrong`, `missing`, `unknown`).
findingsrequiredobject[]What is wrong, most serious first, each with the concrete `fix`.
200401403404429502

Verify a domain

POST/api/v1/email/domains/{id}/verify

POST /email/domains/{id}/verify
curl -X POST 'https://www.unitpost.com/api/v1/email/domains/dom_123/verify' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'User-Agent: my-app/1.0'
Response · 200
{
  "object": "domain",
  "id": "id_123",
  "name": "Example",
  "status": "pending",
  "records": [
    {
      "type": "string",
      "name": "Example",
      "value": "string",
      "priority": 0
    }
  ],
  "dkim_selector": "string",
  "open_tracking": false,
  "click_tracking": false,
  "tls": "opportunistic",
  "tracking_subdomain": "string",
  "tracking_verified_at": "2026-01-01T00:00:00.000Z",
  "verified_at": "2026-01-01T00:00:00.000Z",
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z",
  "checks": [
    "string"
  ]
}

Run an on-demand DNS check and advance the domain's verification status. The response includes per-record checks so you can see exactly which DNS records are still missing or wrong. checks.mx and checks.spf are true once the record is correct on your nameservers; checks.dkim also needs the email provider to confirm the key. The domain is verified when all three are true. Get the records to publish from Retrieve a domain. Verification also re-runs periodically in the background. If another workspace also added this domain, yours verifies only once the domain's sending identity is yours: your DKIM record is live and theirs is gone on two checks at least 10 minutes apart, or their domain is deleted. While they have it verified, this returns 409 once all your records are live, and nothing is written. Requires the domains:write capability.

Parameters1 field
FieldTypeDescription
idrequiredstring · path—
Response · 200 fields15 fields
FieldTypeDescription
objectrequiredstring—
idrequiredstring—
namerequiredstring—
statusrequiredenumpending | verifying | verified | degraded | failed—
recordsrequiredobject[] | null—
dkim_selectorrequiredstring | null—
open_trackingrequiredboolean | null—
click_trackingrequiredboolean | null—
tlsrequiredenumopportunistic | enforced—
tracking_subdomainrequiredstring | null—
tracking_verified_atrequiredstring | nullWhen tracked links and open pixels started using the custom tracking host (`tracking_subdomain`). Non-null only while sends actually use it: the domain is verified (or degraded) and the host's last check proved it answers over HTTPS. Otherwise null, and links use the default tracking host. A host that stops answering, or a domain that's no longer verified, goes back to null.
verified_atrequiredstring | null—
created_atrequiredstring—
updated_atrequiredstring—
checksobject[]—
200401404409502

Search docs and guides

Search the docs and product guides.