# Unitpost — full documentation
> Unitpost is an email and SMS platform for developers: send transactional and marketing email, send SMS, manage contacts, segments, topics, templates, sending domains, inbound email, webhooks, and suppressions — over a REST API, official SDKs (Node.js, Python, Ruby, PHP, Laravel, Go, Java, Rust, .NET), an SMTP relay, or the Unitpost MCP server for AI agents. It also ships an email design suite: the @unitpost/email component library on npm (works standalone, no account needed — https://unitpost.com/email), a hybrid visual + code template builder, a free template gallery, and a browser playground.
This file inlines every Unitpost guide for one-shot ingestion by an LLM. The curated index is at https://www.unitpost.com/llms.txt; the machine-readable API contract is at https://www.unitpost.com/api/v1/openapi.json.
# Getting started
## How it all fits together
> The contacts → segments → campaigns flow and the rules that keep it safe.
Unitpost has two ways to send email: the API (transactional mail — receipts, password resets, one-to-one messages triggered by your app) and Campaigns (one-to-many marketing sends to an audience). SMS uses the same contacts and segments. A brand is the business carriers register before they approve a number, a sender is the number or Sender ID that can reach a country, and a marketing text needs consent on record and stays inside quiet hours. The SMS guides below are the map of what is different. API setup for each channel lives in the docs.
### The core flow
Three resources form a pipeline, each building on the one before it: Contacts → Segments → Campaigns.
- Contacts are the people you can email — an address plus optional name and custom fields. They carry a marketing subscription state; an unsubscribed or suppressed contact is never mailed.
- Segments are named groups of contacts. A campaign sends to exactly one segment, so a segment is the unit of "who receives this send." Only subscribed members are mailable.
- Campaigns combine a template (the content, authored on the Templates page) with a segment (the audience) and a verified from-domain, then send now or on a schedule.
> **Read the chain bottom-up when something won't send:** A campaign can only send if its template is publishable, its segment has subscribed members, and its from-domain is verified. When a send is blocked, walk back down the chain — domain → segment members → template — and the offending link is almost always one of those three.
### Supporting pieces
- Domains establish sending identity (DKIM/SPF/DMARC). Nothing sends from an address on an unverified domain.
- Templates hold reusable content; the Library stores the images and assets templates reference.
- Emails and Activity are the read side — every individual message and every account event, for debugging and auditing.
- API Keys and Webhooks are the developer surface: keys authenticate the API, webhooks push event notifications back to you.
### Archive, don't delete
Deleting a segment or campaign archives it rather than removing it. Archiving keeps history intact and avoids orphaning references. Archived items are hidden from pickers, can be restored, and are swept ~30 days later once nothing active references them.
> **Active references block destructive actions:** You can't archive a segment that a non-terminal campaign (draft, scheduled, sending) needs — cancel or finish that campaign first. This is a clean, explained stop in both the UI and the API.
Each page has its own guide below. New to the product? Follow the sidebar order: verify a Domain, build a Template, add Contacts, group them into a Segment, then create a Campaign.
## Install the SDK
> Add the official SDK for your language — or use the CLI, or call the API directly.
You can integrate in whichever way fits your stack. The official SDKs wrap the same REST API with idempotent retries and a `{ data, error }` result shape; the CLI (/docs/cli) wraps it for your terminal and CI; or skip the dependency entirely and call the HTTP API directly with cURL or your language's HTTP client.
### 1. Add the SDK
Pick your language tab. The API tab needs nothing installed — any HTTP client works.
**cURL**
```bash
# No package to install — call the REST API with any HTTP client.
# Keep your key in an environment variable:
export UNITPOST_API_KEY="pk_live_..."
```
**CLI**
```bash
# Homebrew (macOS, Linux). Windows, the install script and npm: /docs/cli#install
brew install unitpostcom/tap/unitpost
# Sign in once (OAuth 2.1 + PKCE), or set UNITPOST_API_KEY in CI:
unitpost login
```
**Node.js**
```ts
# npm
npm install unitpost
# pnpm
pnpm add unitpost
# yarn
yarn add unitpost
# Requires Node.js 18+ (uses the built-in fetch).
```
**Python**
```python
# pip
pip install unitpost
# Poetry
poetry add unitpost
# Requires Python 3.8+.
```
**Ruby**
```ruby
# Bundler — add to your Gemfile:
# gem "unitpost"
bundle install
# or install directly:
gem install unitpost
# Requires Ruby 3.0+.
```
**PHP**
```php
# Composer
composer require unitpost/unitpost
# Requires PHP 8.2+.
```
**Laravel**
```php
# Composer — also pulls unitpost/unitpost
composer require unitpost/laravel
# Requires PHP 8.2+ and Laravel 10+.
```
**Go**
```go
go get github.com/unitpostcom/unitpost-go
# Requires Go 1.22+.
```
**Java**
```java
# Maven
#
# com.unitpost
# unitpost
# 0.3.0
#
# Requires Java 17+.
```
**Rust**
```rust
cargo add unitpost
# Requires Rust 1.70+.
```
**.NET**
```csharp
dotnet add package Unitpost
# Requires .NET 8+.
```
### 2. Initialize the client
Construct a client once and reuse it. Each SDK reads UNITPOST_API_KEY from the environment by default, or you can pass the key explicitly. Never hard-code a key in source you commit.
**cURL**
```bash
# Every request carries your key as a Bearer token plus a User-Agent.
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"
```
**CLI**
```bash
# Nothing to construct — the CLI resolves a credential per run:
# --api-key > UNITPOST_API_KEY > your saved `unitpost login`
unitpost whoami # shows which one is in effect (never the secret)
```
**Node.js**
```ts
import { Unitpost } from "unitpost";
// Reads UNITPOST_API_KEY from the environment, or pass it explicitly:
// new Unitpost("pk_live_...")
const unitpost = new Unitpost(process.env.UNITPOST_API_KEY);
```
**Python**
```python
from unitpost import Unitpost
# Reads UNITPOST_API_KEY from the environment, or pass it explicitly:
# Unitpost(api_key="pk_live_...")
unitpost = Unitpost()
```
**Ruby**
```ruby
require "unitpost"
# Reads UNITPOST_API_KEY from the environment, or pass it explicitly:
# Unitpost::Client.new(api_key: "pk_live_...")
unitpost = Unitpost::Client.new
```
**PHP**
```php
'pk_live_...']);
$unitpost = new Client();
```
**Laravel**
```php
use Unitpost\Laravel\Facades\Unitpost;
// Bound from UNITPOST_API_KEY / config/unitpost.php
$result = Unitpost::email()->send([/* ... */]);
```
**Go**
```go
import "github.com/unitpostcom/unitpost-go"
// Reads UNITPOST_API_KEY from the environment, or:
// unitpost.NewWithOptions(unitpost.Options{APIKey: "pk_live_..."})
client := unitpost.New()
```
**Java**
```java
import com.unitpost.Unitpost;
// Reads UNITPOST_API_KEY from the environment, or:
// new Unitpost(java.util.Map.of("apiKey", "pk_live_..."));
var unitpost = new Unitpost();
```
**Rust**
```rust
use unitpost::Unitpost;
// Reads UNITPOST_API_KEY from the environment
let unitpost = Unitpost::new();
```
**.NET**
```csharp
using Unitpost;
// Reads UNITPOST_API_KEY from the environment, or:
// new UnitpostClient(apiKey: "pk_live_...");
var unitpost = new UnitpostClient();
```
> **Keys live in the environment:** Store your API key in an environment variable (UNITPOST_API_KEY) or your secrets manager — not in committed source. Scope it to Sending if the integration only sends mail. See Quickstart (#guide-quickstart) for creating and scoping keys.
That's it — you're ready to send. Head to the Quickstart (#guide-quickstart) to make your first request, or jump straight to Templates & variables (#guide-templates).
## Command line (CLI)
> Send, inspect, and script every API operation from your terminal and CI.
`unitpost` is the official command-line interface — one command per API operation, generated from the same OpenAPI contract as the SDKs. Install it, sign in once, and send from your shell, your scripts, and your agents. The complete reference — flags, profiles, JSON mode, CI, troubleshooting — lives on the CLI docs (/docs/cli) page.
### 1. Install
Homebrew and the install scripts give you a standalone binary with no Node.js needed; the scripts check the download's SHA-256 before installing. With Node.js 20+ you can use npm instead, or `npx` for a one-off.
**Homebrew (macOS, Linux)**
```bash
brew install unitpostcom/tap/unitpost
```
**Install script (macOS, Linux)**
```bash
curl -fsSL https://www.unitpost.com/install.sh | sh
```
**PowerShell (Windows)**
```bash
irm https://www.unitpost.com/install.ps1 | iex
```
**npm (Node.js 20+)**
```bash
npm install -g unitpost-cli
```
Check it worked with `unitpost --version`. For a one-off without installing, run `npx unitpost-cli@latest --help`.
### 2. Sign in
```bash
unitpost login # browser sign-in (OAuth 2.1 + PKCE)
unitpost login --no-browser # prints the URL — for SSH / remote shells
unitpost whoami # which credential is in effect, and its scopes
```
Commands read as `unitpost ` — try `unitpost --help` for all 82, or open the CLI docs (/docs/cli) for auth, profiles, scripting, agents, and CI.
The `unitpost sms` commands need a paid plan. On the free plan, sending a text or an SMS campaign fails with `403` `limit_reached`, and the CLI tells you to upgrade the plan rather than to change your key's scopes. Reading your SMS history works on every plan.
```bash
unitpost --help
unitpost email send --help
unitpost doctor
```
## Quickstart
> Create a key, verify a domain, and send your first email.
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 (/dashboard/api-keys). Choose a scope (Full access, Sending, or on paid plans Read only or your own capability list) and copy the key when shown. What each scope covers: API keys (/guides/api-keys#api-key-scopes).
> **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 (/dashboard/email/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 "`).
**cURL**
```bash
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 ",
"to": "customer@acme.com",
"subject": "Welcome aboard",
"html": "
",
})
```
**Java**
```java
var result = unitpost.email.send(java.util.Map.of(
"from", "Acme ",
"to", "customer@acme.com",
"subject", "Welcome aboard",
"html", "
Thanks for signing up!
"
));
```
**Rust**
```rust
let result = unitpost.email().send(serde_json::json!({
"from": "Acme ",
"to": "customer@acme.com",
"subject": "Welcome aboard",
"html": "
Thanks for signing up!
"
})).await?;
```
**.NET**
```csharp
var result = await unitpost.Email.Send(new {
from = "Acme ",
to = "customer@acme.com",
subject = "Welcome aboard",
html = "
Thanks for signing up!
",
});
```
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 (/dashboard/email/emails) page in the dashboard.
# API reference
## Templates & variables
> Reference a saved template and pass per-recipient data.
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**
```bash
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" }
}
}'
```
**Node.js**
```ts
import { Unitpost } from "unitpost";
const unitpost = new Unitpost(process.env.UNITPOST_API_KEY);
const { data, error } = await unitpost.email.send({
from: "you@yourdomain.com",
to: "customer@acme.com",
template: {
id: "tmpl_123",
variables: { plan: "Pro" },
},
});
```
**Python**
```python
from unitpost import Unitpost
unitpost = Unitpost()
result = unitpost.email.send({
"from": "you@yourdomain.com",
"to": "customer@acme.com",
"template": {
"id": "tmpl_123",
"variables": {"plan": "Pro"},
},
})
```
**Ruby**
```ruby
require "unitpost"
unitpost = Unitpost::Client.new
result = unitpost.email.send({
from: "you@yourdomain.com",
to: "customer@acme.com",
template: {
id: "tmpl_123",
variables: { plan: "Pro" },
},
})
```
**PHP**
```php
require 'vendor/autoload.php';
use Unitpost\Client;
$unitpost = new Client(); // reads UNITPOST_API_KEY
$result = $unitpost->email->send([
"from" => "you@yourdomain.com",
"to" => "customer@acme.com",
"template" => [
"id" => "tmpl_123",
"variables" => ["plan" => "Pro"],
],
]);
```
**Laravel**
```php
use Unitpost\Laravel\Facades\Unitpost;
$result = Unitpost::email()->send([
"from" => "you@yourdomain.com",
"to" => "customer@acme.com",
"template" => [
"id" => "tmpl_123",
"variables" => ["plan" => "Pro"],
],
]);
```
**Go**
```go
import (
"context"
"github.com/unitpostcom/unitpost-go"
)
client := unitpost.New() // reads UNITPOST_API_KEY
data, err := client.Email.Send(ctx, map[string]any{
"from": "you@yourdomain.com",
"to": "customer@acme.com",
"template": map[string]any{
"id": "tmpl_123",
"variables": map[string]any{"plan": "Pro"},
},
})
```
**Java**
```java
import com.unitpost.Unitpost;
var unitpost = new Unitpost(); // reads UNITPOST_API_KEY
var result = unitpost.email.send(java.util.Map.of(
"from", "you@yourdomain.com",
"to", "customer@acme.com",
"template", java.util.Map.of(
"id", "tmpl_123",
"variables", java.util.Map.of("plan", "Pro")
)
));
```
**Rust**
```rust
use unitpost::Unitpost;
let unitpost = Unitpost::new(); // reads UNITPOST_API_KEY
let result = unitpost.email().send(serde_json::json!({
"from": "you@yourdomain.com",
"to": "customer@acme.com",
"template": {
"id": "tmpl_123",
"variables": {"plan": "Pro"}
}
})).await;
```
**.NET**
```csharp
using Unitpost;
var unitpost = new UnitpostClient(); // reads UNITPOST_API_KEY
var result = await unitpost.Email.Send(new
{
from = "you@yourdomain.com",
to = "customer@acme.com",
template = new
{
id = "tmpl_123",
variables = new { 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 ". 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 (/dashboard/email/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 `` 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 (/components).
## Subscription topics
> Let contacts opt out of one kind of mail without leaving your list.
A topic is a subscription category — "Product updates," "Promotions" — that a contact can opt out of. It's independent of the global unsubscribe: a contact can stay on your list but silence one kind of mail.
### Opt-in vs. opt-out
The default_opt_in field decides what happens for a contact who has never touched the topic:
- default_opt_in: true (the default) — an opt-out topic. Contacts are auto-enrolled unless they opt out.
- default_opt_in: false — an opt-in topic. Contacts are silent until they explicitly subscribe.
> **An explicit choice always wins:** Once a contact subscribes or unsubscribes from a topic, that explicit choice overrides the default in both directions. The default only governs contacts with no recorded preference.
### Managing topics
Create, list, update, and archive topics under /api/v1/email/topics. Deleting a topic archives it (soft): it disappears from pickers, but campaigns that used it keep their history.
**cURL**
```bash
# Create an opt-out newsletter topic
curl https://www.unitpost.com/api/v1/email/topics \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Product updates", "default_opt_in": true }'
# Opt a contact out of it (id or email in the path)
curl -X PUT https://www.unitpost.com/api/v1/contacts/customer@acme.com/topics \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "topic_id": "top_123", "subscribed": false }'
```
**Node.js**
```ts
import { Unitpost } from "unitpost";
const unitpost = new Unitpost(process.env.UNITPOST_API_KEY);
// Create a topic
const { data: topic } = await unitpost.email.topics.create({
name: "Product updates",
default_opt_in: true,
});
// Opt a contact out of it (id or email in the path)
await unitpost.email.topics.setTopic("customer@acme.com", {
topic_id: topic.id,
subscribed: false,
});
```
**Python**
```python
from unitpost import Unitpost
unitpost = Unitpost() # reads UNITPOST_API_KEY
# Create a topic
topic = unitpost.email.topics.create({
"name": "Product updates",
"default_opt_in": True,
}).data
# Opt a contact out of it (id or email in the path)
unitpost.email.topics.set_topic("customer@acme.com", {
"topic_id": topic["id"],
"subscribed": False,
})
```
**Ruby**
```ruby
require "unitpost"
unitpost = Unitpost::Client.new # reads UNITPOST_API_KEY
# Create a topic
topic = unitpost.email.topics.create({
name: "Product updates",
default_opt_in: true,
}).data
# Opt a contact out of it (id or email in the path)
unitpost.email.topics.set_topic("customer@acme.com", {
topic_id: topic["id"],
subscribed: false,
})
```
**PHP**
```php
require 'vendor/autoload.php';
use Unitpost\Client;
$unitpost = new Client(); // reads UNITPOST_API_KEY
// Create a topic
$topic = $unitpost->email->topics->create([
"name" => "Product updates",
"default_opt_in" => true,
])->data;
// Opt a contact out of it (id or email in the path)
$unitpost->email->topics->setTopic("customer@acme.com", [
"topic_id" => $topic["id"],
"subscribed" => false,
]);
```
**Laravel**
```php
use Unitpost\Laravel\Facades\Unitpost;
// Create a topic
$topic = Unitpost::email()->topics->create([
"name" => "Product updates",
"default_opt_in" => true,
])->data;
// Opt a contact out of it (id or email in the path)
Unitpost::email()->topics->setTopic("customer@acme.com", [
"topic_id" => $topic["id"],
"subscribed" => false,
]);
```
**Go**
```go
import (
"context"
"github.com/unitpostcom/unitpost-go"
)
client := unitpost.New() // reads UNITPOST_API_KEY
// Create a topic
topic, err := client.Email.Topics.Create(ctx, map[string]any{
"name": "Product updates",
"default_opt_in": true,
})
// Opt a contact out of it (id or email in the path)
client.Email.Topics.SetTopic(ctx, "customer@acme.com", map[string]any{
"topic_id": topic["id"],
"subscribed": false,
})
```
**Java**
```java
import com.unitpost.Unitpost;
var unitpost = new Unitpost(); // reads UNITPOST_API_KEY
// Create a topic
var topic = unitpost.email.topics.create(java.util.Map.of(
"name", "Product updates",
"default_opt_in", true
));
// Opt a contact out of it (id or email in the path)
unitpost.email.topics.setTopic("customer@acme.com", java.util.Map.of(
"topic_id", String.valueOf(((java.util.Map, ?>) topic).get("id")),
"subscribed", false
));
```
**Rust**
```rust
use unitpost::Unitpost;
let unitpost = Unitpost::new(); // reads UNITPOST_API_KEY
// Create a topic
let topic = unitpost.email().topics().create(serde_json::json!({
"name": "Product updates",
"default_opt_in": true
})).await;
// Opt a contact out of it (id or email in the path)
unitpost.email().topics().set_topic("customer@acme.com", serde_json::json!({
"topic_id": topic["id"],
"subscribed": false
})).await;
```
**.NET**
```csharp
using Unitpost;
var unitpost = new UnitpostClient(); // reads UNITPOST_API_KEY
// Create a topic
var topic = await unitpost.Email.Topics.Create(new
{
name = "Product updates",
default_opt_in = true,
});
// Opt a contact out of it (id or email in the path)
await unitpost.Email.Topics.SetTopic("customer@acme.com", new
{
topic_id = topic.GetProperty("id").GetString(),
subscribed = false,
});
```
### Per-contact preferences
GET /api/v1/contacts/{id}/topics returns a contact's effective subscription for every topic — folding in each topic's default so you get the real answer, not just rows they've touched. PUT the same path with { topic_id, subscribed } to set one, or PATCH it with { topics: [{ topic_id, subscribed }, …] } to change up to 50 at once and leave the rest as they are. The path accepts a contact id or email; an unknown topic returns 404. This is the same thing a contact does by toggling a topic on the unsubscribe preference page.
To opt many contacts out of one topic, POST /api/v1/email/topics/{id}/opt-out with { contact_ids } or { emails } (up to 1,000) or { segment_id } (every member). It runs as a background job: the answer is the job, which you follow with GET /api/v1/jobs/{id}. Each contact is opted out as the PUT above would do it, with a topic.unsubscribed webhook only for those who were subscribed. There is no bulk opt-in: a contact joins a topic because they asked.
### Scoping a campaign
Pass topic_id when you create a campaign to scope it. At send, contacts opted out of that topic are skipped (and counted as suppressed in the validate report) — on top of the usual segment-membership and global-unsubscribe checks. You can't point a campaign at an archived or missing topic: create and edit return a 409, and validate/send report a TOPIC_ARCHIVED or TOPIC_MISSING blocker.
> **One-click unsubscribe respects the topic:** Mail sent for a topic-scoped campaign carries a topic-aware unsubscribe link and RFC 8058 List-Unsubscribe-Post header, so a recipient's one-click opt-out silences just that topic rather than all of your marketing.
## Sending domains
> Add SPF, DKIM, and DMARC so mail sends from your domain.
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 (/dashboard/email/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 (/acceptable-use#prohibited).
## Open & click tracking
> Control engagement tracking per send, template, or category.
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:
- An explicit tracking flag on the request wins.
- Otherwise the referenced template's default applies.
- 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.
## Batch sending
> Send up to 100 distinct messages — schedule and cancel as a unit.
POST to /api/v1/email/batch to send many distinct messages in one request. Each entry is its own message with its own recipient, subject, and data. This is not one message to many people — for that, use a campaign.
The body is a JSON array of message objects, or an object { emails: [...], scheduled_at: "" } to schedule the batch. Validation is all-or-nothing: if any item is invalid, nothing is queued.
### Group, inspect, cancel
Read a live rollup with GET /api/v1/email/batches/{id}, and stop every still-scheduled message with POST /api/v1/email/batches/{id}/cancel. In the dashboard, open Emails and set Filters → Show to Batches (/dashboard/email/emails?status=batches).
**cURL**
```bash
# Schedule the whole batch
curl https://www.unitpost.com/api/v1/email/batch \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"scheduled_at": "2026-07-01T15:00:00Z",
"emails": [
{ "from": "you@yourdomain.com", "to": "a@example.com", "subject": "Hi", "html": "
" }
]
}'
# Cancel the whole batch before it sends
curl -X POST https://www.unitpost.com/api/v1/email/batches/$BATCH_ID/cancel \
-H "Authorization: Bearer $API_KEY"
```
**Node.js**
```ts
import { Unitpost } from "unitpost";
const unitpost = new Unitpost(process.env.UNITPOST_API_KEY);
// Scheduled batch — fires as a unit, cancelable as a unit
const { data } = await unitpost.email.batch({
scheduled_at: "2026-07-01T15:00:00Z",
emails: [
{ from: "you@yourdomain.com", to: "a@example.com", subject: "Hi", html: "
"],
],
])->data;
// Cancel the whole batch before it sends
$unitpost->email->cancelBatch($batch["id"]);
```
**Laravel**
```php
use Unitpost\Laravel\Facades\Unitpost;
// Scheduled batch — fires as a unit, cancelable as a unit
$batch = Unitpost::email()->batch([
"scheduled_at" => "2026-07-01T15:00:00Z",
"emails" => [
["from" => "you@yourdomain.com", "to" => "a@example.com", "subject" => "Hi", "html" => "
"},
},
})
// Cancel the whole batch before it sends
client.Email.CancelBatch(ctx, batch["id"].(string))
```
**Java**
```java
import com.unitpost.Unitpost;
var unitpost = new Unitpost(); // reads UNITPOST_API_KEY
// Scheduled batch — fires as a unit, cancelable as a unit
var batch = unitpost.email.batch(java.util.Map.of(
"scheduled_at", "2026-07-01T15:00:00Z",
"emails", java.util.List.of(
java.util.Map.of("from", "you@yourdomain.com", "to", "a@example.com", "subject", "Hi", "html", "
")
)
));
// Cancel the whole batch before it sends
unitpost.email.cancelBatch(String.valueOf(((java.util.Map, ?>) batch).get("id")));
```
**Rust**
```rust
use unitpost::Unitpost;
let unitpost = Unitpost::new(); // reads UNITPOST_API_KEY
// Scheduled batch — fires as a unit, cancelable as a unit
let batch = unitpost.email().batch(serde_json::json!({
"scheduled_at": "2026-07-01T15:00:00Z",
"emails": [
{"from": "you@yourdomain.com", "to": "a@example.com", "subject": "Hi", "html": "
"}
]
})).await;
// Cancel the whole batch before it sends
unitpost.email().cancel_batch(batch["id"].as_str().unwrap()).await;
```
**.NET**
```csharp
using Unitpost;
var unitpost = new UnitpostClient(); // reads UNITPOST_API_KEY
// Scheduled batch — fires as a unit, cancelable as a unit
var batch = await unitpost.Email.Batch(new
{
scheduled_at = "2026-07-01T15:00:00Z",
emails = new[]
{
new { from = "you@yourdomain.com", to = "a@example.com", subject = "Hi", html = "
…
" },
new { from = "you@yourdomain.com", to = "b@example.com", subject = "Hi", html = "
…
" },
},
});
// Cancel the whole batch before it sends
await unitpost.Email.CancelBatch(batch.GetProperty("id").GetString());
```
> **Batch limitations:** Per-item scheduled_at and attachments are not supported in batch — schedule the batch as a unit, or send individually if you need per-message attachments.
## Scheduling & canceling sends
> Schedule for later and recall a send before it leaves.
Any send can be scheduled for the future with scheduled_at. Until a message is handed to the provider it sits in a scheduled (or queued) state and can be canceled.
### A single email
From the dashboard, open the email on the Emails (/dashboard/email/emails) page and click Cancel. Via the API, PATCH /api/v1/email/{id} with { "cancel": true }. You can also reschedule a still-pending email by PATCHing a new scheduled_at.
### A whole batch
POST /api/v1/email/batches/{id}/cancel stops every still-scheduled message in the batch. Already-sent messages are unaffected. In the dashboard this is the Cancel batch button on a batch under Emails, Filters → Show → Batches (/dashboard/email/emails?status=batches).
### A campaign
Unschedule a scheduled campaign to edit it (`POST /api/v1/email/campaigns/{id}/unschedule`, or `/sms/campaigns/{id}/unschedule`): it becomes a draft again with no send time. Cancel stops one that is scheduled, sending or paused (`POST …/cancel`); a canceled campaign can't be restarted, only duplicated. See Campaigns (/guides/campaigns).
> **Once it's out, it's out:** After a message has been handed off for delivery it can no longer be recalled. Cancel only works while a send is still scheduled or queued.
## Idempotency & retries
> Safely retry sends without duplicating mail.
Networks fail mid-request. To retry safely, pass an Idempotency-Key header on send requests. If a request with the same key is retried we return the original result instead of sending again.
A retried email send returns the first answer: the same `id` and the same `status` (`queued`, or `scheduled` for a later send), even if the email has been delivered or failed since. The response carries an `Idempotent-Replayed: true` header so you can tell it's a replay. Read the email's current state with `GET /email/{id}`.
The same header works on `POST /email/batch`, `POST /sms` and on duplicating a campaign (`POST /email/campaigns/{id}/duplicate`, `POST /sms/campaigns/{id}/duplicate`), where a retry returns the same copy. A key belongs to one request: reusing it on a different request is a `409`.
> **Use a stable key per logical send:** Derive the key from your own event ID (e.g. order ID) so retries reuse the same key and deduplicate. Keys are scoped to your workspace and retained to cover realistic retry windows.
### Rate limits
Exceed the limit and you get a 429 with a Retry-After header — back off and retry. For large fan-outs, prefer a campaign (/dashboard/email/campaigns) or a batch. Rate limits (/docs/rate-limits) has the details.
A heavy read that runs too long, such as a segment count or a search over a very large audience, is stopped after a few seconds with a 503 and the code `operation_timed_out`, plus a Retry-After header. Nothing changed; retry after the wait. Our SDKs and CLI retry it for you.
### Fields a request doesn't read
A misspelled field shouldn't pass quietly. When a request succeeds but ignored a body field it doesn't read, the response names it in a `Unitpost-Warning` header, with the field you probably meant (`fist_name` → `first_name`). Send `Unitpost-Strict: true` to have such a request refused with a 422 instead, before anything changes. Newer operations always refuse unknown fields.
## Rate limits
> How many requests you can make, what a 429 looks like, and how the SDKs, CLI and MCP server handle it.
Every workspace has one request budget, shared by all of its API keys, the MCP server, the CLI and apps you sign in to with OAuth: 10 requests per second by default. If you need more, contact support; we can raise it for your workspace.
### Headers
Every response from `/api/v1` carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (when the window resets, as a Unix time). Over the limit, the API answers `429` with the code `rate_limit_exceeded` and a `Retry-After` header in seconds. Nothing ran, so the same request is safe to send again after that wait.
### Limits on single operations
Some operations have a limit of their own on top of the workspace budget, because each call costs money, reaches outside Unitpost or scans a lot of data. Their 429 has a `Retry-After` but no `RateLimit-Limit`. Wait as long as it says.
- Sends that test or reach outside Unitpost: test sends of a template, email or SMS (per workspace, and per person for a sign-in), SMS opt-in requests, SMS test numbers, webhook tests, replays and flushes.
- Domains and receiving: domain checks and diagnoses, adding domains, changing a tracking subdomain, turning receiving on.
- Searches and heavy reads, which share one limit: a text search (`q`) of the email, SMS, activity and suppression lists and of conversations; the email list's `source` and `outcome` filters, the SMS list's `type` and the activity log's `channel=email`; segment previews; and an API key's usage. The request log (`GET /logs`) has its own.
- Bulk work and rendering: contact imports and their retries, contact exports, topic opt-outs for many contacts, rendering a template.
- Brand: Brand Kit writes and imports, image uploads (to the library, the logo, the workspace image and Brand Kit graphics).
- SMS setup and purchases: brand writes, campaign checks, compliance checks, quotes and confirms (which share the dashboard's purchase limits).
### SDKs, CLI and MCP
- The SDKs retry a `429`, `5xx` errors and timeouts for reads and for writes that carry an idempotency key (sends and batches always do), so a retry never sends twice. On a `429` they wait the `Retry-After` it names, up to 20 seconds. Any other write that gets a `429` comes back to your code as an error. Most SDKs let you change or turn off retries; see each README.
- The CLI retries the same way, and also retries any request on a `429`.
- MCP tool calls that reach the API draw on the same workspace budget, one request each (the docs and design-library tools don't). A rate-limited tool call's error carries `retry_after_seconds`: wait that long before calling again.
### The dashboard and Ask Unit
The dashboard has a separate, generous limit on changes per person and per workspace, and Ask Unit on messages per minute. A tool Ask Unit runs counts like the same action in the dashboard.
## Bounces, complaints & suppression
> How bad addresses are handled and where to review them.
When an address hard bounces or complains, we add it to your suppression list (/dashboard/contacts?tab=suppressions). 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 (/dashboard/contacts?tab=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. Before your first big send, read the deliverability guides (/blog/deliverability).
### 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. More in How to warm up a new sending domain (/blog/deliverability/warm-up-new-sending-domain).
### 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 (/dashboard/email/domains); a policy of `p=none` is enough to start. What Gmail, Yahoo and Microsoft require: bulk sender requirements (/blog/deliverability/bulk-sender-requirements-gmail-yahoo-microsoft).
### 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. More in How often to send marketing email (/blog/deliverability/email-sending-frequency).
### The send time
A campaign's saved send time is armed when you press Send: until then the campaign stays a draft, and the campaign list and page show the time as saved, not scheduled. 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 (/dashboard/email/topics), 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**
```bash
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 ",
"to": "ada@example.com",
"subject": "Your weekly digest",
"html": "
",
"headers": {
"List-Unsubscribe": ", ",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
}
}'
```
**CLI**
```bash
unitpost email send \
--from "Jobs " --to ada@example.com \
--subject "Your weekly digest" --html @digest.html \
--headers "List-Unsubscribe=, " \
--headers "List-Unsubscribe-Post=List-Unsubscribe=One-Click"
```
**Node.js**
```ts
const { data, error } = await unitpost.email.send({
from: "Jobs ",
to: "ada@example.com",
subject: "Your weekly digest",
html: digestHtml,
headers: {
"List-Unsubscribe": ", ",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
},
});
```
**Python**
```python
result = unitpost.email.send({
"from": "Jobs ",
"to": "ada@example.com",
"subject": "Your weekly digest",
"html": digest_html,
"headers": {
"List-Unsubscribe": ", ",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
},
})
```
**Ruby**
```ruby
result = unitpost.email.send({
from: "Jobs ",
to: "ada@example.com",
subject: "Your weekly digest",
html: digest_html,
headers: {
"List-Unsubscribe" => ", ",
"List-Unsubscribe-Post" => "List-Unsubscribe=One-Click",
},
})
```
**PHP**
```php
$result = $unitpost->email->send([
'from' => 'Jobs ',
'to' => 'ada@example.com',
'subject' => 'Your weekly digest',
'html' => $digestHtml,
'headers' => [
'List-Unsubscribe' => ', ',
'List-Unsubscribe-Post' => 'List-Unsubscribe=One-Click',
],
]);
```
**Laravel**
```php
$result = Unitpost::email()->send([
'from' => 'Jobs ',
'to' => 'ada@example.com',
'subject' => 'Your weekly digest',
'html' => $digestHtml,
'headers' => [
'List-Unsubscribe' => ', ',
'List-Unsubscribe-Post' => 'List-Unsubscribe=One-Click',
],
]);
```
**Go**
```go
data, err := client.Email.Send(ctx, map[string]any{
"from": "Jobs ",
"to": "ada@example.com",
"subject": "Your weekly digest",
"html": digestHTML,
"headers": map[string]string{
"List-Unsubscribe": ", ",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
},
})
```
**Java**
```java
var result = unitpost.email.send(java.util.Map.of(
"from", "Jobs ",
"to", "ada@example.com",
"subject", "Your weekly digest",
"html", digestHtml,
"headers", java.util.Map.of(
"List-Unsubscribe", ", ",
"List-Unsubscribe-Post", "List-Unsubscribe=One-Click"
)
));
```
**Rust**
```rust
let result = unitpost.email().send(serde_json::json!({
"from": "Jobs ",
"to": "ada@example.com",
"subject": "Your weekly digest",
"html": digest_html,
"headers": {
"List-Unsubscribe": ", ",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
}
})).await?;
```
**.NET**
```csharp
var result = await unitpost.Email.Send(new {
from = "Jobs ",
to = "ada@example.com",
subject = "Your weekly digest",
html = digestHtml,
headers = new Dictionary {
["List-Unsubscribe"] = ", ",
["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click",
},
});
```
**Over SMTP (Nodemailer)**
```ts
await transporter.sendMail({
from: "Jobs ",
to: "ada@example.com",
subject: "Your weekly digest",
html: digestHtml,
headers: {
"List-Unsubscribe": ", ",
"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**
```bash
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 ",
"to": "ada@example.com",
"subject": "Your weekly digest",
"text": "Jobs for you this week. Reply to this message to unsubscribe.",
"headers": { "List-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.
| Signal | Judged once there are | Warning at | Paused at |
| --- | --- | --- | --- |
| Addresses that don't exist (hard bounces), per message sent | 5 hard bounces | — | 10% |
| Addresses that don't exist (hard bounces), per message sent | 10 hard bounces | 2% | 5% |
| Spam complaints, per message delivered | 3 complaints | 0.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.
## Webhooks
> Receive signed event notifications at your own endpoint.
Webhooks let your application react to events as they happen — update a record on delivery, or sync a contact. Register an endpoint under Developers → Webhooks (or via the API), choose events, and we POST a signed JSON payload to your URL.
> **Endpoint URLs must be public HTTPS:** We only deliver to https:// URLs that resolve to a public address. http:// endpoints and URLs that resolve to private, loopback, or link-local ranges (localhost, 127.0.0.1, 10.0.0.0/8, 169.254.0.0/16, etc.) are rejected when you save the endpoint — a deliberate SSRF guard. Develop against a tunnel (ngrok, Cloudflare Tunnel) that gives you a public HTTPS URL.
### Event types
Events are namespaced by category as category.event. Subscribe to individual events, or to a whole category with a wildcard like email.* — handy when you want everything in a category without re-editing the endpoint as we add events.
- email.sent, email.delivered, email.delivery_delayed, email.bounced, email.complained, email.opened, email.clicked, email.failed, email.scheduled, email.suppressed
- domain.created, domain.updated, domain.deleted
- contact.created, contact.updated, contact.deleted
- segment.joined, segment.left
- topic.subscribed, topic.unsubscribed
- campaign.paused, campaign.resumed
> **Which events an endpoint receives:** An endpoint receives the events that happen while it is subscribed to them, from the moment you save it. Events from before you create an endpoint, or add an event type to one, are not sent later. If we disable an endpoint after repeated failures, we keep its events, and Retry queued sends them once you re-enable it. An endpoint you disable yourself receives nothing until you enable it again.
### Payload
Every delivery is the same JSON envelope: a type (the dotted event name), an ISO created_at timestamp, an event_id (the event's stable id — the same event always carries the same event_id, so you can dedupe on the body even if two deliveries arrive with different svix-ids), and a data object. The envelope and signing are identical for every event — only data varies by event. We send three headers you use to verify it (the scheme is compatible with Svix's, so existing Svix libraries work): svix-id, svix-timestamp, and svix-signature.
```bash
POST /your-endpoint HTTP/1.1
svix-id: msg_2abc...
svix-timestamp: 1735689600
svix-signature: v1,g0hM9SsE...
content-type: application/json
{
"type": "email.delivered",
"created_at": "2026-01-01T00:00:00.000Z",
"event_id": "f6a1f1f6-6a86-4f8f-9d2e-1c2b3a4d5e6f",
"data": { "email_id": "email_2Wxyz123Example", "to": "delivered@example.com" }
}
```
> **Field conventions:** Field names are snake_case. Email events identify the message as email_id; domain and contact events use the resource's own id. The type is only on the envelope — it is never duplicated inside data. Optional fields are always present (as null) rather than omitted, so you can rely on a stable shape.
### Email event data
email.sent, email.delivered, email.delivery_delayed, email.bounced, email.complained, email.opened, email.clicked and email.failed share the same shape: the message id and its recipient(s). email.scheduled and email.suppressed add a few fields (below).
email.delivery_delayed means the provider is still retrying, and a later email.delivered or email.bounced will follow. email.bounced means the message is finished and was not delivered, for both hard and soft bounces: check bounce.type ("Permanent" or "Transient") to tell them apart. A Transient bounce doesn't suppress the address on its own: three in a row do, and so does one to a domain that doesn't exist. Since 2026-10-10, a soft bounce the provider has stopped retrying is sent as email.bounced (it was previously sent as email.delivery_delayed), so subscribe to email.bounced to receive it.
```bash
// email.sent | email.delivered | email.bounced | email.opened | ...
{
"type": "email.delivered",
"created_at": "2026-01-01T00:00:00.000Z",
"data": {
"email_id": "email_2Wxyz123Example",
"to": "delivered@example.com"
}
}
// email.scheduled — adds the deferred-send context
{
"type": "email.scheduled",
"created_at": "2026-01-01T00:00:00.000Z",
"data": {
"email_id": "email_2Wxyz123Example",
"to": "marie@example.com",
"from": "hello@example.com",
"subject": "Your weekly digest",
"scheduled_at": "2026-01-01T09:00:00.000Z",
"batch_id": null
}
}
// email.suppressed — who was skipped and why
{
"type": "email.suppressed",
"created_at": "2026-01-01T00:00:00.000Z",
"data": {
"email_id": "email_2Wxyz123Example",
"to": "bounced@example.com",
"suppressed": ["bounced@example.com"],
"reasons": [{ "email": "bounced@example.com", "reason": "BOUNCE" }],
"full": true
}
}
```
### Domain event data
domain.created, domain.updated and domain.deleted carry the domain id, name and status. domain.updated fires on a status transition, a settings change and when receiving is turned on or off (from the dashboard, the API or an agent). previous_status is populated only on a status transition (e.g. PENDING → VERIFIED); it's null otherwise. Read the domain for what changed.
```bash
{
"type": "domain.updated",
"created_at": "2026-01-01T00:00:00.000Z",
"data": {
"id": "dom_2Wxyz123Example",
"name": "example.com",
"status": "VERIFIED",
"previous_status": "PENDING"
}
}
```
### Contact event data
contact.created, contact.updated and contact.deleted carry the contact id, email, name fields (null when unset) and the marketing unsubscribed flag. A contact created because you sent a marketing template to a new address also carries source: "send" and channel: "email" on contact.created, so you can tell it from a sign-up.
```bash
{
"type": "contact.updated",
"created_at": "2026-01-01T00:00:00.000Z",
"data": {
"id": "con_2Wxyz123Example",
"email": "marie@example.com",
"first_name": "Marie",
"last_name": "Curie",
"unsubscribed": false
}
}
```
### Campaign event data
campaign.paused fires when you pause an email campaign and when an automatic check pauses it (too many bounces or complaints, a billing limit, a sender that stopped verifying, a restriction on the account). pause_reason is the same object as on the campaign in the API. campaign.resumed fires when it resumes, with pause_reason null.
```bash
{
"type": "campaign.paused",
"created_at": "2026-10-19T09:14:03.000Z",
"data": {
"campaign_id": "cmp_2WcampaignExampl1",
"name": "October newsletter",
"status": "paused",
"pause_reason": {
"code": "hard_bounce_rate",
"message": "We paused this campaign: 98 of 1,400 addresses don't exist (7%). Those addresses are already suppressed; check the list, then resume.",
"metric": "hard_bounce_rate",
"value": 0.07,
"threshold": 0.05,
"sample": { "events": 98, "of": 1400 },
"window": { "since": "2026-10-19T09:00:00.000Z" },
"decided_by": "floor",
"resume": "self_serve",
"paused_at": "2026-10-19T09:14:03.000Z"
}
}
}
```
### Verifying signatures
Every webhook is signed with a per-endpoint secret (whsec_...) shown once when you create the endpoint or rotate its secret. Verify the signature before trusting any payload. The signature is an HMAC-SHA256 over `${svix-id}.${svix-timestamp}.${raw_body}`, base64-encoded. Always use the RAW request body — re-serializing the parsed JSON changes the bytes and breaks the signature.
**Node.js**
```ts
import { verifyWebhook } from "unitpost";
// Pass the RAW request body — re-serializing breaks the signature.
const event = verifyWebhook({
payload: rawBody,
secret: process.env.UNITPOST_WEBHOOK_SECRET, // whsec_…
headers: {
"svix-id": headers["svix-id"],
"svix-timestamp": headers["svix-timestamp"],
"svix-signature": headers["svix-signature"],
},
});
if (event.type === "email.bounced") {
// …handle the bounce
}
```
**Python**
```python
from unitpost import verify_webhook
# Pass the RAW body — re-serializing breaks the signature.
event = verify_webhook(
payload=raw_body,
secret=os.environ["UNITPOST_WEBHOOK_SECRET"], # whsec_…
headers={
"svix-id": headers["svix-id"],
"svix-timestamp": headers["svix-timestamp"],
"svix-signature": headers["svix-signature"],
},
)
if event["type"] == "email.bounced":
... # handle the bounce
```
**Ruby**
```ruby
require "unitpost"
# Pass the RAW body — re-serializing breaks the signature.
event = Unitpost::Webhooks.verify(
payload: request.raw_post,
secret: ENV["UNITPOST_WEBHOOK_SECRET"], # whsec_…
headers: {
"svix-id" => request.headers["svix-id"],
"svix-timestamp" => request.headers["svix-timestamp"],
"svix-signature" => request.headers["svix-signature"],
}
)
handle_bounce(event) if event["type"] == "email.bounced"
```
**PHP**
```php
require 'vendor/autoload.php';
use Unitpost\WebhookVerificationError;
use function Unitpost\verifyWebhook;
// Pass the RAW body — re-serializing breaks the signature.
try {
$event = verifyWebhook(
$payload,
getenv('UNITPOST_WEBHOOK_SECRET'), // whsec_…
[
'svix-id' => $_SERVER['HTTP_SVIX_ID'] ?? null,
'svix-timestamp' => $_SERVER['HTTP_SVIX_TIMESTAMP'] ?? null,
'svix-signature' => $_SERVER['HTTP_SVIX_SIGNATURE'] ?? null,
],
);
} catch (WebhookVerificationError $e) {
http_response_code(401);
}
```
**Laravel**
```php
use Illuminate\Http\Request;
use Unitpost\WebhookVerificationError;
use function Unitpost\verifyWebhook;
public function __invoke(Request $request)
{
// Pass the RAW body — re-serializing breaks the signature.
try {
$event = verifyWebhook(
$request->getContent(),
config('services.unitpost.webhook_secret'), // whsec_…
[
'svix-id' => $request->header('svix-id'),
'svix-timestamp' => $request->header('svix-timestamp'),
'svix-signature' => $request->header('svix-signature'),
],
);
} catch (WebhookVerificationError $e) {
abort(401);
}
}
```
**Go**
```go
import (
"github.com/unitpostcom/unitpost-go"
)
// Pass the RAW body — re-serializing breaks the signature.
event, err := unitpost.VerifyWebhook(string(rawBody), secret, r.Header, 300)
if err != nil {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
if event["type"] == "email.bounced" {
// …handle the bounce
}
```
**Java**
```java
import com.unitpost.WebhookVerificationError;
import com.unitpost.WebhooksVerify;
// Pass the RAW body — re-serializing breaks the signature.
try {
Object event = WebhooksVerify.verifyWebhook(
payload, // the raw request body string
secret, // whsec_…
headers // Map of svix-id / svix-timestamp / svix-signature
);
} catch (WebhookVerificationError e) {
// 401 — bad or stale signature
}
```
**Rust**
```rust
use std::collections::HashMap;
use unitpost::{verify_webhook, WebhookVerificationError};
// Pass the RAW body — re-serializing breaks the signature.
match verify_webhook(&payload, &secret, &headers, 300) {
Ok(event) => {
if event["type"] == "email.bounced" {
// …handle the bounce
}
}
Err(WebhookVerificationError { .. }) => {
// 401 — bad or stale signature
}
}
```
**.NET**
```csharp
using Unitpost;
// Pass the RAW body — re-serializing breaks the signature.
try
{
var evt = WebhooksVerify.Verify(
payload, // the raw request body string
secret, // whsec_…
headers // Dictionary of svix-id / svix-timestamp / svix-signature
);
}
catch (WebhookVerificationException)
{
// 401 — bad or stale signature
}
```
> **Reject stale timestamps:** The svix-timestamp is part of the signed content. After verifying the signature, reject deliveries whose timestamp is outside a tolerance window (e.g. ±5 minutes) to defeat replay attacks.
> **Test before you ship:** Use the Send test action on any endpoint (dashboard or API) to fire a sample signed event to confirm your verification works end to end. The test payload uses the real event shape.
### Rotating the signing secret
The signing secret is shown once. If you lose it, rotate the secret from the ⋯ menu to generate a fresh one. Rotation takes effect immediately.
### Retries & failures
If your endpoint doesn't return a 2xx, we retry with backoff up to 5 attempts. Acknowledge with a 2xx quickly, then do slower work asynchronously. Permanent errors (4xx) are not retried. Transient failures (5xx, timeouts) are retried.
Each endpoint shows a live status: Enabled, Failing, or Disabled. After sustained failures we disable an endpoint to stop sending to a dead URL. Re-enable it to resume delivery.
> **Event retention:** Delivery attempts and their payloads are retained for 30 days, then pruned. Persist anything you need to keep when you receive it.
> **Permissions:** Viewing webhooks requires the webhooks:read capability; creating, updating, and deleting require webhooks:manage. API keys can hold these like any other capability.
## Inbound routing
> Receive emails at your domain and get clean JSON POSTed to your webhook.
Inbound routing turns incoming email into structured data. When someone replies to your campaign or emails a custom address on your domain, we receive the raw MIME message, parse it, and POST a clean JSON payload to your webhook endpoint.
### Setup
- Enable receiving: In the dashboard under Domains, click on your verified domain and go to the Receiving tab to enable inbound routing. From code or an agent, send `PATCH /email/domains/{id}` with `{ "capabilities": { "receiving": "enabled" } }` (MCP `email_domains_update`, CLI `unitpost email domains update --capabilities '{"receiving":"enabled"}'`). The domain must be verified.
- Publish the MX record: Add the MX record shown on the domain (the API returns it as `receiving.record`) at your DNS provider, then verify the domain. Receiving is live once `receiving.status` is `verified`.
- Add a webhook: Create a webhook endpoint that listens for the `email.received` event.
> **Dedicated receiving domains:** We recommend using a dedicated subdomain for inbound routing (e.g. `reply.yourdomain.com`) so its MX records don't conflict with your primary corporate email (Google Workspace, Office 365).
### The payload
When an email arrives, we POST an `email.received` event with the message's metadata: sender, recipients, subject, Message-ID and attachment metadata. Fetch the body (text and HTML) and the SPF, DKIM, DMARC, spam and virus verdicts with `GET /email/received/{id}`, which lists attachments as metadata. Get each attachment's bytes from `GET /email/received/{id}/attachments/{attachmentId}`: it returns a signed download URL that expires after about five minutes.
## API changelog
> What changed in the API, MCP server, SDKs and CLI, newest first.
New operations, new fields and changes in behavior, newest first. A behavior change says what a caller that relied on the old behavior now sees. SDK and CLI releases list their own changes in their changelogs.
### 2026-11-03: Sends leave out example.com and other reserved domains
Behavior changes
- A `to`, `cc` or `bcc` address on a domain reserved for examples (`example.com`, `example.net`, `example.org` or a subdomain of them) is left out of `POST /email`, and the other recipients get the email. Those domains can't receive mail, so the send used to bounce and add the address to your suppression list. The response names each address it left out, with its field, in `Unitpost-Warning`; it isn't billed. Send `Unitpost-Strict: true` to have such a send refused with a `422` instead. `reply_to` isn't checked.
- With no `to` address left, `POST /email` answers `422 validation_error`, one `details[]` entry per reserved address. Send test emails to an inbox you own.
- `POST /email/batch` does the same per item: a mixed item goes to its other recipients, named in `Unitpost-Warning`; an item with no `to` left is refused on its own and comes back in `failed` with its `index` and the reason, and the rest of the batch is sent. Only a batch in which no item has a `to` left is a `422`. `Unitpost-Strict: true` refuses per item here too: an item with any reserved recipient comes back in `failed` and the clean items are sent, with a `422` only when every item has one.
- `Unitpost-Warning` stays within 1 KB: a long list of left-out addresses names the first ones, then how many more and how many in all. A batch names its items' addresses in one note.
- A retry with the same `Idempotency-Key` repeats the first answer's `Unitpost-Warning` with its `Idempotent-Replayed: true`, so a retry after a lost response still names the addresses left out.
MCP, SDKs and CLI
- Node SDK: a result carries `warning`, the `Unitpost-Warning` text, when a successful call had one (`const { data, error, warning } = await unitpost.email.send(…)`). The other SDKs don't return it yet.
### 2026-11-03: Organization keys: create and manage your organization's workspaces
Added
- `GET /organizations/{id}/workspaces`: List the organization's workspaces, newest first, cursor-paginated; `external_id` filters to one. Takes an organization key (`ok_live_…`, made in the organization's API page by an Owner or Admin with two-factor) with `organizations:read`. Staff approve each organization first (`403` until then).
- `POST /organizations/{id}/workspaces`: Create a workspace on the Owner's API rules (allowed paid plans, default plan, a monthly cap on new workspaces). A paid plan bills the organization's card within its workspace limit; `free` is one per person, so a second free one is a `409`. The key's creator becomes its Owner. Needs an `Idempotency-Key`: a retry answers the first request's workspace, never a second one. Takes `organizations:write`.
- `GET /organizations/{id}/workspaces/{workspace_id}`: One of the organization's workspaces. Takes `organizations:read`.
- `PATCH /organizations/{id}/workspaces/{workspace_id}`: Rename a workspace or change its `external_id` (unique in the organization). Takes `organizations:write`.
- `POST /organizations/{id}/workspaces/{workspace_id}/close`: Close a workspace: its keys are revoked, its requests refused, its plan ends at period end, and it is deleted after 30 days. Takes `organizations:write`.
- `POST /organizations/{id}/workspaces/{workspace_id}/restore`: Reopen a closed workspace within 30 days; a plan its close set to end resumes, and its old keys stay revoked. Takes `organizations:write`.
- `POST /organizations/{id}/workspaces/{workspace_id}/api-keys`: Mint a workspace key for one of the organization's workspaces (never with `apikeys:*`), revoked with the organization key that made it. The secret is shown once. Takes `organizations:write`.
MCP, SDKs and CLI
- Organization keys are their own credential: a workspace key gets `401` on these routes, and an organization key gets `401` on every workspace route. They aren't in the SDKs, the CLI or MCP yet.
### 2026-10-31: Organizations: whoami names yours, webhooks name the workspace
Changed
- `GET /whoami` returns `organization` (`id` with its `org_` prefix, and `name`): the organization the workspace belongs to. A key still reaches only its own workspace.
- Every webhook event carries a top-level `workspace_id` (`ws_…`), so one endpoint can serve several workspaces and tell their events apart.
MCP, SDKs and CLI
- SDKs: the `Whoami` model gains `organization`, in each language's casing. Webhook verification helpers return the new field as part of the event.
### 2026-10-31: Ask Unit credits follow your plan's price
Behavior changes
- Ask Unit's monthly credits now scale with your plan: Free 10; Starter and Growth 2 credits for every $1 of your monthly plan (20 to 160 on Starter, 400 to 1,600 on Growth); Enterprise 2,000 unless your contract sets another amount. `GET /usage` reports the new figure as `ai_credits`. A workspace that has already used more than its new amount this period can't start Ask Unit turns until its credits reset.
### 2026-10-31: A paused SMS campaign stops failing texts that are still going out
Behavior changes
- An SMS campaign paused for another reason (by you, an empty balance, a sending limit) still sends the texts it had already queued. If 10 of those fail in a row before reaching a carrier, its `pause_reason.code` changes to `failure_streak` and the rest are held, so Resume sends them instead of each one failing. You get a second `sms_campaign.paused` webhook with the new code. Resume still checks everything it checked before.
### 2026-10-31: SDK error details carry their code
MCP, SDKs and CLI
- SDKs: error details expose `code`. An `error.details[]` entry's machine-readable reason (the SMS skip word on `to`, a campaign send's reason) is typed in the Node SDK (`ApiFieldError.code`, now exported) and the PHP SDK, and documented in Python, Ruby and Go.
- Java and .NET: errors now carry their details (`UnitpostError.details` / `Details`, a list of `ApiFieldError` with field, code and message); they had only the code and message before.
### 2026-10-31: One word for a text that wasn't sent
Changed
- A `POST /sms` refused for consent, an opt-out, reach or the country now names the reason in the SMS skip vocabulary: `error.details[]` has an entry with `field` `to` and `code` `opted_out`, `no_marketing_consent`, `out_of_reach` or `unsupported_country`, the same words `GET /sms/{id}` and the `sms.suppressed` webhook use. The top-level `code` is unchanged, so `out_of_reach` is how to tell "no sender reaches this country" from "not supported there" under `unsupported_country`.
Behavior changes
- `sms.scheduled` for a text that quiet hours moved when you sent it now has `reason` `quiet_hours_deferred`, as it already did when quiet hours moved it at send time; it said `scheduled`. `scheduled` now means only a `scheduled_at` you chose.
MCP, SDKs and CLI
- MCP: `sms_send` says which skip word a refusal carries.
### 2026-10-31: Cheaper texts, and pay-as-you-go capped at 25%
Changed
- `GET /usage` `sms.countries[]` has `price_per_segment_cents_decimal`: the per-segment price in cents as a decimal string (`"1.4"` for $0.014), like Stripe's `unit_amount_decimal`.
Behavior changes
- SMS prices drop: a text to the US is $0.014 (was $0.02), Canada $0.017 (was $0.03), the UK $0.056 (was $0.08). Prices now go to a tenth of a cent, so `price_per_segment_cents` is `null` when the price is not a whole cent; read `price_per_segment_cents_decimal`.
- Past its monthly allowance a paid plan keeps sending on pay-as-you-go up to 25% of the allowance (was twice the allowance), then sends answer `429 overage_cap_exceeded` until the period resets or the plan is upgraded. An existing subscription keeps its current cap until its next billing period starts. `GET /usage` `wallet.overage_cap_cents` reports the ceiling in effect.
MCP, SDKs and CLI
- SDKs: the usage model has `price_per_segment_cents_decimal`.
### 2026-10-28: Sent email content is kept up to 365 days
Changed
- API key `capabilities` lists `emails:read_content` (View email content), the permission to see what a sent email said in the dashboard. A key can't hold it yet: it's dropped when the key is created, as the team, workspace and billing permissions are.
Behavior changes
- The HTML and text of an email you sent are kept for up to 365 days, even where your plan keeps logs longer; the email and its events stay. After that, `POST /email/{id}/retry` on a `failed` send of your own HTML or text is a `409` (its content is no longer stored). A template send re-renders, so it can still be retried.
### 2026-10-28: One number's texts
Changed
- `GET /sms` takes `sender_id`: one of your numbers or Sender IDs (`snum_…`, from `GET /sms/numbers`), and lists every text it sent or received, as its page in the dashboard does. A value it can't apply is ignored with a `Unitpost-Warning`, refused under `Unitpost-Strict: true`.
MCP, SDKs and CLI
- MCP: `sms_list` takes `sender_id`.
### 2026-10-27: SMS campaigns pause on a run of failures, and renewals are set aside
Behavior changes
- An SMS campaign pauses by itself when 10 texts in a row fail before reaching a carrier. It reads `paused` with `pause_reason.code` `failure_streak`, the `sms_campaign.paused` webhook carries the same code, and the texts not yet sent are held, so Resume sends them instead of each one failing in turn. A campaign whose last texts were still going out can go from `sent` back to `paused`; its `completed_at` is cleared until it finishes.
- Number and campaign renewals due this billing period are set aside from the shared sending allowance ahead of time. `GET /usage` reports them as `wallet.reserved_cents`, and they come off what's left to send with, so the allowance runs out sooner than it used to.
- A text scheduled into the next billing period is paid for when it goes out, not when it's scheduled. If the plan can't cover it then, it fails (`sms.failed`, with the reason in `last_error`).
### 2026-10-27: A key gets an opt-in permission only from a member who holds it
Behavior changes
- `GET /email/topics`, `GET /email/topics/{id}`, `GET /sms/topics`, `GET /sms/topics/{id}` and `GET /webhooks/{id}` count subscribers or the delivery backlog; a count that takes too long answers `503 operation_timed_out` with `Retry-After`, as other heavy reads do.
- `POST /api-keys` refuses a key that names `branding:write`, `apikeys:read` or `sms:purchase` when the member creating it doesn't hold that permission (`403 insufficient_scope`, nothing created). Every teammate holds `activity:read`, and a teammate who can edit the workspace holds `branding:write`. Every other permission is granted as before.
### 2026-10-27: Deleting the logo's image or a Brand Kit graphic takes the branding permission
Behavior changes
- `DELETE /library-images/{id}?force=true` on the image that is the workspace's logo or image, or a graphic pinned to a Brand Kit profile, is a `403 insufficient_scope` unless the key holds `branding:write`: deleting it clears the logo or unpins the graphic from every profile, as editing them does. The dashboard applies the same rule to teammates.
### 2026-10-27: Buy SMS carrier resources by API
Added
- `POST /sms/quotes`: Price a carrier purchase and bind what it buys: a 10DLC brand registration (or the paid resubmission of one carriers sent back), brand vetting, a 10DLC campaign registered from a messaging program with its local number, the number of an approved campaign, or a Canadian local number (`product`). Nothing is charged. Answers `due_now_cents`, `monthly_cents`, the line items, `expires_at` (10 minutes) and a single-use `confirm_token`, shown once. A toll-free number is `409 dashboard_required` with `error.dashboard_url`: it needs an opt-in evidence file a person provides. Needs `sms:manage`.
- `GET /sms/quotes/{id}`: A quote's price and state (`open`, `expired`, `ordered`, `pending`, `failed`) and, once confirmed, what its order made. A `pending` quote (the carrier's answer is unknown) settles from the billing ledger when it is read: `ordered` once the fee is captured or the number or campaign goes live, `failed` once the fee is refunded or the commitments are released. An `ordered` number or campaign whose commitments are released before it ever goes live (carriers refuse it, the number never leases) also reads `failed` and stops counting against the cap. Never the confirm token. Needs `sms:read`.
- `POST /sms/quotes/{id}/confirm`: Buy what the quote binds with its `confirm_token`, through the same order the dashboard places (the plan's purchase rules, the fee taken before carriers are called and refunded if they refuse). Requires an `Idempotency-Key`. Counts against a monthly API spend cap the workspace owner sets in the dashboard (with none set, it's refused: `api_spend_cap_not_set`); the owner gets an email per purchase and the activity log names the key. While an order for the same purchase is still in flight, a new quote or confirm for it is `409 conflict`, naming that quote. A `502` means the carrier's answer is unknown: the quote is `pending` and counted, so follow it. Refusals are answered before the shared hourly limits are spent. Needs `sms:purchase`, which no key preset includes, a key gets only from a member who holds it, and an app gets only when ticked on the consent page.
Changed
- `sms:purchase` can be held by an API key or an app, ticked by hand (it was dashboard-only). No existing key or app gains it, and no preset includes it. Creating a key that holds it needs a member who holds it too (`403 insufficient_scope` otherwise); an app gets it only from a member who holds it.
- New error codes: `api_spend_cap_not_set`, `api_spend_cap_exceeded` (403), `quote_already_used`, `quote_stale`, `dashboard_required` (409), `idempotency_key_required` (400).
MCP, SDKs and CLI
- MCP: `sms_quotes_create`, `sms_quotes_get`, `sms_quotes_confirm` (its `idempotency_key` is required; the tool tells the agent to show the price and get the user's yes first). CLI: `unitpost sms quotes create|get|confirm`. SDKs: `sms.quotes.create`, `sms.quotes.get`, `sms.quotes.confirm`, in each language's casing.
### 2026-10-27: Opt many contacts out of a topic at once
Added
- `POST /email/topics/{id}/opt-out`: Opt many contacts out of a topic in one call, as a background job: the contacts you list (`contact_ids` or `emails`, up to 1,000) or every member of a segment (`segment_id`, any size; also needs `segments:read`). Each is opted out as `POST /contacts/{id}/topics` does it, with `topic.unsubscribed` and an activity row only for a contact that was subscribed; suppressions and other topics are untouched. Answers `202` with the job (follow it with `GET /jobs/{id}`); pass an `Idempotency-Key` so a retry answers the same job. At most five opt-outs run at once in a workspace (`409 job_in_progress` past that). There is no bulk opt-in. Needs `topics:write`.
- `POST /sms/topics/{id}/opt-out`: The same for an SMS topic, where SMS is available.
Changed
- `GET /jobs` and `GET /jobs/{id}` show topic opt-out jobs (`kind: "contacts.topic_opt_out"`) to a key holding `topics:read`; cancelling or retrying one takes `topics:write`.
MCP, SDKs and CLI
- MCP: `email_topics_opt_out`, `sms_topics_opt_out` (with `idempotency_key`). CLI: `unitpost email topics opt-out`, `unitpost sms topics opt-out`. SDKs: `topics.optOut` and `sms.topics.optOut`, in each language's casing, each taking an idempotency key.
### 2026-10-27: Request logs
Added
- `GET /logs`: Your workspace's API request log, newest first: each call's method, path, status, time taken, the API key that made it and its `x-request-id`. Never the caller's IP and never a body. Filters: `status` (a code, or a class such as `4xx`), `api_key_id`, `method`, `path_prefix` (matched literally), `request_id` (the last 24 hours unless `created_after` is given), `created_after` / `created_before`; a bad value is a `422`. An email address or phone number in a path reads `{email}` / `{phone}`. Kept 30 days. Needs `apikeys:read`, which no key preset includes, and has its own per-workspace rate limit.
MCP, SDKs and CLI
- MCP: `logs_list`. CLI: `unitpost logs list`. SDKs: `logs.list`, in each language's casing.
### 2026-10-27: Paged SMS setup lists, prefixed workspace ids, safer replies
Changed
- `GET /sms/registrations`, `GET /sms/test-destinations`, `GET /sms/consent-setups`, `GET /sms/billing/resources` and `GET /sms/messaging-programs` are cursor-paginated like every other list (`limit`, `after`, `before`), newest first.
- `GET /whoami` and `PATCH /workspace` return the workspace's id with its `ws_` prefix, and `GET /whoami`'s `rate_limit.window_seconds` reports the limit's window.
- The OpenAPI spec declares the `Unitpost-Warning` response header on every operation that can send it.
Behavior changes
- `POST /email/threads/{id}/reply` never carries a copy of one of your own sends to another address: a forward of it is a `403`, a reply to it goes without the quote, and `include_quote: true` on it is a `403`. What you send isn't readable through the API.
- `POST /segments/preview` and `GET /segments/{id}/preview` share a per-workspace limit with the log searches and key usage reads: `GET /activity` with `q` or `channel=email`, `GET /email` with `q`, `source` or `outcome`, `GET /sms` with `q` or `type`, `GET /email/threads` with `q`, `GET /suppressions` with `q`, and `GET /api-keys/{id}/usage`. Past it, a `429` with `Retry-After`.
- A retry, with its `Idempotency-Key`, of a send that was accepted before send-time keys were checked returns the first answer instead of a `422`.
MCP, SDKs and CLI
- MCP: the five SMS setup list tools take `limit`, `after` and `before`.
- MCP: a connection with no new parameters lists the same tools as before. Newer tools come in toolsets: add `?toolsets=` to the server URL, or send an `X-MCP-Toolsets` header, with groups such as `campaigns`, `templates`, `audience` or `inbox`, `default` for the standard set, or `all`. `?readonly=1` (`X-MCP-Readonly`) lists only read tools and refuses writes; `?scoped=1` (`X-MCP-Scoped`) hides tools the credential can't call. The server instructions list every group.
- MCP (behavior change): `email_templates_test_send` is marked destructive, so clients ask before it, and takes `idempotency_key`. `docs_search` returns the Node.js and cURL snippets unless you pass `language` (or `all`). `email_campaigns_list_affected` returns 200 addresses per page unless you pass `limit` (the API keeps 10,000). A tool result's text is compact JSON. The server instructions are shorter; the full rules are in the `unitpost://guides/agent-rules` resource.
### 2026-10-27: Activity log, API key reads, several topics at once, search on the logs
Added
- `GET /activity`: The workspace activity log, newest first: what changed, when, who (`actor.name`) and, for a change made over the API, MCP or Ask Unit, which key (`actor.via`). Filters: `subject` (+ `subject_id`), `contact_id`, `campaign_id`, `q`, `channel`. Needs `activity:read`, which no key preset includes: tick it on the key that reads it.
- `GET /api-keys/{id}/usage`: One key's last 24 hours: requests, 4xx, 5xx and 429 answers, and when it was last used. Needs `apikeys:read`, which no key preset includes.
- `PATCH /contacts/{id}/topics`: Change several of a contact's topic preferences in one call: `{ topics: [{ topic_id, subscribed }] }`, up to 50, each topic once; topics you leave out keep their preference. The contact and every topic are checked before any is set (an unknown topic is a `404` naming it, and nothing changes). Answers `200` with the resolved preferences, in the order sent. `POST`/`PUT /contacts/{id}/topics` still set one and answer `201`. Needs `topics:write`.
Changed
- `GET /api-keys` works with an API key or MCP sign-in holding `apikeys:read` (it took a dashboard session only). Creating and revoking keys still take a dashboard session.
- `GET /email` takes `q` (an exact recipient, or part of the subject or sender), `source` (`api`, `smtp`, `test`, `batch`, `campaign`, `reply`) and `outcome` (`delivered`, `opened`, `clicked`, `unsubscribed`, `bounced`, `complained`, `failed`, `pending`; with `campaign_id`, who opened or clicked it); `GET /sms` takes `q` (the text, or 3+ digits of either number) and `type` (`transactional`, `marketing`).
Behavior changes
- New capabilities `activity:read` and, for keys, `apikeys:read` are in no preset: no existing key or app gains them. Every teammate already sees the activity log in the dashboard. A `GET /api-keys` request that carries an `Authorization: Bearer` header is answered for that key, even beside a dashboard session cookie.
- `q`, `source`, `outcome` and `type` were ignored by these lists before, so a value they can't apply is still ignored and named in `Unitpost-Warning`; `Unitpost-Strict: true` refuses it.
MCP, SDKs and CLI
- MCP: `activity_list`, `api_keys_list`, `api_keys_usage`, `contact_topics_update`. CLI: `unitpost workspace list-activity`, `unitpost api-keys list|usage`, `unitpost contacts topics update`. SDKs: `workspace.listActivity`, `apiKeys.usage`, `email.topics.updateTopics`, in each language's casing.
### 2026-10-27: List filters, topic counts, richer stats, plan limits
Changed
- `GET /suppressions` takes `q` (part of the address), `reason` and a `created_after` / `created_before` range.
- `GET /webhooks/{id}/deliveries` takes `status` (one or more, comma-separated, e.g. `failed,exhausted`) and `event_type`; `GET /webhooks/{id}` returns its `backlog`: the queued and failed deliveries a flush would send.
- Topics (`/email/topics`, `/sms/topics`) return `subscriber_count` and `unsubscribed_count`, the numbers the dashboard shows.
- `GET /email/stats` splits `bounced` into `bounced_permanent` and `bounced_transient`, adds `suppressed`, `unsubscribed` and the `unsubscribe` rate, and returns the window it counted (`from`, `to`) and `domain_id`. It takes `domain_id`, a `from` / `to` period instead of `days`, and with `grain` (`5m`, `1h`, `1d`) and `tz` the send volume per bucket as `series`, as the dashboard home charts it.
- `GET /usage` returns the plan's `limits` (domains, API keys, webhook endpoints, contacts, log retention, scheduling window, …; null when unlimited) and `features` (SMS, inbound email, topics, granular permissions, branding removal).
Behavior changes
- These lists ignored the new parameters before, so a value one can't apply (a typo, an unknown domain) is still ignored: it is named in the `Unitpost-Warning` header and the answer is the one you got before. Send `Unitpost-Strict: true` to have it refused with a `422` (`404` for an unknown `domain_id`). The contacts list's filters work the same way. With `days`, `GET /email/stats` answers the `days` window as before, and a `from` / `to` sent beside it is what's ignored.
- Paging any list with `after` or `before` no longer skips a row when the row you paged from has stopped matching the list's filter (a delivery you replayed, a contact that unsubscribed).
- `bounced_permanent` counts permanent bounces only, as the hard-bounce rate that can pause a campaign does; a bounce of undetermined type counts as soft.
MCP, SDKs and CLI
- MCP: the same parameters on `suppressions_list`, `webhooks_list_deliveries` and `email_stats`; the counts, backlog, limits and features in the tools' answers.
### 2026-10-27: Who am I, an email's events, original messages, template render
Added
- `GET /whoami`: The calling credential: its workspace, the API key (name, prefix, preset) or the sign-in (app and member), the `capabilities` it may use and the rate limit. Any credential; never the secret.
- `GET /email/{id}/events`: One sent email's delivery events, oldest first, each with its detail under the webhook's names (a bounce's `diagnostic_code`, a click's `link`). Cursor-paginated (`emev_` ids). Needs `emails:read`.
- `GET /email/received/{id}/raw`: A short-lived signed URL for a received message's original MIME (`.eml`): how to read a body over 256 KB. A copy of your own send is a `403`. Needs `emails:read`.
- `POST /email/templates/{id}/render`: The subject, HTML and text a send of the template would carry, with your footer and branding, and the variables left without a value. Sends and stores nothing; any unknown key is a `422`. Needs `templates:read`.
Changed
- `GET /email/{id}` also returns `cc`, `bcc`, `reply_to` and `tags` as the email was sent (never its body).
MCP, SDKs and CLI
- MCP: `workspace_whoami`, `email_list_events`, `email_received_raw_url`, `email_templates_render`.
- CLI: `unitpost whoami` shows the workspace's name and, for an API key, its capabilities.
### 2026-10-27: SMS operations name the fields they ignore
Behavior changes
- The SMS operations now name a body field they don't read in the `Unitpost-Warning` response header, as the others do, and refuse it with a `422` when you send `Unitpost-Strict: true`: send, update, the consent record, templates, campaigns, their send and reschedule.
- `POST /sms/campaigns/{id}/send` without `scheduled_at` warns that it used the default (the draft's saved time, or now). An SMS campaign PATCH with `archived` names the edits sent beside it, which it doesn't apply.
MCP, SDKs and CLI
- CLI: `contacts imports cancel`, `brand-kits imports create|get|accept|cancel`, `sms brands list|create|get|update` and `sms numbers list` also run the matching commands; the existing names keep working.
### 2026-10-27: SMS brand drafts and carrier registrations from the API
Added
- `POST /sms/brands`: Create an SMS brand, a draft when partial (`name` is enough; `missing` says what's still needed). Free; one per website domain; the tax id is write-only. Registering it with carriers is a paid step in the dashboard. Takes an `Idempotency-Key`. Needs `sms:manage`.
- `GET /sms/brands/{id}`: One brand with every field you can edit; the tax id only as `tax_id_on_file`. Needs `sms:read`.
- `PATCH /sms/brands/{id}`: Edit a brand (`null` clears an optional field), or `{ archived }` alone. A brand carriers hold refuses carrier-field edits with `409 brand_locked`, naming each field. Needs `sms:manage`.
- `GET /sms/registrations`: Carrier registrations with their review state and the carrier's feedback; a 10DLC campaign carries its number. Needs `sms:read`.
Changed
- SMS brands read back every field you can edit: `dba_name`, `sender_id_name`, `vertical`, `legal_organization_type`, `registration_number_type`, `tax_country`, `stock_symbol`, `stock_exchange`, `contact`, `address`, `revision`, `archived_at`.
Behavior changes
- The registration wizard's own checks (its question plan, readiness score, compiled carrier copy, opt-in page check) stay in the dashboard with the submission they serve; read `missing` on the brand instead.
MCP, SDKs and CLI
- MCP: `sms_brands_create` / `_get` / `_update` and `sms_registrations_list`. Ask Unit can start and edit a brand draft (never its tax id), prepare and edit a number setup draft, and compare a brand registration.
- SDKs: `sms.createBrand` / `getBrand` / `updateBrand`, `sms.registrations.list`, in each language's casing. CLI: `unitpost sms create-brand|get-brand|update-brand`, `sms registrations list`.
### 2026-10-26: SMS setup from the API: messaging programs, test texts, charges
Added
- `GET /sms/messaging-programs`: Your messaging programs: what a registration says your texts are for, the opt-in, help and stop copy, sample messages. `archived=true` includes archived ones. Needs `sms:read`.
- `POST /sms/messaging-programs`: Create a program, checked by the carriers' rules (the `422` names the field). Writing a program never changes a registration. Takes an `Idempotency-Key`. Needs `sms:manage`.
- `GET /sms/messaging-programs/{id}`: One program, archived ones too. Needs `sms:read`.
- `PATCH /sms/messaging-programs/{id}`: Edit a program: only the fields you send change, `null` clears one. `revision` moves on a material change. An archived program is a `409`. Needs `sms:manage`.
- `DELETE /sms/messaging-programs/{id}`: Archive a program; registrations keep the copy they made. Needs `sms:manage`.
- `POST /sms/messaging-programs/{id}/duplicate`: Copy a program into a new editable one. Takes an `Idempotency-Key`. Needs `sms:manage`.
- `GET /sms/consent-setups`: Where and how people opt in to your texts. Needs `sms:read`.
- `GET /sms/billing/resources`: What your workspace pays for SMS every month, per number and 10DLC campaign, with the next charge. Needs `sms:read`.
- `GET /sms/test-destinations`: The numbers a template's test text may go to besides the simulator, and whether each is verified. Needs `sms:read`.
- `DELETE /sms/test-destinations/{id}`: Remove a test number. Needs `sms:manage`.
- `GET /sms/templates/{id}/usage`: What uses an SMS template, and whether an edit or unpublish would be accepted now. Needs `templates:read`.
- `POST /sms/templates/{id}/test-send`: Send one test text of a template, only to the simulator or a number you verified (`403` otherwise), billed like any text. Takes an `Idempotency-Key`. Needs `sms:send`.
Behavior changes
- Ordering a number, carrier registrations and vetting, a brand's documents, releasing a number, adding or verifying a test number, and paying stay in the dashboard: a person approves money, signs carrier forms and proves a phone is theirs (SMS purchases came to the API on 2026-10-27 as quote then confirm, with the owner's cap).
MCP, SDKs and CLI
- MCP: `sms_messaging_programs_*`, `sms_consent_setups_list`, `sms_billable_resources_list`, `sms_test_destinations_list` / `_delete`, `sms_templates_usage` and `sms_templates_test_send` (it and the program creates take `idempotency_key`). Ask Unit can read, save, edit, archive and copy saved use cases, read consent setups and monthly charges, remove a test number, and pick a sender for a test text.
- SDKs: `sms.messagingPrograms.list` / `create` / `get` / `update` / `delete` / `duplicate`, `sms.consentSetups.list`, `sms.billing.listResources`, `sms.testDestinations.list` / `delete`, `sms.templates.usage` / `testSend`, in each language's casing. CLI: `unitpost sms messaging-programs …`, `sms consent-setups list`, `sms billing list-resources`, `sms test-destinations …`, `sms templates usage|test-send`.
### 2026-10-26: Edit and import Brand Kits from the API
Added
- `POST /brand-kits`: Create an empty Brand Kit profile (`name`, `make_default`). At most five per workspace (`403 limit_reached`). Needs `branding:write`.
- `PATCH /brand-kits/{id}`: Edit a profile: voice notes, tone, description, industry, website, core pages, the palette and logo candidates (`extracted`), the pinned graphics (library images, `img_…`). Only the fields you send change. Needs `branding:write`.
- `DELETE /brand-kits/{id}`: Delete a profile; the oldest remaining one becomes the default. The last profile can't be deleted (`409`). Needs `branding:write`.
- `POST /brand-kits/{id}/reset`: Clear a profile's content, keeping its name and default status. Needs `branding:write`.
- `POST /brand-kits/{id}/set-default`: Make a profile the default. Needs `branding:write`.
- `POST /brand-kits/{id}/graphics`: Add a graphic from a public https image `url`: saved to the image library and pinned with its `role`. Needs `branding:write`.
- `POST /brand-kits/imports`: Build a profile from a website (`url`) or the brand's own words (`text`), in the background (`202`). Applied when ready, or with `review: true` held as a proposal to accept. Needs `branding:write`.
- `GET /brand-kits/imports/{id}`: One import: status, the proposal once ready, warnings. Needs `templates:read`.
- `POST /brand-kits/imports/{id}/accept`: Apply a review import's proposal to a profile, its logos brought into the image library. Needs `branding:write`.
- `POST /brand-kits/imports/{id}/cancel`: Discard a review import or stop its crawl. Needs `branding:write`.
Changed
- A Brand Kit's `graphics` carry `image_id` (`img_…`) and `path` (`/img/…`) beside `library_image_id`.
Behavior changes
- An import adds its site as a sending domain only for a caller that may add domains (`domains:write`); an import started over the API or MCP never adds one. Add the domain with `POST /email/domains`.
- MCP's `brand_kits_import` returns the import's id as `bki_…` and also accepts `branding:write`.
MCP, SDKs and CLI
- MCP: `brand_kits_create` / `_update` / `_delete` / `_reset` / `_set_default` / `_add_graphic` and `brand_kit_imports_*`; `brand_kits_import` stays as it was. Ask Unit can create, edit, reset, delete and make a profile the default, add a graphic by URL, and follow an import it started; accepting or discarding a review import stays in Settings → Brand.
- SDKs: `brandKits.create` / `update` / `delete` / `reset` / `setDefault` / `addGraphic` / `import` / `getImport` / `acceptImport` / `cancelImport`, in each language's casing. CLI: `unitpost brand-kits …`.
### 2026-10-26: Your image library, branding and workspace name
Added
- `GET /library-images`: Your image library, newest first, cursor-paged. Each image has a `path` (`/img/…`) to put in a template and a public `url`. Needs `templates:read`.
- `POST /library-images`: Add an image from a public https `url` (fetched once, no redirects) or as base64 `data` with its `content_type`: PNG, JPEG, GIF or WebP up to 5 MB, counted against your plan's image storage. Takes an `Idempotency-Key`. Needs `templates:write`.
- `GET /library-images/{id}`: One image. Needs `templates:read`.
- `PATCH /library-images/{id}`: Change an image's suggested `alt` text. Needs `templates:write`.
- `DELETE /library-images/{id}`: Delete an image. Refused with `409` (`resource_in_use`, naming what uses it) while a template, a campaign or sent mail shows it, unless you add `?force=true`. Needs `templates:write`.
- `GET /branding`: The look your marketing footer and hosted unsubscribe page share: colors, font, company name and postal address, the page's copy, the logo, and whether the "Sent with Unitpost" mark shows. Needs `templates:read`.
- `PATCH /branding`: Change any of those fields; `null` returns one to its default. The font is one of the dashboard's list. Hiding the "Sent with Unitpost" mark needs a paid plan (`403`, `limit_reached`). Needs `branding:write`.
- `PUT /branding/logo`: Replace the logo your emails and unsubscribe page show, from a `url` or base64 `data`. Needs `branding:write`.
- `DELETE /branding/logo`: Remove the logo (the workspace image shows instead, else none). Needs `branding:write`.
- `PATCH /workspace`: Rename the workspace. Needs `branding:write`.
- `PUT /workspace/image`: Replace the workspace's image (its avatar in the dashboard), from a `url` or base64 `data`. Needs `branding:write`.
- `DELETE /workspace/image`: Remove the workspace's image. Needs `branding:write`.
Changed
- A new key capability, `branding:write`, covers branding, the logo and the workspace's name and image. No key preset includes it, a full-access key too: tick it on the key to let it change how your mail looks. Members who can edit the workspace hold it already.
- Deleting an image (here and in the dashboard) also checks Brand Kit graphics and the brand logo: each is named in the `409`, and `force=true` returns the logo to its fallback and unpins the graphic.
- An app you connect through OAuth (MCP, the CLI) starts without `branding:write` on the consent page; tick it to let the app change branding.
MCP, SDKs and CLI
- MCP: `library_images_*`, `branding_get` / `branding_update` / `branding_set_logo` / `branding_remove_logo`, `workspace_update` / `workspace_set_image` / `workspace_remove_image`. Ask Unit can list images, change their alt text and delete one no template, campaign or setting uses, read and change branding (it asks first), remove the logo, preview the unsubscribe page and rename the workspace.
- SDKs: `libraryImages` (with `listAll`; `create` takes an idempotency key, minted when you pass none), `branding.get` / `update` / `setLogo` / `removeLogo`, `workspace.update` / `setImage` / `removeImage`, in each language's casing. CLI: `unitpost library-images …`, `unitpost branding …`, `unitpost workspace …`.
### 2026-10-26: Webhook replay, email retry, batches and sender addresses
Added
- `POST /webhooks/{id}/deliveries/{delivery}/replay`: Deliver one event to a webhook again, with the same event id. The webhook must be enabled. Needs `webhooks:manage`.
- `POST /webhooks/{id}/flush`: Deliver a webhook's backlog: the deliveries held while it was suspended, and with `include_failed` the ones that failed for good. Needs `webhooks:manage`.
- `POST /email/{id}/retry`: Send a failed email again on the same id, when its failure was temporary (a campaign recipient, a test, a suppressed or rejected send is a `409` saying why). Takes an `Idempotency-Key`. Needs `emails:send`.
- `GET /email/batches`: Your batch sends, newest first, each with its live per-status rollup. Needs `emails:read`.
- `GET /email/senders`: Your saved From addresses, filterable by `domain_id`. Needs `domains:read`.
- `POST /email/senders`: Save a From address (`address`, `display_name`) on one of your domains. Needs `domains:write`.
- `GET /email/senders/{id}`: One saved From address. Needs `domains:read`.
- `PATCH /email/senders/{id}`: Change a saved From address's `display_name`. Needs `domains:write`.
- `DELETE /email/senders/{id}`: Remove a saved From address. Templates and campaigns keep their From. Needs `domains:write`.
- `POST /contacts/export`: Export contacts to a CSV file, every contact or the ones `GET /contacts`'s filters match. Runs as a job (202): follow it, then download its file. One export at a time. Needs `contacts:read`.
- `GET /jobs`: Your background jobs, newest first: the kinds your key may read. Filter with `status`, `kind`.
- `POST /jobs/{id}/cancel`: Stop a queued or running job; work done stays. Needs the capability that starts its kind.
- `POST /jobs/{id}/retry`: Resume a failed job from where it stopped. Needs the capability that starts its kind.
- `GET /jobs/{id}/download`: A finished export's CSV as a signed URL (5 minutes). Needs `contacts:read`.
- `POST /segments/preview`: Count an unsaved dynamic filter: matches, the subscribed set a campaign would mail, a sample (with `contacts:read`). Needs `segments:read`.
- `GET /segments/{id}/preview`: The same counts for a saved segment, and whether it is archived. Needs `segments:read`.
- `POST /segments/all-contacts`: The managed "All subscribed contacts" segment, made the first time (an archived one is restored). A `409` when a segment that doesn't target everyone holds its name. Needs `segments:write`.
- `PATCH /segments/{id}/contacts/{contact}`: Opt a member in or out of one static segment's sends (`subscribed`) without removing it. Needs `segments:write`.
Changed
- `GET /email/campaigns/{id}/quality/affected` pages: `limit` (up to 10,000, the default) and `after` (the last row's `email`). Past 10,000 addresses there was no way to read the rest.
- `GET /email/templates/{id}/usage` carries `campaign_count`, how many campaigns use the template in all (`campaigns` lists the newest 25).
- `GET /jobs/{id}` also returns contacts export jobs (`kind: contacts.export`).
- Operations that ignore a body field they don't read now name it in a `Unitpost-Warning` response header on success, with the closest real name (`fist_name` → `first_name`). Send `Unitpost-Strict: true` to have such a field refused with a `422` instead, as newer operations do; nothing changes then. SMS operations follow.
- `POST /email/campaigns/{id}/send` without `scheduled_at` says in `Unitpost-Warning` that it used the default (the draft's saved time, or now).
MCP, SDKs and CLI
- MCP: `webhooks_replay_delivery`, `webhooks_flush`, `email_retry` (takes `idempotency_key`), `email_batches_list` and `email_senders_*`. Ask Unit can do the same from the dashboard's own routes.
- SDKs: `email.retry` (takes an idempotency key, minted when you pass none), `email.listBatches`, `email.domains.listSenders` / `createSender` / `getSender` / `updateSender` / `deleteSender`, `webhooks.replayDelivery` and `webhooks.flush`, in each language's casing. CLI: `unitpost email batches list`, `unitpost email senders …`.
- Ask Unit says when a list it read stops at its size limit, instead of reading the newest rows as all of them.
- MCP: a result carries `warning` beside `data` when the API sent a `Unitpost-Warning`.
- The activity log names who made a change over the API, MCP or Ask Unit: the API key (`API key "Production"`), the member and the app they signed in with, or the member via Ask Unit.
### 2026-10-26: Inbox and campaign-report access match the dashboard
Changed
- `GET /email` with `campaign_id` is that campaign's report, so a key with `campaigns:read` reads it too (as an SMS campaign's recipients), beside `emails:read`.
- `GET /email/received/{id}` carries `sent_by_us`.
Behavior changes
- Marking a conversation read, reporting spam, blocking a sender, replying and deleting take `emails:read` and `emails:send`, as in the dashboard: a read-only key changes nothing.
- `GET /email/received/{id}` answers `text` and `html` as `null` for a copy of one of your own sends that came back to your receiving domain (`sent_by_us: true`), as the conversation read does: sent bodies aren't on the API. Mail you received is unchanged.
- `GET /email/received/{id}/attachments/{attachmentId}` answers `403` (`forbidden`) for such a copy: its files are what you sent. Its attachments stay listed with their names and sizes.
### 2026-10-26: Template history, copies and gallery emails
Added
- `POST /email/templates/{id}/duplicate`: Copy a template into a new draft: its name with " (copy)" (or your `name`), subject, editor and body; never its status or history. Takes an `Idempotency-Key`. Needs `templates:write`.
- `POST /email/templates/from-library`: Add a ready-made gallery email (`slug`) to your templates as a draft Design template. Takes an `Idempotency-Key`. Needs `templates:write`.
- `GET /email/templates/{id}/usage`: What uses a template, and whether a change would be accepted now: the campaigns that reference it, its live campaigns and automations, queued sends, `can_edit_content` and `can_unpublish` with the reason. Needs `templates:read`.
- `GET /email/templates/{id}/versions`: A template's history, newest first: a version for every publish and every content save while published. Page with the last `version` as `after`. Needs `templates:read`.
- `POST /email/templates/{id}/versions/{version}/restore`: Put a version's subject and body back. Refused with `409` while the template's content is locked (a live campaign renders it); on a published template it adds a version. Needs `templates:write`.
Changed
- Templates carry `category` (`marketing` or `transactional`) on every read, and `PATCH /email/templates/{id}` takes `category` on any editor. A change to it is a content edit: refused with `409` while a live campaign renders the template.
- A single read of a Simple template carries `text`, its body in the format you write it in; edit it and send it back as `text`. It is `null` for Design and Code templates, and for a Simple email the format can't carry whole.
Behavior changes
- `PATCH /email/templates/{id}` used to ignore a `category` key; it now applies it, and a change to it under a live campaign is a `409`, like any content edit.
- A `text` or `html` body update now keeps the template's preview text, declared variables, default sender and tracking settings; it used to reset them. Send `preview_text` to change the preview text.
- Restyling, converting or recategorizing a template whose stored body can't be read is a `409` (send a whole new body); it used to replace that body with an empty one.
- A whole template read sent back to `PATCH` now also leaves out its unchanged `text` and `category`, so a rename stays a rename. An edited `html` on a Simple read still converts the template to Code, as before.
- A template read copied into `POST /email/templates` keeps its category, where a copy used to be transactional. A Simple read makes a Simple copy, where it used to make a Code template with the same look; when its `html` was edited, `html` is used as before and `warnings` says so. A create that names both `html` and `text` on its own is still a `422`.
MCP, SDKs and CLI
- MCP: `email_templates_duplicate` and `email_templates_from_library` (both take `idempotency_key`), `email_templates_usage`, `email_templates_list_versions` and `email_templates_restore_version`.
- SDKs: `email.templates.duplicate`, `fromLibrary`, `usage`, `listVersions` and `restoreVersion` (in each language's own casing).
- CLI: `unitpost email templates duplicate|from-library|usage|list-versions|restore-version`.
- Ask Unit can add a gallery email, list a template's versions and, after asking you, restore one.
### 2026-10-26: Work your inbox from the API
Added
- `GET /email/threads`: Your inbox's conversations, newest activity first; `q` to search. Page with `next_cursor`. Needs `emails:read`.
- `GET /email/threads/{id}`: One conversation, oldest first: what you received (bodies, Reply-To, verdicts, attachments) and what you sent from it (status and recipients; never a body). A long one comes newest page first (`limit`, default 50): pass `older_cursor` as `before` for the messages before. Needs `emails:read`.
- `DELETE /email/threads/{id}`: Delete a conversation for good, as the inbox does: its received mail is erased (it still counts toward usage); what you sent from it stays on your sent emails. Needs `emails:read` and `emails:send`.
- `POST /email/threads/{id}/reply`: Reply, reply all or forward a received message, as the inbox does: your `text` / `html` on top, the original quoted under it, the reply in the conversation. Sends now; takes an `Idempotency-Key`. Needs `emails:send` and `emails:read`.
Changed
- Received emails (`GET /email/received`, `GET /email/received/{id}`) carry `thread_id` and `reply_to` (answer these before `from`); the single read also carries `headers`.
MCP, SDKs and CLI
- MCP: `email_threads_list`, `email_threads_get`, `email_threads_delete` and `email_threads_reply` (it takes `idempotency_key`).
- SDKs: `email.threads.list`, `get`, `delete` and `reply` (in each language's own casing).
- CLI: `unitpost email threads list|get|delete|reply`.
- Ask Unit can read your inbox and, after asking you, delete a conversation or send a reply.
- The skill's "Answering inbound mail" uses the conversation tools; deleting a conversation is no longer dashboard-only. Read state, Spam and blocking stay in the dashboard.
### 2026-10-26: A campaign's results, from the API
Added
- `GET /email/campaigns/{id}/stats`: A campaign's numbers, as its page shows them: delivered, unique opens and clicks, unsubscribes, bounces and complaints, their rates, recipients and who didn't receive it. A rate is `null` until there's something to measure, and open and click rates are `null` when that tracking was off. Needs `campaigns:read`.
- `GET /email/campaigns/stats`: The same numbers for up to 100 campaigns in one read (`ids`, comma-separated). An id that isn't one of your email campaigns is left out. Needs `campaigns:read`.
- `GET /email/campaigns/{id}/recipients`: Who the campaign would reach if you sent it now, member by member: `sendable`, `missing_data`, `suppressed` or `excluded`, with the reason. Filter with `status`; page with `next_cursor`. Needs `campaigns:read` and `contacts:read`.
- `GET /email/campaigns/{id}/quality/affected`: After an automatic quality pause: the addresses that hard-bounced or marked the campaign as spam (up to 10,000). They're already suppressed. Needs `campaigns:read`.
- `POST /email/campaigns/{id}/quality/remove-affected`: While the campaign is paused for its delivery quality, take every flagged contact out of its static segment. Another campaign or a filter segment is a `409`; a repeat removes nothing. It doesn't resume the campaign. Needs `segments:write`.
- `POST /email/campaigns/{id}/quality/request-review`: Ask our team to review a campaign whose Resume answered `409 review_required`. A repeat within a day answers `already_requested: true`; a campaign paused while we review the whole account is a `409`. Needs `campaigns:send`.
- `GET /sms/campaigns/{id}/recipients`: How each recipient of an SMS campaign fared: the carrier's answer, or why no text was sent (`skipped`, with `reason_code`), plus the skipped counts by reason over the whole campaign (on the first page). Search with `q` (digits of the number). Needs `campaigns:read`.
Changed
- `GET /email/campaigns` and `GET /sms/campaigns` take `topic_id` and `segment_id`: the campaigns scoped to a topic or aimed at a segment.
Behavior changes
- Removing the flagged addresses after a quality pause, in the dashboard or the API, now records each removal on the contact's activity and sends `segment.left` for each contact (which also starts any automation triggered by leaving the segment), like every other way a contact leaves a segment. It used to remove them silently. It now also reaches every flagged contact, not only the first 10,000, and is offered only while the campaign is paused for its quality, as on the campaign page.
MCP, SDKs and CLI
- MCP: `email_campaigns_stats`, `email_campaigns_list_stats`, `email_campaigns_list_recipients`, `email_campaigns_list_affected`, `email_campaigns_remove_affected`, `email_campaigns_request_review` and `sms_campaigns_list_recipients`; `email_campaigns_list` and `sms_campaigns_list` take `topic_id` and `segment_id`.
- SDKs: `email.campaigns.stats`, `listStats`, `listRecipients`, `listAffected`, `removeAffected`, `requestReview`, and `sms.campaigns.listRecipients` (in each language's own casing).
- CLI: `unitpost email campaigns stats|list-stats|list-recipients|list-affected|remove-affected|request-review`, `sms campaigns list-recipients`.
- Ask Unit can read a campaign's results and compare campaigns, show who a campaign would reach, list the addresses a quality pause flagged, and, after asking you, remove them from the audience or request a review. It can list the campaigns on a topic or segment.
### 2026-10-26: Everything on a contact's page, from the API
Added
- `GET /contacts/{id}/addresses`: Every email address and SMS number a contact has, each with its status and, for a number, its SMS consent. Needs `contacts:read`.
- `GET /contacts/{id}/activity`: A contact's most recent messages, newest first: the emails sent to them and texts both ways (`limit` up to 100; no cursor). Emails also need `emails:read` and texts `sms:read`; without them those rows are left out. For the full history, list `GET /email` or `GET /sms` with `contact_id`.
- `GET /contacts/{id}/segments`: The segments a contact is in: static ones with their per-segment opt-out, and dynamic ones whose filter they match now. Needs `contacts:read`.
- `POST /contacts/imports/{id}/retry`: Re-run a failed import from its saved rows, without uploading them again. Contacts the failed run added are skipped. Needs `contacts:write`.
- `POST /contacts/{id}/sms-opt-in-request`: Text a contact "Reply YES to get our marketing texts". A YES within 72 hours records a verified marketing opt-in. It only confirms consent you already recorded, is billed like any text, and takes an `Idempotency-Key`. Needs `sms:send`.
Changed
- Contacts carry `phone`: the contact's SMS number in E.164, or `null`. Set it on `POST /contacts` and `PATCH /contacts/{id}` in international format (`+` and the country code); `phone: null` on a PATCH removes the contact's SMS numbers when you also send `Unitpost-Strict: true`; without it the numbers are kept and `Unitpost-Warning` says so, so a sync that sends `null` for a blank field never removes one. A number is not consent: record consent with `POST /contacts/{id}/sms-consent`. A number that can't be saved (empty, malformed, or another contact has it) is left out and named in `Unitpost-Warning`, and the rest of the request is saved; with `Unitpost-Strict: true` it is a `422` (`409` when taken) and nothing is saved.
- `GET /contacts` filters like the dashboard's contacts table: `q` (email, first or last name, and 3 or more digits of a phone number), `status` (`subscribed` or `unsubscribed`), `phone` (`has` or `none`), `segment_id` (also needs `segments:read`) and `segment_subscription` (`subscribed` or `opted_out`, for a static segment).
- `GET /email` filters by `to` (one recipient address) and `contact_id` (emails linked to the contact or sent to its address).
Behavior changes
- `GET /contacts/{id}/sms-consent`: `marketing_eligible` is `false` for a number on your SMS suppression list, as the dashboard shows it. It used to read `true` when the consent record alone allowed marketing.
MCP, SDKs and CLI
- MCP: `contacts_addresses`, `contacts_activity`, `contact_segments_list`, `contact_imports_retry` and `sms_consent_request_opt_in` (it takes `idempotency_key`); `contacts_list` takes the new filters, `email_list` takes `to` and `contact_id`, and `contacts_create` / `contacts_update` take `phone`.
- SDKs: `contacts.addresses`, `activity`, `listSegments`, `retryImport`, and `sms.requestSmsOptIn` (in each language's own casing).
- CLI: `unitpost contacts addresses|activity `, `contacts segments list|add|remove`, `contacts imports retry `, `sms consent request-opt-in `.
- Ask Unit can read a contact's addresses and segments, set or remove their phone number (removing asks you first; it never records consent), retry a failed import, and send the YES-to-confirm text (it asks you first). Its delete card names the campaigns that would lose the contact.
### 2026-10-26: Ask Unit catches up with the API, and a fix
Behavior changes
- `/contacts/{id}/topics` (`GET`, `POST`, `PUT`) with an email in place of the id finds the contact. It answered `404` for every email.
MCP, SDKs and CLI
- Ask Unit can now delete a contact, template or webhook; archive a segment, topic or campaign; set a contact's topic subscription; import up to 500 contacts and follow, stop or list imports; rename or delete a contact field; and read or cancel an email batch. Each of these asks you first, on a card that names the record. It can also take a contact out of a segment, which runs straight away, like adding one.
- Some jobs stay with you, on purpose: deleting a sending domain (the dashboard asks you to type its name), rotating a webhook's signing secret (the new secret is shown only once), sending a batch of up to 100 different emails, downloading a received attachment (Ask Unit opens the email for you), and recording SMS consent.
### 2026-10-26: Rate limits on every surface, and fixes
Changed
- A public Rate limits (/docs/rate-limits) page: the workspace budget shared by keys, the MCP server, the CLI and OAuth sign-ins, the `RateLimit-*` and `Retry-After` headers, and which operations have a limit of their own.
Behavior changes
- `POST /email/domains`: adding domains has a per-workspace limit of its own, on every surface. Past it, `429 rate_limit_exceeded` with `Retry-After`; nothing is created.
- `POST /email/templates/{id}/test-send` by an OAuth sign-in (the CLI, an MCP client) also counts against that person's test-send limit, as it does in the dashboard.
- `/contacts/{id}/sms-consent` with an email in place of the id finds the contact. It answered `404` for every email.
MCP, SDKs and CLI
- MCP: a tool call counts once against the per-IP limit, at the same limit as the API (it counted twice, at a fixed lower limit); in a JSON-RPC batch the first call rides on the request's own charge and each further call counts once. A rate-limited tool call's error carries `retry_after_seconds`. `brand_kits_import` now goes through the same checks as every other tool: the workspace budget, and a suspended workspace is refused.
- Ask Unit: a domain check it runs is a real check (it was answered as a background poll), and saving an attachment to the image library counts against the library's upload limit.
### 2026-10-26: Turn receiving on and off from the API
Changed
- `PATCH /email/domains/{id}` takes `capabilities: { receiving: "enabled" | "disabled" }` (`sending` is accepted only as `"enabled"`). Turning receiving on needs a verified (or degraded) domain; otherwise it's a `409` and none of the request's other fields apply. Turning it off is always allowed. Turning it on is limited to a few times an hour per domain.
- Every domain read carries `capabilities` and a read-only `receiving` object: `status` (`disabled`, `not_started`, `pending`, `verified`, or `held_elsewhere` when another workspace receives this name), the MX `record` to publish, the last `checks` (`mx_found`, `mx_lowest_priority`, `other_mx_found`) and `checked_at`. The MX record isn't in `records`.
- `POST /email/domains/{id}/verify` also runs the inbound MX check when receiving is on, and starts accepting mail once our MX record wins.
- A receiving change fires one `domain.updated` webhook, including a PATCH that changes nothing else.
MCP, SDKs and CLI
- MCP: `email_domains_update` takes `body.capabilities.receiving`; `email_domains_verify` runs the MX check.
- SDKs: the domain update model takes `capabilities`; domain reads carry `capabilities` and `receiving`. CLI: `email domains update --capabilities '{"receiving":"enabled"}'`.
- Ask Unit can turn receiving on and off (it asks you first) and run the MX check.
### 2026-10-26: Precise campaign error codes; agents always name the send time
Behavior changes
- `POST /email/campaigns`: a refusal keeps its status and now says which it is. Inline `recipients` past your plan's contact limit is `403 limit_reached` (`details[].field` `recipients`); a sender domain that isn't added or is blocked is `403 domain_not_found` or `domain_blocked` (`field` `from`). Every 403 used to be `domain_not_verified`, which now means only an added domain that isn't verified yet.
MCP, SDKs and CLI
- MCP: `email_campaigns_send` and `sms_campaigns_send` require `body.scheduled_at`: a time to schedule, or `null` to send now. A call without it is refused and nothing is sent. Over REST, the SDKs and the CLI a Send without a time still uses the saved time, or sends now.
- Ask Unit names the send time on every campaign send, and asks you when if you didn't say.
### 2026-10-26: Edit, unschedule and duplicate campaigns from the API
Added
- `POST /email/campaigns/{id}/unschedule`: Move a scheduled email campaign back to a draft and clear its send time. Needs `campaigns:send`. A campaign that has started sending can't be unscheduled: cancel it, then duplicate it.
- `POST /sms/campaigns/{id}/unschedule`: The same for an SMS campaign.
- `POST /email/campaigns/{id}/duplicate`: Copy a campaign into a new draft: template, segment, sender, subject, preview text, tracking, variable fallbacks and exclusions, never the send time. Optional `{ "name" }`. Send an `Idempotency-Key` so a retry returns the same copy. Needs `campaigns:write`. The copy goes to the whole segment, including people who got the original, unless you add them to `excluded_contact_ids`.
- `POST /sms/campaigns/{id}/duplicate`: The same for an SMS campaign (template, segment, sender, variable fallbacks).
Changed
- `PATCH /email/campaigns/{id}` edits a draft's `name`, `subject`, `from`, `preview_text`, `template_id` and `segment_id`, with the same checks as create (a published marketing template, a segment that isn't archived, a verified sending domain). A value equal to the draft's is no edit, so you can PATCH back the object you read.
Behavior changes
- `PATCH /email/campaigns/{id}` used to ignore `name`, `subject`, `from`, `preview_text`, `template_id` and `segment_id`. It now applies them. Read-only fields such as `id` and `status` are still ignored.
- `PATCH /email/campaigns/{id}` answers `422` instead of `200` for a body that isn't a JSON object and for `recipients` (inline recipients are create-only). Nothing is changed. Edits sent beside `archived` are still not applied: the campaign is archived or restored as before, and the edits are named in `Unitpost-Warning` (`422` under `Unitpost-Strict: true`).
- `excluded_contact_ids` on campaign create and PATCH must be contact ids from your workspace. Anything else is a `422` naming `excluded_contact_ids[i]`; it used to be saved and never matched anyone.
- A misnamed saved send time on `PATCH /email/campaigns/{id}` (`send_at`, `scheduledAt`, …) is a `422` naming the key. Use `scheduled_at`.
MCP, SDKs and CLI
- MCP: `email_campaigns_unschedule`, `email_campaigns_duplicate`, `sms_campaigns_unschedule` and `sms_campaigns_duplicate` (the duplicates take `idempotency_key`).
- SDKs: `unschedule` and `duplicate` on email and SMS campaigns; `duplicate` sends an idempotency key you pass, or one it makes up, so its retries are safe.
- CLI: `unitpost email campaigns unschedule|duplicate ` and the same under `sms campaigns`.
- Ask Unit can unschedule an email campaign (it asks you first).
### 2026-10-26: A misnamed send time is refused instead of sending now
Behavior changes
- `POST /email`: a time-like key other than `scheduled_at` or `scheduledAt` (`send_at`, `schedule_at`, `deliver_at`, `send_time`, …) is a `422` naming it, and nothing is sent. The email used to go out at once.
- `POST /email/batch`: an item carrying its own send time is a `422` naming `emails[i].`; a misnamed batch-level time is a `422` naming it. Nothing is sent. Those items used to go out at once.
- `POST /sms`: a time-like key other than `scheduled_at` is a `422`, and nothing is sent.
- `POST /email/campaigns`, `POST /sms/campaigns` and `PATCH /sms/campaigns/{id}`: a misnamed saved send time is a `422` ("Nothing was saved"). It used to be dropped, so a later Send without a body went out at once.
- The `422` says how to send now: leave the time out on one-off and batch sends, pass `scheduled_at: null` on a campaign Send, or `null` to clear a campaign's saved time.
MCP, SDKs and CLI
- MCP (behavior change): a REST tool refuses a top-level argument it doesn't take, with "Request fields go inside `body`". `email_campaigns_send({ id, scheduled_at })` used to drop the time and send now; the time goes in `body.scheduled_at`.
### 2026-10-26: Clearer agent tools
Changed
- `GET /email/campaigns` and `GET /segments` document the `archived` query parameter they already honored (`archived=true` lists archived rows).
MCP, SDKs and CLI
- MCP: a write tool's `body` sent as JSON text that encodes an object is read as that object (it was a `-32602`). `email_campaigns_list` takes `archived`.
- MCP and the agent skill list the steps that still need the dashboard, and explain how to answer inbound mail in its thread (`in_reply_to`).
- SDKs and CLI: an optional `archived` parameter on campaign and segment lists (`--archived`).
# API endpoints
- `GET /api/v1/email`: List sent emails ([docs](https://www.unitpost.com/docs/email/get-email))
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`, `to`, `contact_id`, `campaign_id`, `batch_id`, `q`, `source`, `outcome` and `created_after`/`created_before` filters. A `q`, `source` or `outcome` value it can't apply (a typo, an unknown id) is ignored and named in the `Unitpost-Warning` header, and the answer is the one without it; send `Unitpost-Strict: true` to have it refused instead. Use an email's `id` with [Retrieve an email](https://www.unitpost.com/docs/email/get-email-id) to read its full record, or [Cancel or reschedule an email](https://www.unitpost.com/docs/email/patch-email-id) to change a scheduled send. For aggregate deliverability numbers over a window, see [Email deliverability stats](https://www.unitpost.com/docs/email/get-email-stats). With `campaign_id` it is that campaign's report, so `campaigns:read` reads it too. Requires the `emails:read` capability.
- `POST /api/v1/email`: Send or schedule an email ([docs](https://www.unitpost.com/docs/email/post-email))
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](https://www.unitpost.com/docs/email/post-email-templates)). 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](https://www.unitpost.com/docs/email/get-email-id), change a scheduled send with [Cancel or reschedule an email](https://www.unitpost.com/docs/email/patch-email-id), or send many at once with [Send a batch of emails](https://www.unitpost.com/docs/email/post-email-batch). 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"`. A `to`/`cc`/`bcc` address on a domain reserved for examples (example.com, example.net, example.org) can't receive mail: it is left out, the others get the email, and `Unitpost-Warning` names it. Requires the `emails:send` capability.
- `POST /api/v1/email/batch`: Send a batch of emails ([docs](https://www.unitpost.com/docs/email/post-email-batch))
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. Addresses on a reserved domain (example.com, example.net, example.org) can't receive mail: an item's are left out and the rest of it is sent (named in `Unitpost-Warning`), and an item with no `to` left is refused on its own and returned in `failed` with its index while the rest of the batch is sent. With `Unitpost-Strict: true` an item with any reserved recipient is refused the same way, on its own and in `failed`: strict mode doesn't refuse the whole batch for it. To schedule, use the object form `{ emails, scheduled_at }` for the whole batch: an item that carries its own send time (`scheduled_at`, `send_at`, …) is rejected (422) and nothing is sent. 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](https://www.unitpost.com/docs/email/get-email-batches-id) and stop the rest of it with [Cancel a batch](https://www.unitpost.com/docs/email/post-email-batches-id-cancel). To send one email, use [Send or schedule an email](https://www.unitpost.com/docs/email/post-email). Requires the `emails:send` capability.
- `GET /api/v1/email/batches`: List batches ([docs](https://www.unitpost.com/docs/email/get-email-batches))
Your batch sends, newest first and cursor-paginated, as the dashboard's Batches tab lists them. Each is the same live rollup [Retrieve a batch](https://www.unitpost.com/docs/email/get-email-batches-id) returns: how many emails it holds, when it's scheduled, a count per delivery status and how many are still cancelable. Requires the `emails:read` capability.
- `GET /api/v1/email/batches/{id}`: Retrieve a batch ([docs](https://www.unitpost.com/docs/email/get-email-batches-id))
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](https://www.unitpost.com/docs/email/post-email-batch), or stop its remaining messages with [Cancel a batch](https://www.unitpost.com/docs/email/post-email-batches-id-cancel). Requires the `emails:read` capability.
- `POST /api/v1/email/batches/{id}/cancel`: Cancel a batch ([docs](https://www.unitpost.com/docs/email/post-email-batches-id-cancel))
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](https://www.unitpost.com/docs/email/get-email-batches-id). Requires the `emails:send` capability.
- `GET /api/v1/email/{id}/events`: List an email's events ([docs](https://www.unitpost.com/docs/email/get-email-id-events))
One sent email's delivery events, oldest first: sent, delivered, delayed, bounced, complained, opened, clicked, failed, suppressed, unsubscribed. Each carries its detail under the webhook's names (a bounce's `diagnostic_code`, a click's `link`), with the provider's wording redacted as in webhooks. Opens and clicks are recorded once per email per day (clicks once per link per day). Cursor-paginated with `emev_` ids. Requires the `emails:read` capability.
- `GET /api/v1/email/{id}`: Retrieve an email ([docs](https://www.unitpost.com/docs/email/get-email-id))
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](https://www.unitpost.com/docs/email/get-email). 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. It also carries the `cc`, `bcc`, `reply_to` and `tags` the email was sent with (never its body); its delivery events are [List an email's events](https://www.unitpost.com/docs/email/get-email-id-events). To change a scheduled or queued email, use [Cancel or reschedule an email](https://www.unitpost.com/docs/email/patch-email-id). Requires the `emails:read` capability.
- `PATCH /api/v1/email/{id}`: Cancel or reschedule an email ([docs](https://www.unitpost.com/docs/email/patch-email-id))
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](https://www.unitpost.com/docs/email/get-email-id). Requires the `emails:send` capability.
- `POST /api/v1/email/{id}/retry`: Retry a failed email ([docs](https://www.unitpost.com/docs/email/post-email-id-retry))
Send a `failed` email again on the same `id`, as the dashboard's Retry does. Only a one-off send whose failure was temporary is retried: a campaign recipient, a template test, a suppressed or rejected send, a content error, or a send whose stored content is gone is a `409` saying why (send a new email instead). The HTML and text you sent are kept for up to 365 days; a template send re-renders, so it keeps no copy and can still be retried. It passes the same checks as a new send: the sending domain, your plan and quota. Pass an `Idempotency-Key` so a retried call can't send it twice. Takes no body; follow the email with [Retrieve an email](https://www.unitpost.com/docs/email/get-email-id). Requires the `emails:send` capability.
- `GET /api/v1/email/stats`: Email deliverability stats ([docs](https://www.unitpost.com/docs/email/get-email-stats))
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. Bounces are split into permanent and transient, with the sends that skipped a suppressed recipient and the unsubscribes your emails led to. Control the window with `days` (1–365, default 30), or count a period with `from` and `to`; add `grain` (and `tz`) for the send volume per bucket, as the dashboard home charts it. `domain_id` counts one sending domain. A filter value it can't apply (a typo, an unknown id) is ignored and named in the `Unitpost-Warning` header, and the answer is the one without it; send `Unitpost-Strict: true` to have it refused instead. For the individual emails behind these numbers, see [List sent emails](https://www.unitpost.com/docs/email/get-email). Requires the `emails:read` capability.
- `GET /api/v1/email/received`: List received emails ([docs](https://www.unitpost.com/docs/email/get-email-received))
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](https://www.unitpost.com/docs/email/get-email-received-id) to read the full content and attachment metadata. Requires the `emails:read` capability.
- `GET /api/v1/email/threads`: List conversations ([docs](https://www.unitpost.com/docs/email/get-email-threads))
Your inbox's conversations, newest activity first, as Emails → Received shows them: each received message and the replies and forwards sent from it are one conversation. Every conversation is listed: read state and Spam are the dashboard's. `q` searches the subject, the sender and the opening words. Page with `next_cursor` as `after`. `snippet` is null when the newest message is one you sent (or a copy of your own send): sent bodies aren't on the API, and `q` never matches a withheld snippet, so a page can be shorter than `limit` while `has_more` is true. `q` shares the search rate policy. Any other query key is a `422`. Requires the `emails:read` capability.
- `GET /api/v1/email/threads/{id}`: Retrieve a conversation ([docs](https://www.unitpost.com/docs/email/get-email-threads-id))
One conversation, oldest message first: what you received (bodies, `reply_to`, verdicts, attachments) and what you sent from it (status and recipients, no body). A long conversation comes in pages, newest first: this read holds its newest `limit` messages (fewer when their bodies are large), and `older_cursor` passed as `before` reads the messages before them, as the inbox's "Show earlier messages" does. Answer a message with [Reply to a conversation](https://www.unitpost.com/docs/email/post-email-threads-id-reply). Requires the `emails:read` capability.
- `DELETE /api/v1/email/threads/{id}`: Delete a conversation ([docs](https://www.unitpost.com/docs/email/delete-email-threads-id))
Delete a conversation for good, as the inbox's Delete does. Its received messages are erased (they still count toward usage, and never come back); replies and forwards sent from it stay on your sent emails. It can't be undone. Also requires `emails:read`. Requires the `emails:send` capability.
- `POST /api/v1/email/threads/{id}/reply`: Reply to a conversation ([docs](https://www.unitpost.com/docs/email/post-email-threads-id-reply))
Reply, reply all or forward one received message of the conversation (`received_email_id`), as the inbox does: your `text` and/or `html` go on top and the original is quoted under them; a reply carries In-Reply-To and References; a forward carries the attachments you list in `forward_attachment_ids`. The message joins the conversation and the conversation is marked read. For a reply, send `to` the message's `reply_to` addresses, else its `from`. It goes out now (no `scheduled_at`) through the same checks as any send. Pass an `Idempotency-Key`: a retry with it never sends twice. A copy of one of your own sends (`sent_by_us`) is never forwarded or quoted, since what you send isn't readable through the API: a reply to it goes without the quote, and a forward of it is a `403`. Also requires `emails:read` (a reply quotes mail). Requires the `emails:send` capability.
- `GET /api/v1/email/received/{id}`: Retrieve a received email ([docs](https://www.unitpost.com/docs/email/get-email-received-id))
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](https://www.unitpost.com/docs/email/get-email-received-id-attachments-attachmentid). Browse your inbound mail with [List received emails](https://www.unitpost.com/docs/email/get-email-received). Requires the `emails:read` capability.
- `GET /api/v1/email/received/{id}/attachments/{attachmentId}`: Get an attachment download URL ([docs](https://www.unitpost.com/docs/email/get-email-received-id-attachments-attachmentid))
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](https://www.unitpost.com/docs/email/get-email-received-id). A copy of an email you sent (`sent_by_us`) is a `403`, like its body: what you send isn't readable through the API. Requires the `emails:read` capability.
- `GET /api/v1/email/received/{id}/raw`: Get a received email's original message ([docs](https://www.unitpost.com/docs/email/get-email-received-id-raw))
Get a short-lived, signed URL for the received message's full original MIME (`.eml`), headers, every part and attachment included. A body over 256 KB isn't inlined on [Retrieve a received email](https://www.unitpost.com/docs/email/get-email-received-id), so this is how to read it. Download promptly: the URL expires in about 5 minutes. Stored messages are kept 30 days. A copy of an email you sent (`sent_by_us`) is a `403`. Requires the `emails:read` capability.
- `GET /api/v1/contacts`: List contacts ([docs](https://www.unitpost.com/docs/platform/get-contacts))
Retrieve a list of the contacts in your workspace, newest-first and cursor-paginated. Each row includes the contact's core fields, its SMS number (`phone`) and any custom field values. Narrow the list with `q` (email, first or last name, and 3+ digits of a phone number), `status`, `phone`, `segment_id` (members of a segment; also requires `segments:read`) and, for a static segment, `segment_subscription`. A filter value it can't apply (a typo, an unknown id) is ignored and named in the `Unitpost-Warning` header, and the answer is the one without it; send `Unitpost-Strict: true` to have it refused instead. Use a contact's `id` (or email) with [Retrieve a contact](https://www.unitpost.com/docs/platform/get-contacts-id) for the full record, add contacts with [Create a contact](https://www.unitpost.com/docs/platform/post-contacts), or bring a list in bulk with [Start an async contact import](https://www.unitpost.com/docs/platform/post-contacts-imports). Requires the `contacts:read` capability.
- `POST /api/v1/contacts`: Create a contact ([docs](https://www.unitpost.com/docs/platform/post-contacts))
Add a single contact to your workspace. Set an `email` plus any standard or custom field values, and optionally its SMS number (`phone`). A number is contact data, not consent: record SMS consent with [Record SMS consent](https://www.unitpost.com/docs/sms/post-contacts-id-sms-consent). Custom fields must already exist (define them with [Create a custom contact field](https://www.unitpost.com/docs/platform/post-contact-fields)). Emails are unique per workspace, so creating one that already exists returns `409` — use [Update a contact](https://www.unitpost.com/docs/platform/patch-contacts-id) to change an existing contact, or [Start an async contact import](https://www.unitpost.com/docs/platform/post-contacts-imports) to add many at once. Requires the `contacts:write` capability.
- `POST /api/v1/contacts/export`: Export contacts ([docs](https://www.unitpost.com/docs/platform/post-contacts-export))
Export contacts to a CSV file, as the Contacts page's Export does: every contact, or the ones the list's filters match (`q`, `status`, `phone`, `segment_id`, `segment_subscription`, as on [List contacts](https://www.unitpost.com/docs/platform/get-contacts)). It runs as a background job: follow it with [Retrieve a job](https://www.unitpost.com/docs/platform/get-jobs-id), then fetch the file with [Download a job's file](https://www.unitpost.com/docs/platform/get-jobs-id-download). One export runs at a time: another while it runs is a `409` `job_in_progress` naming it (`Location`). `segment_id` also needs `segments:read`. Requires the `contacts:read` capability.
- `GET /api/v1/contacts/{id}`: Retrieve a contact ([docs](https://www.unitpost.com/docs/platform/get-contacts-id))
Retrieve a single contact and all of its field values. The `{id}` path segment accepts either the contact's id or its email address, so you can look a contact up without storing our id. Change it with [Update a contact](https://www.unitpost.com/docs/platform/patch-contacts-id), or see which subscription topics it's opted into with [List a contact's topic subscriptions](https://www.unitpost.com/docs/email/get-contacts-id-topics). Requires the `contacts:read` capability.
- `PATCH /api/v1/contacts/{id}`: Update a contact ([docs](https://www.unitpost.com/docs/platform/patch-contacts-id))
Update a contact's standard or custom field values. Top-level fields you omit are left as-is, but `custom_fields` REPLACES the contact's whole map: read the contact first and send every key you want to keep. `phone` sets the contact's SMS number (consent is recorded separately); `phone: null` removes every SMS number it has, with `Unitpost-Strict: true` (without it the numbers are kept and `Unitpost-Warning` says so). Unknown keys are ignored. The `{id}` accepts a contact id or email. To manage a contact's subscription preferences instead, use [Set a contact's topic preference](https://www.unitpost.com/docs/email/post-contacts-id-topics). Requires the `contacts:write` capability.
- `DELETE /api/v1/contacts/{id}`: Delete a contact ([docs](https://www.unitpost.com/docs/platform/delete-contacts-id))
Permanently delete a contact and its field values. This also removes the contact from every segment it belonged to, including the audience of any unfinished campaign. To stop mailing someone without deleting them, unsubscribe them from a topic with [Set a contact's topic preference](https://www.unitpost.com/docs/email/post-contacts-id-topics) or add them to your [suppression list](https://www.unitpost.com/docs/platform/post-suppressions) instead. Requires the `contacts:write` capability.
- `GET /api/v1/contacts/{id}/addresses`: List a contact's addresses ([docs](https://www.unitpost.com/docs/platform/get-contacts-id-addresses))
Every address the contact can be reached at: its email addresses and SMS numbers, each with its status and, for a number, its SMS consent state. The contact's `phone` is its primary number; this lists all of them, including numbers the person texted in from. The full consent history is [Get a contact's SMS consent](https://www.unitpost.com/docs/sms/get-contacts-id-sms-consent). The `{id}` accepts a contact id or email. Not paginated. Requires the `contacts:read` capability.
- `GET /api/v1/contacts/{id}/activity`: List a contact's recent messages ([docs](https://www.unitpost.com/docs/platform/get-contacts-id-activity))
The contact's most recent messages across channels, newest first: the emails sent to them, and texts both ways. Emails also require `emails:read` and texts `sms:read`; without one, those rows are left out. Returns up to `limit` (1–100, default 25) with no cursor: for the full history page [List sent emails](https://www.unitpost.com/docs/email/get-email) or [List SMS messages](https://www.unitpost.com/docs/sms/get-sms) with `contact_id`. The `{id}` accepts a contact id or email. Requires the `contacts:read` capability.
- `GET /api/v1/contacts/{id}/segments`: List a contact's segments ([docs](https://www.unitpost.com/docs/platform/get-contacts-id-segments))
The segments the contact is in: static segments by membership, with its per-segment opt-out (`subscribed`), and dynamic segments whose filter it matches now. Not paginated. From the segment's side, see [List segment members](https://www.unitpost.com/docs/platform/get-segments-id-contacts). The `{id}` accepts a contact id or email. Requires the `contacts:read` capability.
- `GET /api/v1/contacts/imports`: List contact imports ([docs](https://www.unitpost.com/docs/platform/get-contacts-imports))
Retrieve a list of your bulk contact-import jobs, newest-first and cursor-paginated. Each row is a reference to one import and its current progress. Start a new one with [Start an async contact import](https://www.unitpost.com/docs/platform/post-contacts-imports), or drill into a single job with [Retrieve a contact import](https://www.unitpost.com/docs/platform/get-contacts-imports-id). Requires the `contacts:read` capability.
- `POST /api/v1/contacts/imports`: Start an async contact import ([docs](https://www.unitpost.com/docs/platform/post-contacts-imports))
Import a batch of contacts (up to 5,000 per request) in the background. The call returns immediately with an import job you poll for progress and per-row results — new contacts are created and existing ones (matched by email) are skipped, not changed. Track it with [Retrieve a contact import](https://www.unitpost.com/docs/platform/get-contacts-imports-id), or see all your imports via [List contact imports](https://www.unitpost.com/docs/platform/get-contacts-imports). To add a single contact synchronously, use [Create a contact](https://www.unitpost.com/docs/platform/post-contacts). Requires the `contacts:write` capability.
- `GET /api/v1/contacts/imports/{id}`: Retrieve a contact import ([docs](https://www.unitpost.com/docs/platform/get-contacts-imports-id))
Retrieve a bulk import job by `id` to check its status and per-row results — how many contacts were created, updated, or skipped, and why any rows failed. Poll this while an import runs. Start one with [Start an async contact import](https://www.unitpost.com/docs/platform/post-contacts-imports). Requires the `contacts:read` capability.
- `POST /api/v1/contacts/imports/{id}/cancel`: Cancel a contact import ([docs](https://www.unitpost.com/docs/platform/post-contacts-imports-id-cancel))
Stop a `pending` or `processing` import job. A running import stops at its next chunk of rows; contacts it already imported are kept. The job's status becomes `canceled`. A job that has already finished returns 409. Check its progress first with [Retrieve a contact import](https://www.unitpost.com/docs/platform/get-contacts-imports-id). Requires the `contacts:write` capability.
- `POST /api/v1/contacts/imports/{id}/retry`: Retry a contact import ([docs](https://www.unitpost.com/docs/platform/post-contacts-imports-id-retry))
Re-run a `failed` import from the rows stored on the job, without uploading them again. Rows that landed in the failed run are skipped as duplicates, so a retry only adds what is missing. Any other state is a `409` (a second retry while the first runs finds the job `pending`), and so is a job whose rows are no longer kept: start a new import. Same rate limit as starting an import. No body; track it with [Retrieve a contact import](https://www.unitpost.com/docs/platform/get-contacts-imports-id). Requires the `contacts:write` capability.
- `GET /api/v1/contact-fields`: List custom contact fields ([docs](https://www.unitpost.com/docs/platform/get-contact-fields))
Retrieve a list of the custom contact fields defined in your workspace, cursor-paginated. These are the typed attributes (text, number, date, boolean, and so on) you can set on any contact and reference as template variables. Define a new one with [Create a custom contact field](https://www.unitpost.com/docs/platform/post-contact-fields). Requires the `contacts:read` capability.
- `POST /api/v1/contact-fields`: Create a custom contact field ([docs](https://www.unitpost.com/docs/platform/post-contact-fields))
Define a new typed custom field that can then be set on any contact and used as a template variable (e.g. `plan`, `signup_date`). An optional `default_value` is the value used for any contact that has none: when an email is sent (merge tags) and when segments are evaluated. It's never written onto contacts, so changing it takes effect everywhere at once. The `key` and `type` are fixed once created — rename the key later with [Rename a custom field's key](https://www.unitpost.com/docs/platform/post-contact-fields-id-rename), or change the label with [Update a custom contact field](https://www.unitpost.com/docs/platform/patch-contact-fields-id). See existing fields via [List custom contact fields](https://www.unitpost.com/docs/platform/get-contact-fields). Requires the `contacts:write` capability.
- `GET /api/v1/contact-fields/{id}`: Retrieve a custom contact field ([docs](https://www.unitpost.com/docs/platform/get-contact-fields-id))
Retrieve a single custom contact field by `id`, including its key, type, and label. Update its label with [Update a custom contact field](https://www.unitpost.com/docs/platform/patch-contact-fields-id), or rename the key with [Rename a custom field's key](https://www.unitpost.com/docs/platform/post-contact-fields-id-rename). Requires the `contacts:read` capability.
- `PATCH /api/v1/contact-fields/{id}`: Update a custom contact field ([docs](https://www.unitpost.com/docs/platform/patch-contact-fields-id))
Update a custom field's editable metadata, such as its display label or `default_value`. A changed default takes effect at once, for sends and for segment membership, because it is never stored on contacts. The `key` and `type` are immutable here — to change the key (and migrate every contact's stored value) use [Rename a custom field's key](https://www.unitpost.com/docs/platform/post-contact-fields-id-rename) instead. Requires the `contacts:write` capability.
- `DELETE /api/v1/contact-fields/{id}`: Delete a custom contact field ([docs](https://www.unitpost.com/docs/platform/delete-contact-fields-id))
Permanently delete a custom field definition and strip its stored value from every contact. Any template variable that referenced it will resolve to empty. On more than 5,000 contacts the strip runs as a background job and the response is `202` with the job's progress instead of `204`. This can't be undone — recreate the field with [Create a custom contact field](https://www.unitpost.com/docs/platform/post-contact-fields) if you need it back. Requires the `contacts:write` capability.
- `POST /api/v1/contact-fields/{id}/rename`: Rename a custom field's key ([docs](https://www.unitpost.com/docs/platform/post-contact-fields-id-rename))
Change a custom field's `key`, migrating the stored value on every contact so no data is lost. Because the key is how templates and the API reference the field, update any template variables that used the old key. To change only the display label, use [Update a custom contact field](https://www.unitpost.com/docs/platform/patch-contact-fields-id). Requires the `contacts:write` capability.
- `GET /api/v1/jobs/{id}`: Retrieve a job ([docs](https://www.unitpost.com/docs/platform/get-jobs-id))
Retrieve a background job by `id` to follow its progress. An operation that touches more contacts than fits in one request (for example [Delete a custom contact field](https://www.unitpost.com/docs/platform/delete-contact-fields-id) or [Rename a custom field's key](https://www.unitpost.com/docs/platform/post-contact-fields-id-rename) above 5,000 holders, or a campaign validation over more than 5,000 recipients) answers `202` with a job. Poll this until `status` is `COMPLETED`, `FAILED` or `CANCELED`; `processed` of `total` is the progress. A failed job keeps the work it finished; resume it with [Retry a job](https://www.unitpost.com/docs/platform/post-jobs-id-retry). Reading a job takes the read capability of the resource it changes (`contacts:read` for a custom-field job or a contacts export, `campaigns:read` for a campaign check, `topics:read` for a topic opt-out). Requires the `contacts:read` capability.
- `GET /api/v1/jobs`: List jobs ([docs](https://www.unitpost.com/docs/platform/get-jobs))
Your background jobs, newest first and cursor-paginated, as Settings → Bulk operations lists them: the kinds an API operation starts that your key may read. Filter with `status` or `kind`. Requires the `contacts:read` capability.
- `POST /api/v1/jobs/{id}/cancel`: Cancel a job ([docs](https://www.unitpost.com/docs/platform/post-jobs-id-cancel))
Stop a queued or running job. A running job stops after the part in flight; the work already done stays. A kind that can't stop half-way (a custom-field rename) is a `409` saying why. Takes no body. Needs the capability that starts the kind (`contacts:write` for a custom-field job, `contacts:read` for an export, `topics:write` for a topic opt-out). Requires the `contacts:read` or `contacts:write` capability.
- `POST /api/v1/jobs/{id}/retry`: Retry a job ([docs](https://www.unitpost.com/docs/platform/post-jobs-id-retry))
Resume a `FAILED` job from where it stopped: nothing it already processed runs again, but the rest does (a custom-field delete finishes removing the field from every contact). Any other status is a `409`. Takes no body. Needs the capability that starts the kind (`contacts:write` for a custom-field job, `contacts:read` for an export, `topics:write` for a topic opt-out). Requires the `contacts:read` or `contacts:write` capability.
- `GET /api/v1/jobs/{id}/download`: Download a job's file ([docs](https://www.unitpost.com/docs/platform/get-jobs-id-download))
A finished job's file (a contacts export's CSV) as a signed `url` that expires in 5 minutes. A job that isn't `COMPLETED`, or a kind with no file, is a `409`. Needs the kind's capability now: the file holds that data. Requires the `contacts:read` capability.
- `GET /api/v1/segments`: List segments ([docs](https://www.unitpost.com/docs/platform/get-segments))
Retrieve a list of the segments in your workspace, cursor-paginated. A segment is a saved group of contacts you can target when sending a campaign. Create one with [Create a segment](https://www.unitpost.com/docs/platform/post-segments), or list a segment's members with [List segment members](https://www.unitpost.com/docs/platform/get-segments-id-contacts). Deleted (archived) segments are hidden; pass `?archived=true` to list only archived ones, which can be restored for 30 days. Requires the `segments:read` capability.
- `POST /api/v1/segments`: Create a segment ([docs](https://www.unitpost.com/docs/platform/post-segments))
Create a segment — a named group of contacts you can target when sending a campaign. Once created, add contacts with [Add a member](https://www.unitpost.com/docs/platform/post-segments-id-contacts) and target it from [Create a campaign](https://www.unitpost.com/docs/email/post-email-campaigns). A dynamic segment's `filter` is evaluated live: a condition on a custom field uses the field's `default_value` for any contact that has no value, and `is_set` / `is_not_set` check whether the contact itself has a value. To let recipients opt out of a category of mail instead, see [Create a topic](https://www.unitpost.com/docs/email/post-email-topics). Requires the `segments:write` capability.
- `GET /api/v1/segments/{id}`: Retrieve a segment ([docs](https://www.unitpost.com/docs/platform/get-segments-id))
Retrieve a single segment by `id`, including its name and member count. To page through the contacts inside it, use [List segment members](https://www.unitpost.com/docs/platform/get-segments-id-contacts). Requires the `segments:read` capability.
- `PATCH /api/v1/segments/{id}`: Update a segment ([docs](https://www.unitpost.com/docs/platform/patch-segments-id))
Update a segment's editable metadata, such as its name. To change who's in the segment, add or remove members with [Add a member](https://www.unitpost.com/docs/platform/post-segments-id-contacts) and [Remove a member](https://www.unitpost.com/docs/platform/delete-segments-id-contacts-contact). Requires the `segments:write` capability.
- `DELETE /api/v1/segments/{id}`: Delete a segment ([docs](https://www.unitpost.com/docs/platform/delete-segments-id))
Delete a segment. The contacts themselves are untouched — only the grouping is removed. This is blocked with `409` while an active campaign still targets the segment; cancel or finish that campaign first (see [List campaigns](https://www.unitpost.com/docs/email/get-email-campaigns)). Requires the `segments:write` capability.
- `GET /api/v1/segments/{id}/contacts`: List segment members ([docs](https://www.unitpost.com/docs/platform/get-segments-id-contacts))
Retrieve the contacts in a segment, cursor-paginated. Add a contact with [Add a member](https://www.unitpost.com/docs/platform/post-segments-id-contacts) or take one out with [Remove a member](https://www.unitpost.com/docs/platform/delete-segments-id-contacts-contact). Requires the `segments:read` capability.
- `POST /api/v1/segments/{id}/contacts`: Add a member ([docs](https://www.unitpost.com/docs/platform/post-segments-id-contacts))
Add a contact to a segment, referenced by contact id or email. The operation is idempotent — adding a contact that's already a member is a no-op and still succeeds. See the current members with [List segment members](https://www.unitpost.com/docs/platform/get-segments-id-contacts). Requires the `segments:write` capability.
- `DELETE /api/v1/segments/{id}/contacts/{contact}`: Remove a member ([docs](https://www.unitpost.com/docs/platform/delete-segments-id-contacts-contact))
Remove a contact from a segment, referenced by contact id or email. The contact itself is not deleted — only its membership. Add it back any time with [Add a member](https://www.unitpost.com/docs/platform/post-segments-id-contacts). Requires the `segments:write` capability.
- `PATCH /api/v1/segments/{id}/contacts/{contact}`: Opt a member in or out ([docs](https://www.unitpost.com/docs/platform/patch-segments-id-contacts-contact))
Opt a member out of this segment's sends without removing it (`subscribed: false`), or back in, as the members table's toggle and the preference page do. It stays a member. A dynamic segment has no per-segment opt-out (`422`). Requires the `segments:write` capability.
- `POST /api/v1/segments/preview`: Preview a filter ([docs](https://www.unitpost.com/docs/platform/post-segments-preview))
Count an unsaved dynamic filter before you create the segment, as the segment dialog's live count does: how many contacts match, how many of them a campaign would mail, and a sample (which also needs `contacts:read`). It reads with the same filter builder the send uses. Nothing is saved. Requires the `segments:read` capability.
- `GET /api/v1/segments/{id}/preview`: Preview a segment ([docs](https://www.unitpost.com/docs/platform/get-segments-id-preview))
How many contacts a saved segment reaches: its members, the subscribed set a campaign would mail (`0` means a campaign would send nothing), and a sample (which also needs `contacts:read`). Requires the `segments:read` capability.
- `POST /api/v1/segments/all-contacts`: Get the all-contacts segment ([docs](https://www.unitpost.com/docs/platform/post-segments-all-contacts))
The workspace's managed "All subscribed contacts" segment, made the first time it's asked for: the dashboard's "Everyone subscribed" campaign target. Repeating the call returns the same segment. Takes no body. Requires the `segments:write` capability.
- `GET /api/v1/email/topics`: List topics ([docs](https://www.unitpost.com/docs/email/get-email-topics))
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](https://www.unitpost.com/docs/email/post-email-topics), or check a contact's preferences with [List a contact's topic subscriptions](https://www.unitpost.com/docs/email/get-contacts-id-topics). Requires the `topics:read` capability.
- `POST /api/v1/email/topics`: Create a topic ([docs](https://www.unitpost.com/docs/email/post-email-topics))
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](https://www.unitpost.com/docs/email/post-email-campaigns), and set an individual contact's preference with [Set a contact's topic preference](https://www.unitpost.com/docs/email/post-contacts-id-topics). Topics differ from segments: a [segment](https://www.unitpost.com/docs/platform/post-segments) decides who a campaign targets, a topic lets recipients opt out. Requires the `topics:write` capability.
- `GET /api/v1/email/topics/{id}`: Retrieve a topic ([docs](https://www.unitpost.com/docs/email/get-email-topics-id))
Retrieve a single topic by `id`, including its name and `default_opt_in` setting. Change it with [Update a topic](https://www.unitpost.com/docs/email/patch-email-topics-id), or list every topic with [List topics](https://www.unitpost.com/docs/email/get-email-topics). Requires the `topics:read` capability.
- `PATCH /api/v1/email/topics/{id}`: Update a topic ([docs](https://www.unitpost.com/docs/email/patch-email-topics-id))
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](https://www.unitpost.com/docs/email/post-contacts-id-topics). Requires the `topics:write` capability.
- `DELETE /api/v1/email/topics/{id}`: Delete a topic ([docs](https://www.unitpost.com/docs/email/delete-email-topics-id))
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](https://www.unitpost.com/docs/email/patch-email-topics-id). Requires the `topics:write` capability.
- `POST /api/v1/email/topics/{id}/opt-out`: Opt contacts out of a topic ([docs](https://www.unitpost.com/docs/email/post-email-topics-id-opt-out))
Opt many contacts out of this topic in one call, as a background job: the contacts you list (`contact_ids` or `emails`, up to 1,000, each a contact of this workspace) or every member of a segment (`segment_id`, however many; also needs `segments:read`). Send exactly one. Each contact is opted out as [Set a contact's topic preference](https://www.unitpost.com/docs/email/post-contacts-id-topics) would: one that was subscribed gets a `topic.unsubscribed` webhook and an activity row naming your key; one already opted out is left as it is, with no webhook. Suppressions and other topics are untouched, and there is no bulk opt-in. Listed contacts are checked first: the first that isn't one of yours is a `404` naming it (`details`), and nothing starts. The answer is the job (`202`, `Location`); follow it with [Retrieve a job](https://www.unitpost.com/docs/platform/get-jobs-id). Pass an `Idempotency-Key` so a retry answers the same job instead of starting another. Requires the `topics:write` capability.
- `GET /api/v1/contacts/{id}/topics`: List a contact's topic subscriptions ([docs](https://www.unitpost.com/docs/email/get-contacts-id-topics))
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](https://www.unitpost.com/docs/email/post-contacts-id-topics). Requires the `topics:read` capability.
- `POST /api/v1/contacts/{id}/topics`: Set a contact's topic preference ([docs](https://www.unitpost.com/docs/email/post-contacts-id-topics))
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](https://www.unitpost.com/docs/email/get-contacts-id-topics); change several at once with [Update a contact's topic preferences](https://www.unitpost.com/docs/email/patch-contacts-id-topics). Requires the `topics:write` capability.
- `PUT /api/v1/contacts/{id}/topics`: Set a contact's topic preference (alias of POST) ([docs](https://www.unitpost.com/docs/email/put-contacts-id-topics))
Identical to [Set a contact's topic preference](https://www.unitpost.com/docs/email/post-contacts-id-topics) — provided so clients that prefer `PUT` semantics for an upsert can use it interchangeably. Requires the `topics:write` capability.
- `PATCH /api/v1/contacts/{id}/topics`: Update a contact's topic preferences ([docs](https://www.unitpost.com/docs/email/patch-contacts-id-topics))
Subscribe or unsubscribe a contact for several topics in one call: `topics` lists up to 50 preferences, each topic once. Topics you leave out keep their current preference. The contact and every topic are checked before any preference is set, so an unknown topic is a `404` naming it and nothing changes. Each preference is then recorded as [Set a contact's topic preference](https://www.unitpost.com/docs/email/post-contacts-id-topics) records one (`topic.subscribed` / `topic.unsubscribed` only when it actually changes), and it's idempotent. The answer lists the resolved subscriptions in the order sent. Any key other than `topics`, in the body or in an item, is a `422`. Requires the `topics:write` capability.
- `GET /api/v1/contacts/{id}/sms-consent`: Get a contact's SMS consent ([docs](https://www.unitpost.com/docs/sms/get-contacts-id-sms-consent))
Read a contact's current SMS consent state and the full immutable consent history — what was agreed to, when, and from which source. Use this to answer a carrier or regulator asking you to prove a recipient opted in, without contacting support. `marketing_eligible` is the field that decides whether marketing SMS to this contact will be accepted. Requires the `contacts:read` capability.
- `POST /api/v1/contacts/{id}/sms-consent`: Record SMS consent ([docs](https://www.unitpost.com/docs/sms/post-contacts-id-sms-consent))
Record an SMS opt-in or opt-out that a recipient gave you — typically a checkbox on your own signup form. This is the supported way to bring consent you already hold into Unitpost so marketing SMS is accepted for that contact.
`source` is required: consent without recorded provenance is not consent, and the source is what makes the audit trail defensible. `occurred_at` may be backdated to when the recipient actually agreed (do not pretend historical consent happened just now); a future timestamp is rejected. Every call appends an immutable event, so the history is never overwritten.
Recording an opt-out suppresses SMS for this contact only — it does not touch their email subscription. Requires the `contacts:write` capability.
- `POST /api/v1/contacts/{id}/sms-opt-in-request`: Request SMS opt-in confirmation ([docs](https://www.unitpost.com/docs/sms/post-contacts-id-sms-opt-in-request))
Text the contact "Reply YES to get our marketing texts" (double opt-in). A YES within 72 hours records a verified marketing opt-in; until then their existing consent stands. It only upgrades consent already on record: a number with none, one that opted out, or one already waiting for a reply is a `409` and nothing is sent. It sends a real text from a number they can reply to and charges your wallet, so pass an `Idempotency-Key`: a retry with the same key returns the same text instead of sending another. `phone` picks which of the contact's numbers (E.164); default its primary one. Any other body key is a `422`. Requires the `sms:send` capability.
- `GET /api/v1/email/campaigns`: List campaigns ([docs](https://www.unitpost.com/docs/email/get-email-campaigns))
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](https://www.unitpost.com/docs/email/get-email-campaigns-id) for the full record, or with [List sent emails](https://www.unitpost.com/docs/email/get-email) (`?campaign_id=`) to see the individual emails it produced. Deleted (archived) campaigns are hidden; pass `?archived=true` to list only archived ones, which can be restored for 30 days. Requires the `campaigns:read` capability.
- `POST /api/v1/email/campaigns`: Create a campaign ([docs](https://www.unitpost.com/docs/email/post-email-campaigns))
Create a campaign in `draft`. Target either a saved segment (`segment_id`, see [Create a segment](https://www.unitpost.com/docs/platform/post-segments)) 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](https://www.unitpost.com/docs/email/patch-email-campaigns-id), check it with [Validate a campaign](https://www.unitpost.com/docs/email/get-email-campaigns-id-validate), then launch it with [Send a campaign](https://www.unitpost.com/docs/email/post-email-campaigns-id-send). Requires the `campaigns:write` capability.
- `GET /api/v1/email/campaigns/{id}`: Retrieve a campaign ([docs](https://www.unitpost.com/docs/email/get-email-campaigns-id))
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](https://www.unitpost.com/docs/email/get-email) by `campaign_id`; for a pre-send readiness report, use [Validate a campaign](https://www.unitpost.com/docs/email/get-email-campaigns-id-validate). Requires the `campaigns:read` capability.
- `PATCH /api/v1/email/campaigns/{id}`: Update a campaign ([docs](https://www.unitpost.com/docs/email/patch-email-campaigns-id))
Edit a campaign while it's still in `draft` (edits after that return `409`; unschedule a scheduled one first with [Unschedule a campaign](https://www.unitpost.com/docs/email/post-email-campaigns-id-unschedule)). Change its `name`, `subject`, `from`, `preview_text`, `template_id` or `segment_id` (a template or segment gets create's checks: this workspace's, published, not archived). A value equal to the draft's is no edit, so you can send back what you read. 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` (a misnamed time key is a `422`). Other keys are ignored. See what's blocking a send with [Validate a campaign](https://www.unitpost.com/docs/email/get-email-campaigns-id-validate). Requires the `campaigns:write` capability.
- `DELETE /api/v1/email/campaigns/{id}`: Delete a campaign ([docs](https://www.unitpost.com/docs/email/delete-email-campaigns-id))
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](https://www.unitpost.com/docs/email/post-email-campaigns-id-cancel). Requires the `campaigns:write` capability.
- `POST /api/v1/email/campaigns/{id}/send`: Send a campaign ([docs](https://www.unitpost.com/docs/email/post-email-campaigns-id-send))
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](https://www.unitpost.com/docs/email/get-email-campaigns-id-validate); fix variable issues via [Update a campaign](https://www.unitpost.com/docs/email/patch-email-campaigns-id). A launched campaign can be [paused](https://www.unitpost.com/docs/email/post-email-campaigns-id-pause) or [canceled](https://www.unitpost.com/docs/email/post-email-campaigns-id-cancel), and a scheduled one [rescheduled](https://www.unitpost.com/docs/email/post-email-campaigns-id-reschedule). Requires the `campaigns:send` capability.
- `POST /api/v1/email/campaigns/{id}/cancel`: Cancel a campaign ([docs](https://www.unitpost.com/docs/email/post-email-campaigns-id-cancel))
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](https://www.unitpost.com/docs/email/post-email-campaigns-id-pause) instead. Requires the `campaigns:send` capability.
- `POST /api/v1/email/campaigns/{id}/pause`: Pause a campaign ([docs](https://www.unitpost.com/docs/email/post-email-campaigns-id-pause))
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](https://www.unitpost.com/docs/email/post-email-campaigns-id-resume), or stop it for good with [Cancel a campaign](https://www.unitpost.com/docs/email/post-email-campaigns-id-cancel). Requires the `campaigns:send` capability.
- `POST /api/v1/email/campaigns/{id}/unschedule`: Unschedule a campaign ([docs](https://www.unitpost.com/docs/email/post-email-campaigns-id-unschedule))
Move a `scheduled` campaign (or one `paused` before it started sending) back to `draft`, same id, so you can edit it. Content, audience and overrides are kept; the saved send time is cleared, so the next [Send a campaign](https://www.unitpost.com/docs/email/post-email-campaigns-id-send) needs an explicit `scheduled_at` (`409` `send_time_required` without one). A campaign that started sending can't be unscheduled: cancel it and [duplicate](https://www.unitpost.com/docs/email/post-email-campaigns-id-duplicate) it (the copy goes to the whole segment again, including people the first run reached, unless you set `excluded_contact_ids`). No body; any key is a `422`. Requires the `campaigns:send` capability.
- `GET /api/v1/email/campaigns/stats`: Results for several campaigns ([docs](https://www.unitpost.com/docs/email/get-email-campaigns-stats))
The same numbers as [Campaign results](https://www.unitpost.com/docs/email/get-email-campaigns-id-stats) for up to 100 campaigns in one read: `ids`, comma-separated. An id that isn't one of this workspace's email campaigns is left out of `data`, never an error. Pass `ids` once; a repeated or any other query key is a `422`. Requires the `campaigns:read` capability.
- `GET /api/v1/email/campaigns/{id}/stats`: Campaign results ([docs](https://www.unitpost.com/docs/email/get-email-campaigns-id-stats))
A campaign's results: delivered, unique opens and clicks, unsubscribes, bounces, complaints and their rates, recipients, and who didn't receive it. Open and click rates are `null` when that tracking was off; every rate is `null` until something was sent. Old messages are cleaned up after your plan's retention window; their counts are kept (`source`). Requires the `campaigns:read` capability.
- `GET /api/v1/email/campaigns/{id}/recipients`: List who a campaign would reach ([docs](https://www.unitpost.com/docs/email/get-email-campaigns-id-recipients))
Who the campaign would reach if sent now, one row per segment member, by the send's own rule: `sendable`, `missing_data` (a required variable has no value), `suppressed` or `excluded`, with the reason. The numbers of [Validate a campaign](https://www.unitpost.com/docs/email/get-email-campaigns-id-validate), member by member. Rows run in contact-id order: pass `next_cursor` as `after` for the next page. A page can hold fewer rows than `limit` while `has_more` is true (a rare status in a big segment is walked in bounded steps). Rows name contacts, so this also requires `contacts:read`. Requires the `campaigns:read` capability.
- `GET /api/v1/email/campaigns/{id}/quality/affected`: List addresses a quality pause flagged ([docs](https://www.unitpost.com/docs/email/get-email-campaigns-id-quality-affected))
After an automatic quality pause: the recipients whose message hard-bounced (`hard_bounce`) or drew a spam complaint (`complaint`). They're already suppressed; this is for cleaning the source list. By address, `limit` per page (default and most 10,000); when `has_more`, pass the last row's `email` as `after` for the rest. Take them out of the campaign's segment with [Remove flagged addresses](https://www.unitpost.com/docs/email/post-email-campaigns-id-quality-remove-affected). Requires the `campaigns:read` capability.
- `POST /api/v1/email/campaigns/{id}/quality/remove-affected`: Remove flagged addresses from the audience ([docs](https://www.unitpost.com/docs/email/post-email-campaigns-id-quality-remove-affected))
While a campaign is paused for its delivery quality: take every hard-bounced and complained contact out of its static segment. It edits a reusable segment for good. Each removal records its activity and fires `segment.left` (the first at once, the rest within a few minutes); a repeat removes nothing. A campaign that isn't paused for quality, or a filter (dynamic) segment, is a `409` (a filter's matches can't be removed one by one, and they're suppressed anyway). It doesn't resume the campaign. No body. Requires the `segments:write` capability.
- `POST /api/v1/email/campaigns/{id}/quality/request-review`: Request a review for a paused campaign ([docs](https://www.unitpost.com/docs/email/post-email-campaigns-id-quality-request-review))
When a campaign the delivery-quality check paused again needs our team's review before it can resume ([Resume a campaign](https://www.unitpost.com/docs/email/post-email-campaigns-id-resume) answers `409 review_required`): ask for that review. Our team is notified once a day per campaign; a repeat answers `already_requested: true` and notifies no one. A campaign that isn't waiting for a review is a `409`, and so is one paused while our team reviews the whole account (`resume: account_review`). No body. Requires the `campaigns:send` capability.
- `POST /api/v1/email/campaigns/{id}/duplicate`: Duplicate a campaign ([docs](https://www.unitpost.com/docs/email/post-email-campaigns-id-duplicate))
Copy a campaign into a new `draft`. Only configuration is copied (name, template, segment, sender, subject, preview text, topic, tracking overrides, fallbacks, exclusions), never the send time or status, and every create check runs again (template published, segment not archived, domain, limits). Optional `name` for the copy; any other key is a `422`. Pass an `Idempotency-Key` so a retry returns the same copy instead of making another. Requires the `campaigns:write` capability.
- `POST /api/v1/email/campaigns/{id}/resume`: Resume a campaign ([docs](https://www.unitpost.com/docs/email/post-email-campaigns-id-resume))
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](https://www.unitpost.com/docs/email/post-email-campaigns-id-send) and answers with the same errors; the campaign stays `paused` when one fails. Pause a send with [Pause a campaign](https://www.unitpost.com/docs/email/post-email-campaigns-id-pause). Requires the `campaigns:send` capability.
- `POST /api/v1/email/campaigns/{id}/reschedule`: Reschedule a campaign ([docs](https://www.unitpost.com/docs/email/post-email-campaigns-id-reschedule))
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](https://www.unitpost.com/docs/email/post-email-campaigns-id-send) instead. Only campaigns that haven't started sending can be rescheduled — once a send is in flight, [pause](https://www.unitpost.com/docs/email/post-email-campaigns-id-pause) or [cancel](https://www.unitpost.com/docs/email/post-email-campaigns-id-cancel) it instead. Requires the `campaigns:send` capability.
- `GET /api/v1/email/campaigns/{id}/validate`: Validate a campaign ([docs](https://www.unitpost.com/docs/email/get-email-campaigns-id-validate))
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](https://www.unitpost.com/docs/email/patch-email-campaigns-id) (`variable_fallbacks` / `excluded_contact_ids`), then launch with [Send a campaign](https://www.unitpost.com/docs/email/post-email-campaigns-id-send). Requires the `campaigns:read` capability.
- `GET /api/v1/email/templates`: List templates ([docs](https://www.unitpost.com/docs/email/get-email-templates))
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](https://www.unitpost.com/docs/email/get-email-templates-id) to get it. Reference a template's `id` when sending with [Send or schedule an email](https://www.unitpost.com/docs/email/post-email) or when creating a campaign. Requires the `templates:read` capability.
- `POST /api/v1/email/templates`: Create a template ([docs](https://www.unitpost.com/docs/email/post-email-templates))
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 `` 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](https://www.unitpost.com/docs/email/post-email), and iterate with [Update a template](https://www.unitpost.com/docs/email/patch-email-templates-id). Requires the `templates:write` capability.
- `GET /api/v1/email/templates/{id}`: Retrieve a template ([docs](https://www.unitpost.com/docs/email/get-email-templates-id))
Retrieve a single template by `id`, including the rendered `html`, for a Design template its `tsx` with the `` theme element, and for a Simple one its `text` ([List templates](https://www.unitpost.com/docs/email/get-email-templates) omits both). Use this to read the body in its own editor, then save changes with [Update a template](https://www.unitpost.com/docs/email/patch-email-templates-id). Requires the `templates:read` capability.
- `PATCH /api/v1/email/templates/{id}`: Update a template ([docs](https://www.unitpost.com/docs/email/patch-email-templates-id))
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, and `category` switches it between marketing and transactional on any editor. A change to its body, subject or category, or an unpublish, is refused with `409` while a live campaign renders it (see [Get a template's usage](https://www.unitpost.com/docs/email/get-email-templates-id-usage)). Read the current body first with [Retrieve a template](https://www.unitpost.com/docs/email/get-email-templates-id). Requires the `templates:write` capability.
- `DELETE /api/v1/email/templates/{id}`: Delete a template ([docs](https://www.unitpost.com/docs/email/delete-email-templates-id))
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](https://www.unitpost.com/docs/email/get-email-campaigns) or [Get a template's usage](https://www.unitpost.com/docs/email/get-email-templates-id-usage)). A template a campaign or live automation still uses, or with queued sends, is a `409`. Requires the `templates:write` capability.
- `POST /api/v1/email/templates/{id}/render`: Render a template ([docs](https://www.unitpost.com/docs/email/post-email-templates-id-render))
The subject, HTML and text a send of this template would carry, with your footer and branding, without sending or storing anything. Pass `variables` as a send's `template.variables`, and `contact_id` to fill contact fields from that contact. `missing_variables` lists the variables left without a value: they render as a literal `{{token}}` here, and a real send refuses them. `{{unsubscribe_url}}` renders a preview link. A POST only because it takes a body; any other key is a `422`. Requires the `templates:read` capability.
- `POST /api/v1/email/templates/{id}/test-send`: Send a test of a template ([docs](https://www.unitpost.com/docs/email/post-email-templates-id-test-send))
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@` 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.
- `POST /api/v1/email/templates/{id}/duplicate`: Duplicate a template ([docs](https://www.unitpost.com/docs/email/post-email-templates-id-duplicate))
Copy a template into a new draft, as the dashboard's Duplicate does: its name (with " (copy)", or your `name`), subject, editor and body carry over; its status, versions and usage don't. Pass an `Idempotency-Key` so a retry returns the same copy instead of making a second one. Requires the `templates:write` capability.
- `POST /api/v1/email/templates/from-library`: Add a template from the gallery ([docs](https://www.unitpost.com/docs/email/post-email-templates-from-library))
Add one of the gallery's ready-made emails (unitpost.com/templates) to your workspace as a draft Design template, as the gallery's "Use this template" does. `slug` names it; the ones marked for paid plans answer `403 limit_reached` on the free plan. Edit it with [Update a template](https://www.unitpost.com/docs/email/patch-email-templates-id). Pass an `Idempotency-Key` so a retry returns the same template. Requires the `templates:write` capability.
- `GET /api/v1/email/templates/{id}/usage`: Get a template's usage ([docs](https://www.unitpost.com/docs/email/get-email-templates-id-usage))
What uses a template, and whether a change would be accepted now: the campaigns that reference it, its live campaigns and automations, queued sends, and `can_edit_content` / `can_unpublish` with the reason when not. Read it before an edit or delete: a content edit is refused while a live campaign renders the template, and a delete while any campaign uses it. Requires the `templates:read` capability.
- `GET /api/v1/email/templates/{id}/versions`: List a template's versions ([docs](https://www.unitpost.com/docs/email/get-email-templates-id-versions))
A template's history, newest first: a version is added on every publish and on every content save while it's published. Put one back with [Restore a template version](https://www.unitpost.com/docs/email/post-email-templates-id-versions-version-restore). Page with the last row's `version` as `after`. Requires the `templates:read` capability.
- `POST /api/v1/email/templates/{id}/versions/{version}/restore`: Restore a template version ([docs](https://www.unitpost.com/docs/email/post-email-templates-id-versions-version-restore))
Put a version's subject and body back, as the dashboard's version restore does. It is an update, so the update rules apply: a system template, or one a live campaign renders, is a `409` naming why ([Get a template's usage](https://www.unitpost.com/docs/email/get-email-templates-id-usage) says so up front). On a published template the restore adds a new version; history is never rewritten. Takes no body. Requires the `templates:write` capability.
- `GET /api/v1/brand-kits`: List brand kits ([docs](https://www.unitpost.com/docs/email/get-brand-kits))
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](https://www.unitpost.com/docs/email/get-brand-kits-id). Create, edit and import profiles with `branding:write`. Requires the `templates:read` capability.
- `POST /api/v1/brand-kits`: Create a brand kit ([docs](https://www.unitpost.com/docs/email/post-brand-kits))
Create an empty profile (`name`, unique in the workspace). The first profile, or one with `make_default`, becomes the default. At most five profiles (`403 limit_reached`). Fill it with [Update a brand kit](https://www.unitpost.com/docs/email/patch-brand-kits-id) or an import. Requires the `branding:write` capability.
- `GET /api/v1/brand-kits/{id}`: Retrieve a brand kit ([docs](https://www.unitpost.com/docs/email/get-brand-kits-id))
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](https://www.unitpost.com/docs/email/get-brand-kits). Requires the `templates:read` capability.
- `PATCH /api/v1/brand-kits/{id}`: Update a brand kit ([docs](https://www.unitpost.com/docs/email/patch-brand-kits-id))
Edit a profile: voice, tone, description, industry, website, core pages, the palette and logo candidates (`extracted`), and the pinned graphics (library images, `img_…`). Only the fields sent change; a list replaces the list; `null` or `""` clears a text field. `sync_visual` on the default profile also sets the email branding's accent from these colors. Requires the `branding:write` capability.
- `DELETE /api/v1/brand-kits/{id}`: Delete a brand kit ([docs](https://www.unitpost.com/docs/email/delete-brand-kits-id))
Delete a profile. When it was the default, the oldest remaining profile becomes the default. The last profile can't be deleted (`409`): reset it instead. Its pinned images stay in the library. Requires the `branding:write` capability.
- `POST /api/v1/brand-kits/{id}/reset`: Reset a brand kit ([docs](https://www.unitpost.com/docs/email/post-brand-kits-id-reset))
Clear a profile's content (voice, colors, graphics, pages) and keep its name and whether it's the default. Takes no body. Requires the `branding:write` capability.
- `POST /api/v1/brand-kits/{id}/set-default`: Make a brand kit the default ([docs](https://www.unitpost.com/docs/email/post-brand-kits-id-set-default))
Make a profile the default: the one agents write with unless told otherwise. Takes no body. Requires the `branding:write` capability.
- `POST /api/v1/brand-kits/{id}/graphics`: Add a graphic to a brand kit ([docs](https://www.unitpost.com/docs/email/post-brand-kits-id-graphics))
Add a graphic from a public https image `url` (fetched once, no redirects): it goes into the image library, counted against its storage, and is pinned with its `role` (default `logo`). To pin an image already in the library, [Update a brand kit](https://www.unitpost.com/docs/email/patch-brand-kits-id) with `graphics`. Requires the `branding:write` capability.
- `POST /api/v1/brand-kits/imports`: Import a brand kit ([docs](https://www.unitpost.com/docs/email/post-brand-kits-imports))
Build a profile from a website (`url`, or a bare domain) or the brand's own words (`text`). It runs in the background, usually under a minute (`202`; follow it with [Retrieve a brand kit import](https://www.unitpost.com/docs/email/get-brand-kits-imports-id)). By default the result is applied when it lands: to `brand_kit_id`, else the default profile, or a new one when the default already has content. With `review: true` it stops at a proposal (`preview_ready`) for [Accept a brand kit import](https://www.unitpost.com/docs/email/post-brand-kits-imports-id-accept); one review import is open at a time, and a new one replaces it. Applying an import started here never adds a sending domain (add one with [Add a domain](https://www.unitpost.com/docs/email/post-email-domains)). Requires the `branding:write` capability.
- `GET /api/v1/brand-kits/imports/{id}`: Retrieve a brand kit import ([docs](https://www.unitpost.com/docs/email/get-brand-kits-imports-id))
One import: its status, the proposal once it's ready, warnings and any error. Requires the `templates:read` capability.
- `POST /api/v1/brand-kits/imports/{id}/accept`: Accept a brand kit import ([docs](https://www.unitpost.com/docs/email/post-brand-kits-imports-id-accept))
Apply a review import's proposal (`preview_ready`) to a profile: voice, colors and pages, and its logos and backgrounds brought into the image library and pinned. Without `brand_kit_id` it fills the profile the import named, else the default, or a new profile when the default has content; the profile takes the name the import found unless you send `name`. A one-shot import whose automatic apply failed (`preview_ready` with an `error`) can be accepted too. The site becomes a sending domain only when the key holds `domains:write`. To change fields, accept and then [Update a brand kit](https://www.unitpost.com/docs/email/patch-brand-kits-id). The body is optional. Requires the `branding:write` capability.
- `POST /api/v1/brand-kits/imports/{id}/cancel`: Cancel a brand kit import ([docs](https://www.unitpost.com/docs/email/post-brand-kits-imports-id-cancel))
Discard a review import's proposal or stop its crawl, or discard a one-shot import whose automatic apply failed. Takes no body. Requires the `branding:write` capability.
- `GET /api/v1/email/domains`: List domains ([docs](https://www.unitpost.com/docs/email/get-email-domains))
Retrieve a list of your sending domains, cursor-paginated, with each domain's verification status. Add a new one with [Add a domain](https://www.unitpost.com/docs/email/post-email-domains), or fetch a single domain (including its DNS records) with [Retrieve a domain](https://www.unitpost.com/docs/email/get-email-domains-id). Requires the `domains:read` capability.
- `POST /api/v1/email/domains`: Add a domain ([docs](https://www.unitpost.com/docs/email/post-email-domains))
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](https://www.unitpost.com/docs/email/post-email-domains-id-verify) — sending from the domain is blocked until it verifies. Requires the `domains:write` capability.
- `GET /api/v1/email/domains/{id}`: Retrieve a domain ([docs](https://www.unitpost.com/docs/email/get-email-domains-id))
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](https://www.unitpost.com/docs/email/post-email-domains-id-verify), or change its tracking/TLS settings with [Update a domain](https://www.unitpost.com/docs/email/patch-email-domains-id). Requires the `domains:read` capability.
- `DELETE /api/v1/email/domains/{id}`: Delete a domain ([docs](https://www.unitpost.com/docs/email/delete-email-domains-id))
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](https://www.unitpost.com/docs/email/post-email-domains) (you'll need to verify it again). Requires the `domains:write` capability.
- `PATCH /api/v1/email/domains/{id}`: Update a domain ([docs](https://www.unitpost.com/docs/email/patch-email-domains-id))
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](https://www.unitpost.com/docs/email/patch-email-campaigns-id)). 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.
**Receiving.** `capabilities.receiving: "enabled"` receives mail sent to any address at the domain: each message is stored and fires the `email.received` webhook. The domain must be `verified` (or `degraded`), else `409` and nothing in the request is applied. Then publish `receiving.record` (an MX record at the domain's own name) and call [Verify a domain](https://www.unitpost.com/docs/email/post-email-domains-id-verify): `receiving.status` turns `verified` once the record wins and mail is accepted. An MX record takes ALL mail for the name, so if `receiving.checks.other_mx_found` is true the name already gets mail elsewhere: receive on a subdomain instead (add it as its own domain). `"disabled"` stops receiving and is always allowed. Turning it on is limited to a few times an hour per domain; sending `"enabled"` again while it's on doesn't count. Requires the `domains:write` capability.
- `GET /api/v1/email/senders`: List sender addresses ([docs](https://www.unitpost.com/docs/email/get-email-senders))
Your saved From addresses, as the domain page lists them and the dashboard's From pickers offer them: each a mailbox on one of your domains, with its display name and `from`, the value to send with. Filter with `domain_id`. A sender can send once its domain can (see [Retrieve a domain](https://www.unitpost.com/docs/email/get-email-domains-id)). Requires the `domains:read` capability.
- `POST /api/v1/email/senders`: Add a sender address ([docs](https://www.unitpost.com/docs/email/post-email-senders))
Save a From address on one of your domains, as the domain page's "Add address" does. The domain may still be verifying. A mailbox already saved on that domain is a `409`, so a retry never saves it twice. Requires the `domains:write` capability.
- `GET /api/v1/email/senders/{id}`: Retrieve a sender address ([docs](https://www.unitpost.com/docs/email/get-email-senders-id))
One saved From address. Requires the `domains:read` capability.
- `PATCH /api/v1/email/senders/{id}`: Update a sender address ([docs](https://www.unitpost.com/docs/email/patch-email-senders-id))
Change a sender's display name, the only field that changes: a different mailbox is a new sender. Requires the `domains:write` capability.
- `DELETE /api/v1/email/senders/{id}`: Delete a sender address ([docs](https://www.unitpost.com/docs/email/delete-email-senders-id))
Remove a saved From address from the pickers. Templates and campaigns that send from it keep their From. Requires the `domains:write` capability.
- `GET /api/v1/library-images`: List images ([docs](https://www.unitpost.com/docs/platform/get-library-images))
The workspace's image library, newest first: the images a template embeds. Each image's `path` (`/img/…`) is what a template's `` (or ``) takes; `url` is its public address. Requires the `templates:read` capability.
- `POST /api/v1/library-images`: Upload an image ([docs](https://www.unitpost.com/docs/platform/post-library-images))
Add an image to the library from a public https `url` (fetched once, no redirects, no private addresses) or base64 `data` with its `content_type`: PNG, JPEG, GIF or WebP up to 5 MB and 16,384 px a side (50 megapixels at most), counted against the plan's image storage (`403 limit_reached`). Send an `Idempotency-Key` so a retried upload stores one image. Requires the `templates:write` capability.
- `GET /api/v1/library-images/{id}`: Retrieve an image ([docs](https://www.unitpost.com/docs/platform/get-library-images-id))
One library image. Requires the `templates:read` capability.
- `PATCH /api/v1/library-images/{id}`: Update an image ([docs](https://www.unitpost.com/docs/platform/patch-library-images-id))
Change the alt text the image suggests when it's inserted. Requires the `templates:write` capability.
- `DELETE /api/v1/library-images/{id}`: Delete an image ([docs](https://www.unitpost.com/docs/platform/delete-library-images-id))
Remove an image from the library. While a template, a campaign or sent mail uses it, the answer is `409 resource_in_use` naming them (`details[]`); `force=true` deletes it anyway, as the dashboard's "Delete anyway" does: those emails then show a broken image wherever it isn't cached yet. Requires the `templates:write` capability.
- `GET /api/v1/branding`: Retrieve branding ([docs](https://www.unitpost.com/docs/platform/get-branding))
The look marketing emails' footer and the hosted unsubscribe page share, as Settings → Brand shows it: colors, the font, the company name and postal address, the page's copy and behavior, the footer's labels, the logo, and whether the "Sent with Unitpost" mark shows. `null` means the default; `company_name` falls back to the workspace's name, so send it only to pin a different one. Requires the `templates:read` capability.
- `PATCH /api/v1/branding`: Update branding ([docs](https://www.unitpost.com/docs/platform/patch-branding))
Change branding with the same options Settings → Brand offers; only the fields you send change, and `null` restores a default. The footer every marketing email carries is built from these at send (it always keeps the unsubscribe link and the postal address). Hiding the "Sent with Unitpost" mark needs a paid plan (`403 limit_reached`). No key preset includes this capability: tick it on the key. Requires the `branding:write` capability.
- `PUT /api/v1/branding/logo`: Set the logo ([docs](https://www.unitpost.com/docs/platform/put-branding-logo))
Replace the logo marketing emails and the unsubscribe page show, from a public https `url` or base64 `data` (PNG, JPEG, GIF or WebP, up to 5 MB and 16,384 px a side). Requires the `branding:write` capability.
- `DELETE /api/v1/branding/logo`: Remove the logo ([docs](https://www.unitpost.com/docs/platform/delete-branding-logo))
Clear the brand logo: the workspace image shows instead, else none. Requires the `branding:write` capability.
- `GET /api/v1/whoami`: Who am I ([docs](https://www.unitpost.com/docs/platform/get-whoami))
The calling credential: its workspace, the API key (name, prefix, scope preset) or the OAuth sign-in (the app and the member it acts for), the `capabilities` it may use after every clamp, and the workspace's rate limit. Call it first to plan within what the credential may do instead of learning by `403`. Never returns the secret. Requires no capability: any valid credential may call it.
- `GET /api/v1/activity`: List activity ([docs](https://www.unitpost.com/docs/platform/get-activity))
The workspace activity log, newest first and cursor-paginated: what changed, when, and who changed it (`actor.name`: a teammate, System, Ask Unit) and, for a change made over the API, MCP or Ask Unit, how (`actor.via`, with the API key). The rows the dashboard's Activity page and each record's timeline show. Narrow with `subject` (and `subject_id`), `contact_id`, `campaign_id`, `q` (the record's name or the actor) and `channel=email`. Needs `activity:read`, which no key preset includes: the log names your teammates. Requires the `activity:read` capability.
- `PATCH /api/v1/workspace`: Rename the workspace ([docs](https://www.unitpost.com/docs/platform/patch-workspace))
Change the workspace's name. Deleting the workspace stays in the dashboard. Requires the `branding:write` capability.
- `PUT /api/v1/workspace/image`: Set the workspace image ([docs](https://www.unitpost.com/docs/platform/put-workspace-image))
Replace the workspace image (its avatar in the dashboard, and the email logo when there's no brand logo), from a public https `url` or base64 `data` (PNG, JPEG, GIF or WebP, up to 5 MB and 16,384 px a side). Requires the `branding:write` capability.
- `DELETE /api/v1/workspace/image`: Remove the workspace image ([docs](https://www.unitpost.com/docs/platform/delete-workspace-image))
Clear the workspace image. Requires the `branding:write` capability.
- `GET /api/v1/email/domains/{id}/diagnose`: Diagnose a domain's DNS ([docs](https://www.unitpost.com/docs/email/get-email-domains-id-diagnose))
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](https://www.unitpost.com/docs/email/post-email-domains-id-verify) does. Shares verify's per-workspace DNS rate limit. Requires the `domains:read` capability.
- `POST /api/v1/email/domains/{id}/verify`: Verify a domain ([docs](https://www.unitpost.com/docs/email/post-email-domains-id-verify))
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](https://www.unitpost.com/docs/email/get-email-domains-id). 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. With receiving on (`capabilities.receiving`), it also checks the receiving MX record and reports it in `receiving`. Requires the `domains:write` capability.
- `GET /api/v1/webhooks`: List webhook endpoints ([docs](https://www.unitpost.com/docs/platform/get-webhooks))
Retrieve a list of your webhook endpoints, cursor-paginated, with each endpoint's URL, subscribed events, and status. Signing secrets are never returned here — they're shown exactly once by [Create a webhook endpoint](https://www.unitpost.com/docs/platform/post-webhooks). Check an endpoint's wiring end-to-end with [Send a test event](https://www.unitpost.com/docs/platform/post-webhooks-id-test). Requires the `webhooks:read` capability.
- `POST /api/v1/webhooks`: Create a webhook endpoint ([docs](https://www.unitpost.com/docs/platform/post-webhooks))
Register an `https` URL to receive event notifications (deliveries, bounces, opens, clicks, and more) and choose which events it subscribes to. The response includes the `signing_secret` exactly once — store it now, as it can never be retrieved again; use it to verify each delivery's signature. Confirm the endpoint works with [Send a test event](https://www.unitpost.com/docs/platform/post-webhooks-id-test), and adjust its URL or events later with [Update a webhook endpoint](https://www.unitpost.com/docs/platform/patch-webhooks-id). Requires the `webhooks:manage` capability.
- `GET /api/v1/webhooks/{id}`: Retrieve a webhook endpoint ([docs](https://www.unitpost.com/docs/platform/get-webhooks-id))
Retrieve a single webhook endpoint by `id` — its URL, subscribed events, and status. The signing secret is never included; it's returned exactly once when the endpoint is [created](https://www.unitpost.com/docs/platform/post-webhooks). `backlog` counts the deliveries [Deliver the backlog](https://www.unitpost.com/docs/platform/post-webhooks-id-flush) would send: queued while the endpoint was paused, and failed. Requires the `webhooks:read` capability.
- `PATCH /api/v1/webhooks/{id}`: Update a webhook endpoint ([docs](https://www.unitpost.com/docs/platform/patch-webhooks-id))
Update a webhook endpoint's `url`, subscribed `events`, `name`, and/or `status` (use `status` to pause and re-enable deliveries without deleting the endpoint). The signing secret is never changed by an update. Verify your changes with [Send a test event](https://www.unitpost.com/docs/platform/post-webhooks-id-test). Requires the `webhooks:manage` capability.
- `DELETE /api/v1/webhooks/{id}`: Delete a webhook endpoint ([docs](https://www.unitpost.com/docs/platform/delete-webhooks-id))
Permanently delete a webhook endpoint and its delivery log. Deliveries stop immediately. The operation is idempotent — deleting an already-deleted endpoint still returns `204`. To stop deliveries temporarily instead, set `status` via [Update a webhook endpoint](https://www.unitpost.com/docs/platform/patch-webhooks-id). Requires the `webhooks:manage` capability.
- `POST /api/v1/webhooks/{id}/rotate-secret`: Rotate a webhook's signing secret ([docs](https://www.unitpost.com/docs/platform/post-webhooks-id-rotate-secret))
Mint a new signing secret for the endpoint. The response carries the new `signing_secret` exactly once, as [Create a webhook endpoint](https://www.unitpost.com/docs/platform/post-webhooks) does. For 24 hours (until `previous_secret_expires_at`) every delivery is signed with the new and the old secret, so switch your receiver to the new one inside that window without dropping a delivery. Rotating again inside the window retires the older secret at once. Requires the `webhooks:manage` capability.
- `GET /api/v1/webhooks/{id}/deliveries`: List a webhook's deliveries ([docs](https://www.unitpost.com/docs/platform/get-webhooks-id-deliveries))
The endpoint's delivery log, newest first and cursor-paginated: one row per event delivered (or being delivered) to it, with its status, attempts, your endpoint's last HTTP status and error, and the event's type and `data`. Use it to see why deliveries fail; [Send a test event](https://www.unitpost.com/docs/platform/post-webhooks-id-test) probes the endpoint without adding a row. Narrow it with `status` and `event_type`. A filter value it can't apply (a typo, an unknown id) is ignored and named in the `Unitpost-Warning` header, and the answer is the one without it; send `Unitpost-Strict: true` to have it refused instead. Requires the `webhooks:read` capability.
- `POST /api/v1/webhooks/{id}/deliveries/{delivery}/replay`: Replay a delivery ([docs](https://www.unitpost.com/docs/platform/post-webhooks-id-deliveries-delivery-replay))
Deliver one event to the endpoint again, as the dashboard's Replay does. The same event goes out with the same id, so a receiver that dedupes on it treats the replay as the delivery it already has. The webhook must be enabled: re-enable a disabled or suspended one with [Update a webhook endpoint](https://www.unitpost.com/docs/platform/patch-webhooks-id) first. Find deliveries with [List a webhook's deliveries](https://www.unitpost.com/docs/platform/get-webhooks-id-deliveries). Takes no body. Requires the `webhooks:manage` capability.
- `POST /api/v1/webhooks/{id}/flush`: Deliver a webhook's backlog ([docs](https://www.unitpost.com/docs/platform/post-webhooks-id-flush))
Deliver the deliveries held while the webhook was suspended (`queued`), as the dashboard's "Retry queued" does; with `include_failed` also the ones that failed for good (`failed`, `exhausted`). The webhook must be enabled: re-enable it with [Update a webhook endpoint](https://www.unitpost.com/docs/platform/patch-webhooks-id) first. Re-enabling never delivers the backlog on its own. Requires the `webhooks:manage` capability.
- `POST /api/v1/webhooks/{id}/test`: Send a test event ([docs](https://www.unitpost.com/docs/platform/post-webhooks-id-test))
Fire a sample event at the endpoint synchronously and return the receiver's response so you can debug your handler end-to-end. The probe is signed exactly like a real delivery (verify it the same way) but is not persisted to the delivery log. Configure the endpoint itself with [Update a webhook endpoint](https://www.unitpost.com/docs/platform/patch-webhooks-id). Requires the `webhooks:manage` capability.
- `GET /api/v1/organizations/{id}/workspaces`: List the organization's workspaces ([docs](https://www.unitpost.com/docs/platform/get-organizations-id-workspaces))
The organization's workspaces, newest first and cursor-paginated (`limit`, `after`, `before`). `external_id` narrows to the workspace you gave that id. Takes an organization key (`ok_live_…`) with `organizations:read`.
- `POST /api/v1/organizations/{id}/workspaces`: Create a workspace ([docs](https://www.unitpost.com/docs/platform/post-organizations-id-workspaces))
Create a workspace in the organization. Its Owner is the person who made the key. The plan follows the Owner's API rules (Organization → API): `free`, or a paid plan they allowed, billed to the organization's card within its workspace limit. `Idempotency-Key` is required; a retry with it returns the first result. Takes an organization key (`ok_live_…`) with `organizations:write`.
- `GET /api/v1/organizations/{id}/workspaces/{workspace_id}`: Get a workspace ([docs](https://www.unitpost.com/docs/platform/get-organizations-id-workspaces-workspace-id))
One of the organization's workspaces. Takes an organization key (`ok_live_…`) with `organizations:read`.
- `PATCH /api/v1/organizations/{id}/workspaces/{workspace_id}`: Update a workspace ([docs](https://www.unitpost.com/docs/platform/patch-organizations-id-workspaces-workspace-id))
Rename a workspace or change its `external_id` (null clears it). Takes an organization key (`ok_live_…`) with `organizations:write`.
- `POST /api/v1/organizations/{id}/workspaces/{workspace_id}/close`: Close a workspace ([docs](https://www.unitpost.com/docs/platform/post-organizations-id-workspaces-workspace-id-close))
The workspace stops working at once: its keys are revoked, every request to it is refused and its campaigns pause. It is deleted after 30 days unless restored. Closing twice changes nothing. Takes an organization key (`ok_live_…`) with `organizations:write`.
- `POST /api/v1/organizations/{id}/workspaces/{workspace_id}/restore`: Restore a workspace ([docs](https://www.unitpost.com/docs/platform/post-organizations-id-workspaces-workspace-id-restore))
Reopen a closed workspace within 30 days. Its old keys stay revoked: mint new ones. Takes an organization key (`ok_live_…`) with `organizations:write`.
- `POST /api/v1/organizations/{id}/workspaces/{workspace_id}/api-keys`: Mint a workspace key ([docs](https://www.unitpost.com/docs/platform/post-organizations-id-workspaces-workspace-id-api-keys))
A workspace key for one of the organization's workspaces: it reaches only that workspace, can't manage keys, and is revoked with the organization key that minted it. The secret is returned once. Takes an organization key (`ok_live_…`) with `organizations:write`.
- `GET /api/v1/sms`: List SMS messages ([docs](https://www.unitpost.com/docs/sms/get-sms))
Retrieve your workspace's SMS messages (outbound and inbound), newest-first and cursor-paginated. Narrow the list with `direction`, `status`, `from`, `to`, `participant`, `contact_id`, `campaign_id`, `created_after`/`created_before`, and `sender_id`. For a conversation with one person, pass `participant` with their number: it returns the texts you sent them and the replies they sent you. Use a message's `id` with [Retrieve an SMS message](https://www.unitpost.com/docs/sms/get-sms-id) to read its delivery state. Requires the `sms:read` capability.
- `POST /api/v1/sms`: Send an SMS message ([docs](https://www.unitpost.com/docs/sms/post-sms))
Send a single SMS — now, or at a future time by passing `scheduled_at`. The recipient must be a valid E.164 number. It doesn't have to be one of your contacts: a number you've never stored is sent to without creating a contact (pass `create_contact` to add one), and opt-outs and the suppression list still apply. Marketing sends additionally require the recipient's prior express consent on record and honor recipient-local quiet hours (they are deferred to the next allowed window, never dropped). The sender is chosen by the recipient's country — the active number or Sender ID whose reach covers it, exactly as campaigns route — unless you pin one with `from` (a `snum_…` id from [List SMS numbers](https://www.unitpost.com/docs/sms/get-sms-numbers), or the number / Sender ID string). Pass an `Idempotency-Key` header to make retries safe: the same key with the same body replays the original message instead of sending (and billing) twice. Billing is per segment. Read delivery state back with [Retrieve an SMS message](https://www.unitpost.com/docs/sms/get-sms-id). Requires the `sms:send` capability.
- `GET /api/v1/sms/stats`: SMS delivery stats ([docs](https://www.unitpost.com/docs/sms/get-sms-stats))
Retrieve aggregate SMS delivery metrics for your workspace over a rolling window: how many texts were queued, sent, delivered, failed, suppressed (skipped on purpose: no consent, opted out, no sender that reaches the country) or canceled, how many replies came in, and how many segments were billed, with delivery, failure and suppression rates. There are no open or click numbers: carriers report sent, delivered and failed only. Control the window with `days` (1–365, default 30). For the messages behind these numbers, see [List SMS messages](https://www.unitpost.com/docs/sms/get-sms). Requires the `sms:read` capability.
- `GET /api/v1/sms/brands`: List SMS brands ([docs](https://www.unitpost.com/docs/sms/get-sms-brands))
Retrieve your SMS brands and their setup status. A brand is the business identity carriers register before they will approve a phone number, so this is the endpoint to poll when you need to know whether SMS is usable yet: `status` reports whether you or the carrier is the blocker, and `missing` lists exactly what is still required. Create and edit a brand with [Create an SMS brand](https://www.unitpost.com/docs/sms/post-sms-brands) (free); registering it with carriers is a paid order: [Create an SMS purchase quote](https://www.unitpost.com/docs/sms/post-sms-quotes), then its confirm. Requires the `sms:read` capability.
- `POST /api/v1/sms/brands`: Create an SMS brand ([docs](https://www.unitpost.com/docs/sms/post-sms-brands))
Create a brand: the business identity carriers register before they approve a number. A draft may be partial (only `name` is required); `missing` says what is still needed. Free, with no carrier call; one brand per website domain (`409`). The tax id is write-only (stored encrypted, read back as `tax_id_on_file`). Registering the brand with carriers is a paid order (an SMS quote, then its confirm). Pass an `Idempotency-Key` so a retry never makes a second brand. Requires the `sms:manage` capability.
- `GET /api/v1/sms/brands/{id}`: Retrieve an SMS brand ([docs](https://www.unitpost.com/docs/sms/get-sms-brands-id))
One brand with every field you can edit, its `status` and `missing` list. Requires the `sms:read` capability.
- `PATCH /api/v1/sms/brands/{id}`: Update an SMS brand ([docs](https://www.unitpost.com/docs/sms/patch-sms-brands-id))
Edit a brand: only the fields sent change (`null` clears an optional one), or send `{ archived }` alone to archive or restore it. A brand carriers are reviewing or have approved, or with numbers attached, refuses carrier-field edits (`409 brand_locked`, `details` names each field) except those a correction request opened. Renaming the Sender ID while it is enabled in several countries is a `409 sender_id_in_use`. Archiving a brand with numbers or open registrations is a `409 resource_in_use`. Requires the `sms:manage` capability.
- `GET /api/v1/sms/numbers`: List SMS phone numbers ([docs](https://www.unitpost.com/docs/sms/get-sms-numbers))
Retrieve your SMS sending identities: phone numbers and alphanumeric Sender IDs. Only entries with `status: "active"` can send; a `ten_dlc` number has no `phone_number` until carriers approve the registration, a `long_code` is a local number issued outside the US (Canada today: `country: "CA"`, `reach: ["CA"]`, no carrier registration, `use_case: null`), and a `simulator` number only delivers to verified test destinations. `use_case` is what carriers approved the number to send. A `sender_id` entry is one country of a Sender ID: the identity is ONE string per brand enabled per country, the list returns one entry per (Sender ID, country), and every entry carries `sender_id_group` with the whole country set so you can group them. Order a number with an SMS quote and its confirm; a toll-free number, a Sender ID and releasing an identity stay in the dashboard. Requires the `sms:read` capability.
- `GET /api/v1/sms/{id}`: Retrieve an SMS message ([docs](https://www.unitpost.com/docs/sms/get-sms-id))
Retrieve one SMS message's current status, delivery state, and segment count by `id`. Requires the `sms:read` capability.
- `PATCH /api/v1/sms/{id}`: Cancel or reschedule an SMS ([docs](https://www.unitpost.com/docs/sms/patch-sms-id))
Cancel a `scheduled` or `queued` SMS (its reserved credit is refunded), or move a `scheduled` one to a new send time with `scheduled_at`. Send exactly one of `cancel: true` or `scheduled_at`. Only a text the carrier has not been handed can be changed — afterwards this returns `409`, as it does for a text that belongs to a campaign. Quiet hours still apply to the new time. Look up the current state first with [Retrieve an SMS message](https://www.unitpost.com/docs/sms/get-sms-id). Requires the `sms:send` capability.
- `GET /api/v1/sms/templates`: List SMS templates ([docs](https://www.unitpost.com/docs/sms/get-sms-templates))
Retrieve your SMS templates, cursor-paginated. An SMS template is a plain-text `body` with the same `{{variable}}` placeholders email templates use, filled per recipient at send time. Only `published` templates can be used by an SMS campaign. Requires the `templates:read` capability.
- `POST /api/v1/sms/templates`: Create an SMS template ([docs](https://www.unitpost.com/docs/sms/post-sms-templates))
Create an SMS template as a `draft`. `body` may be empty for a draft (it must be filled before publishing) and is capped at 10 SMS segments (1,530 GSM-7 characters). Publish it with `{ "status": "published" }` on [Update an SMS template](https://www.unitpost.com/docs/sms/patch-sms-templates-id). Requires the `templates:write` capability.
- `GET /api/v1/sms/templates/{id}`: Retrieve an SMS template ([docs](https://www.unitpost.com/docs/sms/get-sms-templates-id))
Retrieve a single SMS template, including its `body`. An email template id returns `404` here. Requires the `templates:read` capability.
- `PATCH /api/v1/sms/templates/{id}`: Update an SMS template ([docs](https://www.unitpost.com/docs/sms/patch-sms-templates-id))
Update an SMS template's `name`, `body` or `status`. A `published` template must have a message: publishing a blank `body`, or clearing the `body` of a published template, is refused with `422`. While an active SMS campaign uses a published template, editing its `body` or moving it back to `draft` is refused with `409`; unpublishing is also refused while a live automation sends it. Requires the `templates:write` capability.
- `DELETE /api/v1/sms/templates/{id}`: Delete an SMS template ([docs](https://www.unitpost.com/docs/sms/delete-sms-templates-id))
Permanently delete an SMS template. Refused with `409` while any campaign references it, and with `resource_in_use` while a live automation sends it. Requires the `templates:write` capability.
- `GET /api/v1/sms/templates/{id}/usage`: Get an SMS template's usage ([docs](https://www.unitpost.com/docs/sms/get-sms-templates-id-usage))
What uses an SMS template, and whether a change would be accepted now: its live campaigns and automations, queued texts, and `can_edit_content` / `can_unpublish` with the reason when not. Read it before an edit or delete. Requires the `templates:read` capability.
- `POST /api/v1/sms/templates/{id}/test-send`: Send a test of an SMS template ([docs](https://www.unitpost.com/docs/sms/post-sms-templates-id-test-send))
Fill the template's `{{variables}}` and send ONE test text to `to`: the simulator or a number your workspace verified (`403` for any other number). A real text, billed like any; from `sender_id` or routed by the destination's country. Every variable needs a value (`422`). Pass an `Idempotency-Key` so a retry never texts twice. Rate limited per workspace. Requires the `sms:send` capability.
- `GET /api/v1/sms/test-destinations`: List SMS test numbers ([docs](https://www.unitpost.com/docs/sms/get-sms-test-destinations))
The numbers your workspace may text a template's test to besides the simulator: `pending` until the code texted to it is entered, then `verified`. Adding and verifying one happens in the dashboard (Phone numbers → Testing): it proves the phone is the person's. Requires the `sms:read` capability.
- `DELETE /api/v1/sms/test-destinations/{id}`: Remove an SMS test number ([docs](https://www.unitpost.com/docs/sms/delete-sms-test-destinations-id))
Remove a test number; template tests can no longer text it. Requires the `sms:manage` capability.
- `GET /api/v1/sms/consent-setups`: List consent setups ([docs](https://www.unitpost.com/docs/sms/get-sms-consent-setups))
Where and how people opt in to your texts: the form or page, the brand and purpose it discloses. A messaging program points at one with `consent_setup_id`. Archived ones are left out. Requires the `sms:read` capability.
- `GET /api/v1/sms/billing/resources`: List recurring SMS charges ([docs](https://www.unitpost.com/docs/sms/get-sms-billing-resources))
What your workspace pays for SMS every month: each number and 10DLC campaign, its monthly price, status, next charge and, for a past-due number, the date it is released. Buying a carrier resource is an SMS quote, then its confirm. Requires the `sms:read` capability.
- `POST /api/v1/sms/quotes`: Create an SMS purchase quote ([docs](https://www.unitpost.com/docs/sms/post-sms-quotes))
Price a carrier purchase and bind exactly what it buys: a 10DLC brand registration (or the paid resubmission of one carriers sent back), brand vetting, a 10DLC campaign registered from a messaging program with its local number, the number of an approved campaign that has none, or a local number that needs no registration (Canada). Nothing is charged or submitted. The answer names the product, `due_now_cents` (charged at the confirm), `monthly_cents` (from when the number leases or carriers approve the campaign, then monthly), the line items and `expires_at` (10 minutes), and carries a single-use `confirm_token`, shown once. Show the person the price, then [Confirm an SMS purchase quote](https://www.unitpost.com/docs/sms/post-sms-quotes-id-confirm). A toll-free number needs an opt-in evidence file a person provides: `409 dashboard_required`, with `error.dashboard_url`. Something already registered or under review, a number the plan can't hold, a failing card or a fee the plan can't fit are refused here, before a price is shown. Requires the `sms:manage` capability.
- `GET /api/v1/sms/quotes/{id}`: Retrieve an SMS purchase quote ([docs](https://www.unitpost.com/docs/sms/get-sms-quotes-id))
A quote's price and state: `open` until confirmed or expired, `ordered` with what the order made (`order.registration_id` or `order.number_id`), `pending` while the outcome is unknown (the carrier didn't answer: a fee due now is held, monthly fees are committed), or `failed` when the confirm bought nothing (nothing was charged). A `pending` quote settles from the billing ledger when it is read: `ordered` once the fee is captured or the number or campaign goes live, `failed` once the fee is refunded or the commitments are released. An `ordered` number or campaign whose commitments are released before it ever goes live reads `failed` too and stops counting against the cap. Never the confirm token. Requires the `sms:read` capability.
- `POST /api/v1/sms/quotes/{id}/confirm`: Confirm an SMS purchase quote ([docs](https://www.unitpost.com/docs/sms/post-sms-quotes-id-confirm))
Buy what the quote binds, with its single-use `confirm_token`. It charges the workspace: confirm only after the person agreed to the quoted price. Requires an `Idempotency-Key`: a retry with the same key answers what the first call answered and never charges twice. The order runs exactly as in the dashboard: the plan's purchase rules, the one-time fee taken from the plan's allowance before carriers are called and refunded if they refuse the submission, the monthly fee from when the number leases or carriers approve the campaign. Every API purchase counts against the monthly API spend cap the workspace owner sets in the dashboard (the fee due now plus the first month, per UTC month); with no cap set, API purchases are refused. The owner gets an email per purchase, and the activity log names the key. Refusals charge nothing: a changed price (`price_changed`), an expired quote (`quote_expired`), a brand or messaging program edited since (`quote_stale`), a quote already used (`quote_already_used`), an order for the same purchase still in flight (`conflict`, naming its quote). These refusals are answered before the shared hourly limits are spent. A confirm that recorded nothing leaves the quote open (and frees its `Idempotency-Key`); one that reached carriers spends it: `failed` when carriers refused it (quote again), `pending` when their answer is unknown (follow the quote; don't buy the same thing meanwhile). Requires the `sms:purchase` capability.
- `GET /api/v1/sms/messaging-programs`: List messaging programs ([docs](https://www.unitpost.com/docs/sms/get-sms-messaging-programs))
Your messaging programs: what a sender registration says your texts are for, how people opt in, the help and stop replies and sample messages. Registrations copy a program when submitted; writing a program never changes one. `archived=true` includes archived ones. Requires the `sms:read` capability.
- `POST /api/v1/sms/messaging-programs`: Create a messaging program ([docs](https://www.unitpost.com/docs/sms/post-sms-messaging-programs))
Create a program (`name` required). Each field is checked by the carriers' rules (the dashboard's own): lengths, help and stop replies that are a sentence and not just a keyword, links to a real web address (no localhost, bare IP or other scheme). Pass an `Idempotency-Key` so a retry never makes a second one. Requires the `sms:manage` capability.
- `GET /api/v1/sms/messaging-programs/{id}`: Retrieve a messaging program ([docs](https://www.unitpost.com/docs/sms/get-sms-messaging-programs-id))
Retrieve one program, archived ones too. Requires the `sms:read` capability.
- `PATCH /api/v1/sms/messaging-programs/{id}`: Update a messaging program ([docs](https://www.unitpost.com/docs/sms/patch-sms-messaging-programs-id))
Edit a program: only the fields sent change, `null` clears one, and the result must still pass the carriers' rules. `revision` moves on a material change; registrations keep the copy they made. An archived program is a `409`: duplicate it to keep editing. Requires the `sms:manage` capability.
- `DELETE /api/v1/sms/messaging-programs/{id}`: Archive a messaging program ([docs](https://www.unitpost.com/docs/sms/delete-sms-messaging-programs-id))
Archive a program. Registrations that copied it keep their copy; [Duplicate a messaging program](https://www.unitpost.com/docs/sms/post-sms-messaging-programs-id-duplicate) to use it again. Requires the `sms:manage` capability.
- `POST /api/v1/sms/messaging-programs/{id}/duplicate`: Duplicate a messaging program ([docs](https://www.unitpost.com/docs/sms/post-sms-messaging-programs-id-duplicate))
Copy a program (an archived one too) into a new editable one named "… (copy)" at revision 1, its consent setup and recipe included. Takes no body. Pass an `Idempotency-Key` so a retry returns the same copy. Requires the `sms:manage` capability.
- `GET /api/v1/sms/registrations`: List carrier registrations ([docs](https://www.unitpost.com/docs/sms/get-sms-registrations))
Your carrier registrations (10DLC brand and campaign, toll-free) with their review state and the carrier's feedback; a 10DLC campaign also carries the number it backs and one setup state. Submitting or resubmitting a 10DLC one is an SMS quote, then its confirm; toll-free verification and removing one happen in the dashboard. Requires the `sms:read` capability.
- `GET /api/v1/sms/campaigns`: List SMS campaigns ([docs](https://www.unitpost.com/docs/sms/get-sms-campaigns))
Retrieve your SMS campaigns, newest first and cursor-paginated. Deleted (archived) campaigns are hidden; pass `?archived=true` to list only archived ones. Requires the `campaigns:read` capability.
- `POST /api/v1/sms/campaigns`: Create an SMS campaign ([docs](https://www.unitpost.com/docs/sms/post-sms-campaigns))
Create an SMS campaign as a `draft`: a published SMS template sent to a segment. Omit `sender_id` for automatic routing (the best live sender per recipient), or pass an `snum_…` number to send from it. A draft always saves, even if it isn't ready yet; only an id that doesn't exist in this workspace returns `422`. `scheduled_at` saves a send time ("Send at") that a send with no `scheduled_at` uses. Check readiness with [Validate an SMS campaign](https://www.unitpost.com/docs/sms/get-sms-campaigns-id-validate), then send it with [Send an SMS campaign](https://www.unitpost.com/docs/sms/post-sms-campaigns-id-send). Requires the `campaigns:write` capability.
- `GET /api/v1/sms/campaigns/{id}`: Retrieve an SMS campaign ([docs](https://www.unitpost.com/docs/sms/get-sms-campaigns-id))
Retrieve a single SMS campaign: status, routing, schedule, the reason a scheduled campaign is on `hold`, and its projected recipients. An archived campaign is still returned; an email campaign id returns `404` here. Requires the `campaigns:read` capability.
- `PATCH /api/v1/sms/campaigns/{id}`: Update an SMS campaign ([docs](https://www.unitpost.com/docs/sms/patch-sms-campaigns-id))
Edit a `draft` SMS campaign: name, template, segment, sender (`null` returns to automatic routing), topic, `excluded_contact_ids`, `variable_fallbacks` and the saved send time `scheduled_at` (`null` clears it). Once the campaign has been sent or scheduled, or while it is archived, edits return `409`. Pass `{ "archived": true | false }` to archive or restore it (this takes precedence over other fields). Requires the `campaigns:write` capability.
- `DELETE /api/v1/sms/campaigns/{id}`: Delete an SMS campaign ([docs](https://www.unitpost.com/docs/sms/delete-sms-campaigns-id))
Archive a draft or finished SMS campaign; its delivery history is kept and an already archived campaign returns `204` again. Restore it with `{ "archived": false }` on [Update an SMS campaign](https://www.unitpost.com/docs/sms/patch-sms-campaigns-id). A scheduled, sending or paused campaign returns `409`: cancel it first with [Cancel an SMS campaign](https://www.unitpost.com/docs/sms/post-sms-campaigns-id-cancel). Requires the `campaigns:write` capability.
- `POST /api/v1/sms/campaigns/{id}/send`: Send an SMS campaign ([docs](https://www.unitpost.com/docs/sms/post-sms-campaigns-id-send))
Send a `draft` SMS campaign. Omit `scheduled_at` to use the draft's saved send time when it is still in the future (otherwise it sends now; right after an Unschedule it answers `409` `send_time_required` instead), pass `null` to send now, or a time (ISO 8601 with `Z` or an offset) to schedule it. A misnamed time field (`scheduledAt`, `send_at`, …) is a `422` and nothing is sent; other fields are ignored. API sends run the same checks as the dashboard: plan, schedule window, carrier registration and content rules, topic, and sending limits. A plan without SMS returns `403` `limit_reached`, with `feature_not_in_plan` on `smsChannel` in `error.details[]`. Any other check that blocks the send returns `409` with each reason in `error.details[]`: `field` names the request field to fix (`template_id`, `segment_id`, `sender_id`, `topic_id`) and `code` the reason (e.g. `template_not_published`). A sender registered for a non-marketing use case returns `422` `use_case_mismatch`, and a daily sending limit returns `429` with `Retry-After`. Consent and recipient-local quiet hours are applied per recipient: people without marketing consent are skipped, and messages that would land in quiet hours wait for the window to open. Requires the `campaigns:send` capability.
- `POST /api/v1/sms/campaigns/{id}/cancel`: Cancel an SMS campaign ([docs](https://www.unitpost.com/docs/sms/post-sms-campaigns-id-cancel))
Cancel a `scheduled`, `sending` or `paused` SMS campaign. Messages already handed to the carrier are not recalled. Requires the `campaigns:send` capability.
- `POST /api/v1/sms/campaigns/{id}/pause`: Pause an SMS campaign ([docs](https://www.unitpost.com/docs/sms/post-sms-campaigns-id-pause))
Pause a `scheduled` or `sending` SMS campaign. Recipients not yet sent to wait until you resume it. Requires the `campaigns:send` capability.
- `POST /api/v1/sms/campaigns/{id}/unschedule`: Unschedule an SMS campaign ([docs](https://www.unitpost.com/docs/sms/post-sms-campaigns-id-unschedule))
Move a `scheduled` SMS campaign (or one held for quiet hours, or `paused` before it started) back to `draft`, same id, so you can edit it. The saved send time is cleared; nothing was charged, so nothing is refunded. No body; any key is a `422`. Requires the `campaigns:send` capability.
- `POST /api/v1/sms/campaigns/{id}/duplicate`: Duplicate an SMS campaign ([docs](https://www.unitpost.com/docs/sms/post-sms-campaigns-id-duplicate))
Copy an SMS campaign into a new `draft`: template, segment, sender, topic, exclusions and fallbacks; never the send time or status. A source that points at a deleted template or an archived segment is a `422`; readiness problems (unpublished template, sender not live) don't stop the copy, and [Validate an SMS campaign](https://www.unitpost.com/docs/sms/get-sms-campaigns-id-validate) reports them. Optional `name` for the copy; any other key is a `422`. Pass an `Idempotency-Key` so a retry returns the same copy. Requires the `campaigns:write` capability.
- `GET /api/v1/sms/campaigns/{id}/recipients`: List an SMS campaign's recipients ([docs](https://www.unitpost.com/docs/sms/get-sms-campaigns-id-recipients))
How each recipient of an SMS campaign fared: the carrier's answer for a text that went out (`delivered`, `sent`, `failed`, …), or why none was sent (`skipped`, with `reason_code`: `opted_out`, `no_marketing_consent`, …). Newest first, cursor-paginated on `id`; `q` matches digits of the number. `skipped_by_reason` counts skipped recipients over the whole campaign, on the first page (null on the others). A bad `limit` or `q`, or any other query key, is a `422`. Requires the `campaigns:read` capability.
- `POST /api/v1/sms/campaigns/{id}/resume`: Resume an SMS campaign ([docs](https://www.unitpost.com/docs/sms/post-sms-campaigns-id-resume))
Resume a `paused` SMS campaign for the recipients not yet sent to. The send checks run again, so a block that appeared while paused returns `409`, and a plan that no longer includes SMS returns `403` `limit_reached`. Requires the `campaigns:send` capability.
- `POST /api/v1/sms/campaigns/{id}/reschedule`: Reschedule an SMS campaign ([docs](https://www.unitpost.com/docs/sms/post-sms-campaigns-id-reschedule))
Move a `scheduled` SMS campaign to a new future `scheduled_at` inside your plan's scheduling window. Requires the `campaigns:send` capability.
- `GET /api/v1/sms/campaigns/{id}/validate`: Validate an SMS campaign ([docs](https://www.unitpost.com/docs/sms/get-sms-campaigns-id-validate))
Check whether an SMS campaign could send now and who it would reach, without sending anything: `can_send`, the `blockers` a send would be refused for, the `reachable` count, the skip breakdown (`no_marketing_consent`, `quiet_hours_deferred`, and the rest in `skipped_by_reason`), when a quiet-hours hold would let it start, and whether a sender's use case or a missing template variable would block the send; `variable_coverage[]` says per variable whether a `variable_fallbacks` default is required (`missing_for_all`) or only fills blanks. Rate-limited per workspace. Requires the `campaigns:read` capability.
- `GET /api/v1/sms/topics`: List SMS topics ([docs](https://www.unitpost.com/docs/sms/get-sms-topics))
Retrieve your SMS subscription topics, cursor-paginated. An SMS topic is a category of marketing SMS (e.g. Flash sales) recipients can opt out of while still receiving your other texts. SMS and email topics are separate: a contact's email preference never changes their SMS one, and STOP still opts a number out of every SMS. Pass `?archived=true` to list archived topics instead. Create one with [Create an SMS topic](https://www.unitpost.com/docs/sms/post-sms-topics). Requires the `topics:read` capability.
- `POST /api/v1/sms/topics`: Create an SMS topic ([docs](https://www.unitpost.com/docs/sms/post-sms-topics))
Create an SMS subscription topic. `default_opt_in` decides whether contacts with no explicit preference are treated as subscribed. Names are unique per channel, so an SMS topic may share a name with an email topic. Topics are a plan feature: an unentitled workspace gets `403` with `limit_reached`. Requires the `topics:write` capability.
- `GET /api/v1/sms/topics/{id}`: Retrieve an SMS topic ([docs](https://www.unitpost.com/docs/sms/get-sms-topics-id))
Retrieve a single SMS topic by `id`. An email topic id returns `404` here. Change it with [Update an SMS topic](https://www.unitpost.com/docs/sms/patch-sms-topics-id). Requires the `topics:read` capability.
- `PATCH /api/v1/sms/topics/{id}`: Update an SMS topic ([docs](https://www.unitpost.com/docs/sms/patch-sms-topics-id))
Update an SMS topic's name, description or `default_opt_in`, or pass `{ "archived": true | false }` to archive or restore it. Archiving is blocked with `409` while an active SMS campaign or a live automation still uses the topic. Requires the `topics:write` capability.
- `DELETE /api/v1/sms/topics/{id}`: Delete an SMS topic ([docs](https://www.unitpost.com/docs/sms/delete-sms-topics-id))
Archive an SMS topic (a soft delete: subscription records are kept so you can restore it with `{ "archived": false }` on [Update an SMS topic](https://www.unitpost.com/docs/sms/patch-sms-topics-id)). Blocked with `409` while an active SMS campaign or a live automation still uses it. Requires the `topics:write` capability.
- `POST /api/v1/sms/topics/{id}/opt-out`: Opt contacts out of an SMS topic ([docs](https://www.unitpost.com/docs/sms/post-sms-topics-id-opt-out))
Opt many contacts out of this topic in one call, as a background job: the contacts you list (`contact_ids` or `emails`, up to 1,000, each a contact of this workspace) or every member of a segment (`segment_id`, however many; also needs `segments:read`). Send exactly one. Each contact is opted out as [Set a contact's topic preference](https://www.unitpost.com/docs/email/post-contacts-id-topics) would: one that was subscribed gets a `topic.unsubscribed` webhook and an activity row naming your key; one already opted out is left as it is, with no webhook. Suppressions and other topics are untouched, and there is no bulk opt-in. Listed contacts are checked first: the first that isn't one of yours is a `404` naming it (`details`), and nothing starts. The answer is the job (`202`, `Location`); follow it with [Retrieve a job](https://www.unitpost.com/docs/platform/get-jobs-id). Pass an `Idempotency-Key` so a retry answers the same job instead of starting another. An email topic's id is a `404` here. Requires the `topics:write` capability.
- `GET /api/v1/api-keys`: List API keys ([docs](https://www.unitpost.com/docs/platform/get-api-keys))
Retrieve a list of your workspace's API keys — the key prefix, scope, capabilities and last use, never the secret. With an API key or an MCP sign-in it needs `apikeys:read`, which no key preset includes: tick it on the key that audits your keys. From a dashboard session it needs `apikeys:manage`. See how a key is used with [Retrieve an API key's usage](https://www.unitpost.com/docs/platform/get-api-keys-id-usage). Creating ([Create an API key](https://www.unitpost.com/docs/platform/post-api-keys)) and revoking ([Revoke an API key](https://www.unitpost.com/docs/platform/delete-api-keys-id)) keys stay in a dashboard session. Requires the `apikeys:read` capability.
- `POST /api/v1/api-keys`: Create an API key ([docs](https://www.unitpost.com/docs/platform/post-api-keys))
Create an API key scoped to the capabilities you choose. The response includes the plaintext `key` exactly once — store it securely now, because it can never be retrieved again (only [revoked](https://www.unitpost.com/docs/platform/delete-api-keys-id)). This endpoint is session-gated: it requires a dashboard session and cannot be called with a Bearer API key.
- `GET /api/v1/logs`: List request logs ([docs](https://www.unitpost.com/docs/platform/get-logs))
Your workspace's API request log, newest first and cursor-paginated: each call's method, path, status, time taken, the API key that made it and its `x-request-id` (quote it to support). Never the caller's IP and never a body. Narrow with `status` (a code, or a class such as `4xx`), `api_key_id`, `method`, `path_prefix` (e.g. `/email`), `request_id` and a `created_after` / `created_before` range. Rows are kept 30 days. Needs `apikeys:read`, which no key preset includes, and has its own per-workspace rate limit. Requires the `apikeys:read` capability.
- `GET /api/v1/api-keys/{id}/usage`: Retrieve an API key's usage ([docs](https://www.unitpost.com/docs/platform/get-api-keys-id-usage))
One key's last 24 hours: how many requests it made, how many were answered 4xx, 5xx or 429, and when it was last used. Check it before you revoke a key (is anything still calling with it?) or when an integration's calls fail. The dashboard's revoke dialog reads the same count. Needs `apikeys:read`, which no key preset includes. Requires the `apikeys:read` capability.
- `DELETE /api/v1/api-keys/{id}`: Revoke an API key ([docs](https://www.unitpost.com/docs/platform/delete-api-keys-id))
Permanently revoke an API key. Requests made with it start failing with `401` immediately, and revocation cannot be undone — create a replacement first with [Create an API key](https://www.unitpost.com/docs/platform/post-api-keys) if you're rotating. This endpoint is session-gated: it requires a dashboard session and cannot be called with a Bearer API key.
- `GET /api/v1/suppressions`: List suppressed addresses ([docs](https://www.unitpost.com/docs/platform/get-suppressions))
Retrieve a list of the email addresses your workspace has suppressed, newest-first and cursor-paginated. The list holds your workspace's own suppressions only. Unitpost-wide (`platform`) blocks are enforced at send time but never listed: they appear as `email.suppressed` on the send they stopped, and [Retrieve a suppressed address](https://www.unitpost.com/docs/platform/get-suppressions-id) with an email address still returns one, so you can see why that address is blocked. (Since 2026-10-13; before that the list also included platform rows.) Add an address with [Suppress an address](https://www.unitpost.com/docs/platform/post-suppressions) or lift a block with [Un-suppress an address](https://www.unitpost.com/docs/platform/delete-suppressions-id). Narrow the list with `q`, `reason` and a `created_after` / `created_before` range. A filter value it can't apply (a typo, an unknown id) is ignored and named in the `Unitpost-Warning` header, and the answer is the one without it; send `Unitpost-Strict: true` to have it refused instead. Requires the `suppressions:read` capability.
- `POST /api/v1/suppressions`: Suppress an address ([docs](https://www.unitpost.com/docs/platform/post-suppressions))
Block one address (`{ email }`) or up to 1,000 at once (`{ emails: [...] }`) from receiving any future sends from your workspace. The operation is idempotent — re-suppressing an existing address returns `200` rather than a conflict. The bulk form returns an `{ object: "list", added, skipped, total }` summary. Suppression is the hard stop; to let recipients opt out of just one kind of email, use topics (see [Create a topic](https://www.unitpost.com/docs/email/post-email-topics)). Undo with [Un-suppress an address](https://www.unitpost.com/docs/platform/delete-suppressions-id). Requires the `suppressions:write` capability.
- `GET /api/v1/suppressions/{id}`: Retrieve a suppressed address ([docs](https://www.unitpost.com/docs/platform/get-suppressions-id))
Retrieve one suppression by id (`supp_…`) or by the email address itself. Returns your own workspace row, or a read-only `platform` row when the address is blocked Unitpost-wide. Lift a workspace block with [Un-suppress an address](https://www.unitpost.com/docs/platform/delete-suppressions-id). Requires the `suppressions:read` capability.
- `DELETE /api/v1/suppressions/{id}`: Un-suppress an address ([docs](https://www.unitpost.com/docs/platform/delete-suppressions-id))
Remove a workspace suppression by id or email so the address can receive your mail again. The email form is idempotent — removing an address that isn't suppressed still returns `204`. Unitpost-wide (`platform`) entries can't be removed here (`403`) — contact support. Check what's blocking an address first with [Retrieve a suppressed address](https://www.unitpost.com/docs/platform/get-suppressions-id). Requires the `suppressions:write` capability.
- `GET /api/v1/usage`: Retrieve usage ([docs](https://www.unitpost.com/docs/platform/get-usage))
Retrieve your workspace's current billing-period usage snapshot: the plan, the period window, and the email counter. On paid plans the response also carries the dollar sending `wallet`: the monthly allowance included with the plan, spend so far, any upgrade carryover, the non-expiring purchased-credit balance, and billable overage, all as integer USD cents. On the free plan `wallet` is null and the `emails` object carries the monthly quota and daily cap instead. `spend` itemizes the period from the wallet ledger (email, SMS, SMS carrier fees, refunds, and how it was paid), and `sms` breaks SMS down by destination country with segments, the price per segment, monthly number and campaign fees, one-time registration fees, and refunds. Amounts are read from the same ledger and meters that gate sending, so this matches the dashboard statement exactly. Requires the `emails:read` or `sms:read` capability.
# Product guides
## Dashboard
> Your account at a glance — sending health, recent activity, and what needs attention.
The Dashboard is the home screen: a summary of sending volume, deliverability signals (bounces, complaints), and recent campaigns and messages. It's a read-only overview — every figure links through to the page where you can act on it.
Use it as a daily health check. A rising bounce or complaint rate, a domain that's slipped out of verification, or a campaign stuck in draft all surface here before they become a deliverability problem.
> **Start here when onboarding:** If your account is new, the Dashboard guides you through the first run: verify a domain, create a template, and send a test. Those are the prerequisites every campaign depends on.
## Domains
> Verify sending domains so your mail authenticates and lands in the inbox.
A domain is your sending identity. Before you can send from an address like hello@yourcompany.com, the domain yourcompany.com must be added here and verified by publishing the DNS records we generate (a DKIM TXT record, a custom MAIL FROM subdomain, and a DMARC policy).
Verification runs automatically once DNS propagates — you don't click anything. A domain moves through Pending → Verified; if records change or are removed it can fall back, at which point sends from it are blocked.
> **Re-checked at send time:** A domain's status is verified again the moment a campaign sends, not just when you created it. If the domain isn't in a sending-allowed state at send time, the send is blocked with a clear message — so a domain that lapses after a campaign is scheduled won't silently send unauthenticated mail.
Domains feed directly into Templates and Campaigns: the from-address you pick when sending must belong to a verified domain. This is the first link in the contacts → segments → campaigns chain.
### Inbound routing
You can also receive mail at your domain. Enable inbound routing on the domain's Receiving tab (or send `capabilities.receiving` on `PATCH /email/domains/{id}`) and publish the MX record, and we'll parse incoming mail and POST it to your webhook as clean JSON. See the Inbound email (/docs/inbound) guide for details.
## Templates
> Author reusable email content for campaigns and the API.
Templates are reusable emails — a subject and a body, authored in one of three editors: Simple (a plain, personal note, like typing in Gmail), Design (built visually from the component library; the default) or Code (your own HTML). A template can be used by a campaign, or referenced by API sends via its id, so you author content once and send it many ways.
Templates carry a category. Marketing templates are the ones campaigns use. Transactional templates are for API-triggered, one-to-one mail (receipts, resets) and are intentionally NOT selectable for campaigns.
You can also send a marketing template through the API to a single recipient. That recipient becomes a contact if they aren't one already, so every marketing email carries a working unsubscribe link.
> **Transactional templates can't be campaigned:** Creating a campaign from a transactional template is blocked by the server (not just the form), because campaign mail to a marketing audience must honor unsubscribe and consent rules that transactional mail doesn't. Use — or clone into — a marketing template instead.
Images and other assets a template references live in the Library; a template that's still a draft, or that references missing assets, can't back a campaign send.
## Library
> Store and manage images and assets your templates reference.
The Library is shared asset storage for your workspace — upload images and files once, then reference them from any template. Hosting assets here (rather than hot-linking from elsewhere) means they stay available and load fast for every recipient.
Assets are referenced by Templates, so deleting an asset that a live template uses will leave a broken image in that mail. Check usage before removing anything you're unsure about.
> **Keep originals reasonable:** Large images slow rendering and can trip spam filters. Upload appropriately sized assets — the Library is for delivery, not as a full-resolution archive.
## Contacts
> The people you can email — subscription state, custom fields, and imports.
Contacts are individual recipients: an email address plus optional name and custom fields you define. Each contact has a marketing subscription state — subscribed contacts are mailable; unsubscribed or suppressed contacts are skipped automatically and never receive campaign mail.
You add contacts in the dashboard, by CSV import or through the API. A contact is also added automatically when you send a marketing template to an address that isn't one yet: a marketing email's unsubscribe link belongs to a contact. Those contacts show "Created by a marketing send" on their activity.
> **Custom field defaults:** A field's default is the value used for any contact that has none: when an email is sent (merge tags) and when segments are evaluated. It's never written onto contacts, so changing it takes effect everywhere at once. A value you set on a contact always wins.
### Importing
Import contacts in bulk from CSV. The import is per-row resilient: valid rows are created even if others fail. You get a summary of imported, skipped, and rejected rows, so one bad line never sinks the file.
> **Custom-field limits:** Custom fields are capped per contact (and on key/value length). Rows that exceed the caps are rejected in the import rather than truncated silently.
### Deleting a contact
Deleting a contact removes them from every segment. If segments are targeted by an active campaign, the confirmation shows the impact.
Contacts are the first link in the core flow: group them into Segments, then send to a segment from a Campaign.
## Suppressions
> Addresses blocked from receiving mail — protecting your sending reputation.
A suppression is an address-level block that excludes a single email address from every send — transactional, batch, and campaign — including CC and BCC. The match is case-insensitive. Suppressions keep you from mailing addresses that have already bounced or complained.
### Automatic suppression
When a message hard-bounces or the recipient complains, the address is added to your suppression list automatically and silently skipped on future sends. This is the single most important thing you can do for deliverability.
### Adding your own
Add addresses by hand from the Suppressions tab on the Contacts page — paste one or many and they're suppressed in a single step. The same operations are available via API at /api/v1/suppressions.
> **Excluded, not failed:** A send to a suppressed address isn't an error — the send succeeds, the suppressed recipients are simply dropped, and the result tells you how many were excluded and why. One bad address never fails the whole send.
### Platform-wide blocks
> **Blocked here, not listed here:** Unitpost also keeps a platform-wide list (reputation and abuse protection) that is checked on every send. Those addresses don't appear in your Suppressions tab or in the API list. Your list only ever holds your own workspace's addresses. When a platform-wide block stops a send, that email or contact shows as Suppressed, and a campaign counts it under Skipped (suppressed). Looking up one address by email still tells you it is blocked platform-wide. These blocks can't be removed from your workspace; if you believe one is in error, contact support.
### Removing an address
Any member with the manage-suppressions permission can remove an address you added. Removing an address that previously bounced or complained shows a warning, as re-sending to it can damage deliverability.
## Segments
> Named groups of contacts that campaigns send to.
A segment is a named audience — a group of contacts a campaign can target. Each campaign sends to exactly one segment, so segments are how you decide who receives a given send.
The list shows each segment's subscribed-member count alongside its total. Only subscribed members are mailable. A segment with zero subscribed contacts is flagged, because a campaign to it can't send.
> **Empty segments block sends:** A campaign whose segment has no subscribed members is stopped before it sends — there's no one to mail. Watch for the "0 subscribed" / "Empty" badge in the list and fix the audience before scheduling.
A rule on a custom field uses the field's default for any contact that has no value, the same value merge tags use. With a Yes/No field that defaults to No, "is No" includes contacts that never had the field set. "Is set" and "is not set" check whether the contact itself has a value. Defaults are never written onto contacts, so changing one updates every segment at once.
### Archiving & deletion
Deleting a segment archives it. Archived segments disappear from pickers, but the historical record of campaigns stays intact. You can restore an archived segment, and it's permanently swept ~30 days later once unused.
> **Can't archive while actively referenced:** If a non-terminal campaign (draft, scheduled, sending) still targets a segment, archiving is blocked with a 409 — cancel or finish that campaign first.
## Subscription topics
> Let contacts opt out of one kind of mail without leaving your list.
A topic is a subscription category — "Product updates," "Promotions" — that a contact can opt out of. It's orthogonal to segments and global unsubscribe: a contact can stay on your list but silence one kind of mail.
Manage topics from the Topics page in the dashboard. Recipients control membership via the unsubscribe preference page, and campaigns simply point at a topic to scope a send.
### Opt-in vs. opt-out
Each topic has one decision baked in — its default — which sets what "no explicit preference" means for a contact who has never touched it:
- Opt-out (the default) — contacts are auto-enrolled and receive the topic unless they opt out. Use it for newsletters and product updates.
- Opt-in — contacts are silent until they explicitly subscribe. Use it for beta invites or sensitive announcements you don't want going out by default.
> **An explicit choice always wins:** Once a contact subscribes or unsubscribes from a topic, that choice overrides the default in both directions. The default only governs contacts who've never recorded a preference.
### Scoping a campaign
Scope a campaign to a topic and anyone opted out is skipped at send (counted as suppressed in the validation report). You can't point a campaign at an archived or missing topic.
> **One-click unsubscribe respects the topic:** Mail for a topic-scoped campaign carries a topic-aware unsubscribe link and an RFC 8058 List-Unsubscribe-Post header, so a recipient's one-click opt-out silences just that topic — not all of your marketing. They keep getting everything else they didn't object to.
### Managing topics & preferences
Manage topics on the Topics page (or via /api/v1/email/topics) — create, list, edit, and archive. Archiving is blocked (409) while a non-terminal campaign still targets it. A contact's per-topic state is read and set at /api/v1/contacts/{id}/topics.
## Campaigns
> Send a template to a segment — now or on a schedule — with server-enforced safety gates.
A campaign is a one-to-many send: a template (content) + a segment (audience) + a from-address on a verified domain, sent immediately or scheduled. Campaigns are the end of the contacts → segments → campaigns pipeline.
### Send-readiness gates
Before a campaign sends, every one of these must hold (enforced on the server):
- The from-domain is verified and sending-allowed (re-checked at send, not just at create).
- There's a non-empty subject (from the campaign or the template default).
- The template is a publishable marketing template — not transactional, not an archived/draft template.
- The segment is not archived and has at least one subscribed member.
- Any schedule is within bounds — not in the past, and no further than 90 days out.
When a send is blocked, the validation dialog lists exactly which gate failed and how to fix it. The API returns the same reasons as a structured 409/422.
The validation dialog also shows who will receive the campaign. Its numbers (Members, Sendable, Suppressed, Excluded, and Missing data when some contacts lack a template variable) filter a read-only list of the segment's contacts, with each one's status and the reason in plain words: unsubscribed, on the suppression list (bounced, for example), opted out of the campaign's topic, excluded from this campaign, or missing a value. Seeing the list needs permission to view contacts; without it the dialog shows the counts only.
### Lifecycle & archiving
A campaign moves through draft → scheduled → sending → sent (or canceled/failed). Terminal campaigns can be archived directly; a non-terminal campaign must be canceled first. Archived campaigns are restorable and auto-pruned after ~30 days.
> **Scheduling semantics:** A schedule in the past is treated as "send now"; a schedule beyond 90 days is rejected at both create and send time. These bounds are consistent across the dashboard and the v1 API.
### Edit, unschedule or copy a campaign
A draft can be edited from its page or with `PATCH /api/v1/email/campaigns/{id}`: name, subject, from, preview text, template, segment, topic, tracking, variable fallbacks, exclusions and the saved send time. Changed values get the same checks as create. A value equal to the draft's is no edit, so an integration can send back the object it read.
A scheduled campaign is locked. To change it, unschedule it (the Unschedule button, or `POST /api/v1/email/campaigns/{id}/unschedule`): it goes back to a draft and its send time is cleared, so the next Send needs a time or `null` for now. A campaign that has started sending can't be unscheduled; cancel it instead.
Duplicate (`POST /api/v1/email/campaigns/{id}/duplicate`) copies a campaign's setup into a new draft, never its send time or results. Pass an `Idempotency-Key` so a retry returns the same copy. The SMS campaign routes work the same way, and the MCP server, SDKs and CLI have the same operations.
> **A copy mails the whole segment again:** Duplicating a canceled or sent campaign and sending the copy reaches everyone in the segment, including people who already got the original. Add them to the copy's `excluded_contact_ids` first if they shouldn't get it twice.
## Emails
> Every individual message sent — delivery status, opens, clicks, and bounces.
The Emails page is the message-level log: one row per email sent (campaign or API). Each row shows the recipient, subject, delivery state, and engagement (opens, clicks).
Use it to answer "what happened to this specific message?" — trace a delivery, confirm an open, or see why something bounced or was suppressed.
> **Suppression is automatic:** A hard bounce or complaint suppresses that address, so future sends skip it. You'll see the suppression reflected here and on the contact — it protects your domain's reputation without manual cleanup.
## Activity
> A chronological audit log of account events across every resource.
Activity is the cross-resource event feed — domain verifications, template changes, campaign sends, key creation, webhook deliveries, and more, in one timeline. Where the Emails page is per-message, Activity is per-account.
It's the place to reconstruct "who changed what, when" — useful for debugging an unexpected state, confirming an action took effect, or auditing access. Every member can reach it.
## API Keys
> Authenticate the API with scoped, least-privilege keys.
API keys authenticate requests to the public v1 API, the SDKs, the CLI and the SMTP relay. Create one under API Keys (/dashboard/api-keys) (also in Settings → Developers), pick what it may do, and copy the secret: it's shown once.
### Scopes
Every key has a scope. Full access can use every API operation except the opt-in ones, which you tick under Advanced: `branding:write` (branding, the logo, the workspace's name and image, Brand Kits), `apikeys:read` (your keys and their usage) and `activity:read` (the activity log). Sending can send email, campaigns and SMS, and read what a sender needs (contacts, templates, domains, sends). Read only can read everything except the opt-in reads, and change nothing. What each one covers:
| Area | Full access | Sending | Read only |
| --- | --- | --- | --- |
| Emails | Read and send | Read and send | Read |
| SMS | Read, manage and send | Read and send | Read |
| Campaigns | Read, manage and send | Read and send | Read |
| Templates | Read and manage | Read | Read |
| Contacts | Read and manage | Read | Read |
| Segments | Read and manage | Read | Read |
| Topics | Read and manage | Read | Read |
| Domains | Read and manage | Read | Read |
| Webhooks | Read and manage | — | Read |
On a paid plan you can also pick the exact capabilities a key holds instead of a preset (Advanced, when you create it). The API checks the key's capabilities on every request and answers `403` with the missing permission's name when one is missing.
> **What no key can do:** No key, not even Full access, can manage API keys, the team or billing, delete the workspace, or buy SMS numbers and registrations. Those stay in the dashboard behind a signed-in person, so a leaked key can never widen its own access. Changing branding, Brand Kits and the workspace's name is open to a key only when you tick `branding:write` on it (MCP's older `brand_kits_import` also takes `templates:write`).
### Plans
| | Free | Starter | Growth |
| --- | --- | --- | --- |
| Active keys | 3 | 10 | 50 |
| Full access and Sending | Yes | Yes | Yes |
| Read only and custom capabilities | — | Yes | Yes |
### Rotate and revoke
A key's scope can't be changed after it's created. To change what an integration can do, create a new key, deploy it, then revoke the old one. Revoking is immediate: the next request with that key gets `401`. The SMTP relay's credential is a Sending key, so the same rules apply.
> **Scope tightly:** Give each integration the least it needs: a server that only sends mail gets a Sending key, a reporting job gets Read only. Keys belong to the workspace, and anyone who can manage API keys can see and revoke them.
Pair keys with Webhooks (/guides/webhooks) for a full integration: keys for your calls to Unitpost, webhooks for Unitpost's events back to you. People's access is separate: see Team (/guides/team).
## Team
> Invite teammates and scope what each one can do with roles and permissions.
Invite teammates under Settings → Team (/dashboard/settings/team). Each person has a role, and the role sets what they can do. The same permissions model scopes API keys (/guides/api-keys), so a person and a key are limited the same way.
### Roles
| Area | Owner | Admin | Member |
| --- | --- | --- | --- |
| Emails | Read and send | Read and send | Read and send |
| SMS | Read, manage and send | Read, manage and send | Read and send |
| Campaigns | Read, manage and send | Read, manage and send | Read, manage and send |
| Templates | Read and manage | Read and manage | Read and manage |
| Contacts | Read and manage | Read and manage | Read and manage |
| Segments | Read and manage | Read and manage | Read and manage |
| Topics | Read and manage | Read and manage | Read and manage |
| Domains | Read and manage | Read and manage | Read |
| Webhooks | Read and manage | Read and manage | Read |
| API keys | Read and manage | Read and manage | — |
| Team | Read and manage | Read and manage | Read |
| Sent email content | Yes | Yes | — |
| Workspace settings | Yes | Yes | — |
| Billing | Yes | — | — |
| Delete the workspace | Yes | — | — |
There is one Owner. Only the Owner can manage billing and delete the workspace, and can hand ownership to a teammate (Transfer ownership on their row); the old Owner becomes an Admin. Admins can do everything else, including inviting people and managing keys and webhooks. Members build and send: contacts, templates, campaigns and sends. They can see domains and webhooks but not change them, and can't manage keys, the team or settings.
### Custom permissions
On a paid plan, open Advanced when you invite someone or edit a member to add or remove single permissions on top of the role, for example a Member who may also verify domains, or an Admin who may not send campaigns. Billing and deleting the workspace always stay with the Owner.
### Seats and invitations
| | Free | Starter | Growth |
| --- | --- | --- | --- |
| Team members | 1 | 3 | 10 |
A pending invitation holds a seat until it's accepted, revoked, or expires. An expired one shows as Expired on the Team page and frees its seat; Re-invite sends a fresh link if a seat is free.
### Sign-ins
The CLI and AI apps connected through MCP sign in as a person and get at most that person's permissions. Each person sees and revokes their own under Settings → Connected apps; Owners and Admins can review and revoke any member's sign-ins from that member's row. Removing someone from the team ends their sign-ins.
## Webhooks
> Receive signed, real-time event notifications at your own endpoint.
Webhooks push events to your application as they happen — a delivery, a bounce, a contact change — so you can react without polling. Register a public HTTPS endpoint, subscribe to the events (or categories) you care about, and we POST a signed payload whenever one fires.
> **Full reference in the Webhooks guide:** This page manages your endpoints; the API-reference Webhooks guide covers the payload shapes, signature verification (including timestamp tolerance), the retry schedule, the HTTPS/SSRF requirement, and rotating the signing secret. Read it before wiring up your verifier.
Each endpoint shows a live health state — Enabled, Failing, or auto-Disabled after sustained failures — so a dead URL surfaces early. Use Send test to fire a sample signed event and confirm your handler works end to end.
## Wallet
> The monthly sending allowance on a paid plan. Email and SMS draw it, and sending pauses once overage hits the cap.
A paid plan includes a monthly sending allowance: a dollar wallet you spend on email and SMS, each at that channel's own rate. It refreshes at the start of each billing period and does not roll over. The unused portion carries into the new plan only when you upgrade. Balance, spend, and overage are on Settings → Usage (/dashboard/settings/usage). The plan itself is on Settings → Billing (/dashboard/settings/billing). Current rates are on Pricing (/pricing).
### Free plan
The free plan has no dollar wallet. It has a monthly email quota and a daily cap, both shown on the usage page. SMS is not included.
### What the wallet pays for
- Email is charged at your plan's rate per thousand messages. Marketing and transactional sends use the same rate.
- SMS is charged per segment at the published rate for the recipient's country.
- Number and registration fees for toll-free and 10DLC (the monthly number lease, and one-time carrier fees such as brand registration and vetting) are quoted before you confirm, then taken from the wallet. They are not a separate charge on your card.
### After the allowance
When the allowance is spent, sending continues and the extra is billed as overage, at a higher rate than your plan. Overage stops at 25% of the monthly allowance (a $30 plan can go $7.50 over), and you get an email when it's half used. After that, sends pause until the allowance refreshes or you move to a larger plan.
You can't buy extra credit. If the workspace already has purchased credit, that balance is spent after the monthly allowance and does not refresh.
> **Not everything comes out of the wallet:** A dedicated IP is a line item on your card. It is not drawn from the sending allowance.
The same snapshot is on the API. GET /api/v1/usage (/docs/platform/usage) returns the plan, the period, and on a paid plan the wallet in integer cents: allowance, spend, upgrade carryover, purchased credit, and overage. On the free plan `wallet` is null.
## Migration Assistant
> Move from Resend, SendGrid, Mailgun, Postmark, Brevo, or Loops with one read-only API key.
The Migration Assistant (Settings → Migration (/dashboard/settings/migration)) moves your data from another email platform into Unitpost in about five minutes. It supports Resend, SendGrid, Mailgun, Postmark, Brevo, and Loops, and carries over seven kinds of data: contacts (with custom fields and subscription state), segments, topics, templates, domains, suppressions, and webhook endpoints.
### How it works
- Connect — pick your provider and paste an API key (a read-only key is all it needs; it only ever reads from your old provider). The key is validated, KMS-encrypted, used once to fetch a snapshot, then deleted.
- Preview — the assistant fetches everything in the background and shows a full preview: per-entity counts, samples, conflicts with data already in your workspace, and explicit warnings for anything the provider can't export. You can leave the page; the job persists and updates live.
- Accept — nothing is written until you accept. Choose which entities to include and how to resolve conflicts (skip or update), then import. A full report shows every row that was imported, skipped, or failed.
### What key do I need?
For every provider, one API key is all it takes — the assistant does the rest. A few providers have one extra detail worth knowing before you paste the key:
- Resend — any API key (resend.com/api-keys (https://resend.com/api-keys)); read access is enough.
- SendGrid — a key with Full Access, or Restricted Access with read permission on Contacts, Marketing, Templates, Suppressions, Sender Authentication, and Webhooks (Settings → API Keys (https://app.sendgrid.com/settings/api_keys)).
- Mailgun — your account-level Private API key (Settings → API Security (https://app.mailgun.com/settings/api_security)), not a domain sending key.
- Postmark — an Account API token (Account → API Tokens (https://account.postmarkapp.com/api_tokens)) migrates everything in one run: domains plus every server's templates, suppressions, and webhooks. A single Server token also works, but can't read domains.
- Brevo — an API key from Settings → SMTP & API (https://app.brevo.com/settings/keys/api). One thing to check first: if your account blocks unauthorized IPs for API keys (Security → Authorized IPs (https://app.brevo.com/security/authorised_ips)), turn it off before migrating — under “Blocking unauthorized IP addresses”, choose Deactivate for API keys. Brevo rejects API calls from unlisted IPs even when the key is valid. Keys created with the MCP toggle work too.
- Loops — any API key (Settings → API (https://app.loops.so/settings?page=api)).
### Safety model
- Read-only on the source — the assistant never sends, changes, or deletes anything on your old provider.
- Key custody — the API key is encrypted with KMS the moment you submit it, stored separately from the job, used once for the snapshot fetch, and deleted immediately after (also on failure, cancel, or expiry). It is never shown or re-readable.
- Opt-in writes — your Unitpost workspace is untouched until you explicitly accept the preview. Cancel any time before that.
- Zero downtime — your current provider keeps sending throughout. Migrate, verify DNS on the recreated domains, then switch your API key over whenever you're ready.
### What to expect
- Domains are recreated with fresh DKIM keys — you'll publish new DNS records here; your old provider's records stay valid until you remove them.
- Webhook endpoints get new signing secrets, so update your verifier after import.
- Templates carry over their HTML and variables; provider-specific template syntax is flagged in the preview when it can't be converted.
- Suppressions (bounces, complaints, unsubscribes) carry over so you never re-email someone who opted out.
- Anything a provider's API can't export is called out in the preview — never silently dropped. The preview expires if unaccepted, and one migration runs per workspace at a time.
> **Ask Unit to help:** The Unit assistant in the dashboard can check your migration's status, walk you through the preview, and explain any warning — just ask it about your migration.
## Integrations
> Connect forms, sheets, no-code tools, payments, stores, auth providers and CRMs to Unitpost through the API, webhooks or SMTP.
Unitpost connects to the tools you already use through its REST API (/docs/api), webhooks (/guides/webhooks) and SMTP relay (/smtp). Each section below is the smallest reliable way to wire one tool in: what fires in that tool, where the API key goes, and the exact call into Unitpost. The vendor details (trigger names, plan limits, quotas) come from each vendor's own docs, checked 2026-10-24. Ask Unit or your own agent (through MCP (/guides/ai/mcp)) for the same advice applied to your setup.
> **No native apps yet:** No native Zapier, Make, n8n, Google Sheets/Forms/Docs, Shopify, HubSpot or Salesforce app yet — connect with a generic HTTP step (Webhooks by Zapier, Make HTTP, n8n HTTP Request) or a small script, using an API key. Unitpost doesn't read Google Docs, Sheets or Notion by itself: something on your side (Apps Script, a no-code step, a script) pushes the data in. For a one-time list, export CSV and use the contact import instead. Unitpost doesn't host signup forms; the form lives in your site or form tool and posts the contact in. No IMAP/POP mailboxes — replies arrive as email.received webhooks. Unitpost doesn't send a third party's own notifications (Stripe receipts, Shopify order emails, Calendly confirmations): if you move those to Unitpost, switch the vendor's version off so people don't get two.
### Choosing a pattern
- Start from where the data/event begins and the smallest reliable hop: a script in the tool you already use (Apps Script for Google, Run a script in Airtable, a webhook handler in your backend) beats adding a new paid product.
- No-code: if you already pay for Zapier, Webhooks by Zapier is fine (it isn't on Zapier's Free plan). On a budget or self-hosting, n8n (HTTP Request node) or Make (HTTP module) do the same. Free Zapier/Make poll every ~15 minutes, so 'instant' needs a webhook trigger or a paid plan.
- Payments, stores, auth providers push signed webhooks: verify the signature on the RAW body, answer 2xx fast, and pass the source event id as idempotency_key — every one of them retries and may deliver twice or out of order.
- Let the other tool say what happened and let Unitpost decide what to send (an event into an automation, or a new contact into a welcome journey), instead of building the email logic in Zapier.
- Only people who opted in or are customers go into Unitpost. A list of scraped, bought, rented or 'found' leads is a no under the Acceptable Use Policy — design an opt-in form instead.
### Every pattern
| Tool | When it fits |
| --- | --- |
| Google Forms → Unitpost (new response → contact → welcome) (#integrations-google-forms) | A Google Form is the signup/lead form and each response should become a contact (and get a welcome). |
| Google Sheets → Unitpost (rows as contacts, row changes as triggers) (#integrations-google-sheets) | Contacts or orders live in a Sheet that people (or other tools) keep adding to. |
| Google Docs as the content of an email or newsletter (#integrations-google-docs) | You write the newsletter in a Doc and want to send it with Unitpost. |
| Zapier → Unitpost (Webhooks by Zapier) (#integrations-zapier) | You already run your glue in Zapier on a paid plan. |
| Make (make.com) → Unitpost (HTTP module) (#integrations-make) | No-code glue on a budget, or you already use Make. |
| n8n → Unitpost (HTTP Request node, or MCP) (#integrations-n8n) | You self-host or want the cheapest no-code glue; also the easiest to point at Unitpost's MCP server. |
| Stripe → Unitpost (payments and subscriptions) (#integrations-stripe) | Emails driven by payments: welcome after checkout, failed-payment (dunning), trial ending, cancellation. |
| Shopify → Unitpost (orders, abandoned checkouts, customers) (#integrations-shopify) | A Shopify store wants order follow-ups, abandoned-checkout or post-purchase journeys from Unitpost. |
| Supabase → Unitpost (signups, table changes, auth emails) (#integrations-supabase) | Your app runs on Supabase: welcome on signup, events from table changes, or sending Supabase's auth emails through Unitpost. |
| Firebase Auth / Firestore → Cloud Functions → Unitpost (#integrations-firebase) | Your app uses Firebase: welcome on signup, emails from Firestore writes. |
| Clerk → Unitpost (user.created, and sending Clerk's emails yourself) (#integrations-clerk) | Auth by Clerk: add new users as contacts, or deliver Clerk's verification emails through Unitpost. |
| Notion → Unitpost (database rows as contacts or events) (#integrations-notion) | A Notion database tracks subscribers, leads or approvals. |
| Airtable → Unitpost (automation 'Run a script') (#integrations-airtable) | Contacts or orders live in Airtable. |
| Typeform / Tally / Webflow forms → Unitpost (#integrations-form-webhooks) | A hosted form tool collects signups. |
| HubSpot / Salesforce → Unitpost (#integrations-crm) | The CRM is the system of record and Unitpost sends. |
| Calendly → Unitpost (bookings) (#integrations-calendly) | Follow-ups and nurture around booked meetings. |
| Unitpost → Slack (internal alerts) (#integrations-slack-alerts) | The team wants a Slack message on bounces, complaints, failed sends, or new replies. |
| WordPress / WooCommerce → Unitpost (#integrations-woocommerce) | A WordPress site or Woo store. |
| Segment / RudderStack → Unitpost (track events) (#integrations-cdp) | Product events already flow through a CDP. |
| Scheduled sends from your database (GitHub Actions, Vercel Cron, Supabase Cron) (#integrations-cron) | Something due by date in your data (trial ends tomorrow, weekly digest). |
### Google Forms → Unitpost (new response → contact → welcome)
A Google Form is the signup/lead form and each response should become a contact (and get a welcome).
- Trigger in the tool: Apps Script installable form-submit trigger (onFormSubmit) on the Form, or on its response Sheet.
- How it connects: The trigger's handler reads the answers and calls UrlFetchApp.fetch(POST JSON) to Unitpost.
- Where the API key lives: Script Properties (PropertiesService.getScriptProperties()); the script asks once for the external-request permission.
- Into Unitpost: POST /contacts, then POST /email with the welcome template.
- How fast: Instant, on every submission.
- Limit: Apps Script quotas: 20,000 URL fetches/day and 90 min total trigger runtime/day on consumer accounts (100,000 / 6 h on Workspace); 6 min per execution.
- Limit: An installable trigger runs as the person who created it — if that account loses access, it stops.
- Watch out: A function named onFormSubmit does nothing until the trigger is installed.
- Watch out: Turn on the form's 'Collect email addresses' and read getRespondentEmail(); a free-text Email question can hold anything.
- Watch out: Ask for marketing consent in the form if they'll get newsletters, not just the welcome.
**Apps Script**
```ts
function onSubmit(e) {
// Installable trigger (once): ScriptApp.newTrigger('onSubmit').forForm(FormApp.getActiveForm()).onFormSubmit().create();
const answers = {};
e.response.getItemResponses().forEach((r) => (answers[r.getItem().getTitle()] = r.getResponse()));
const key = PropertiesService.getScriptProperties().getProperty('UNITPOST_API_KEY');
const res = UrlFetchApp.fetch('https://www.unitpost.com/api/v1/contacts', {
method: 'post', contentType: 'application/json', muteHttpExceptions: true,
headers: { Authorization: 'Bearer ' + key },
payload: JSON.stringify({ email: e.response.getRespondentEmail() || answers['Email'], first_name: answers['Name'] }),
});
if (res.getResponseCode() >= 300 && res.getResponseCode() !== 409) console.error(res.getContentText());
}
```
Sources: 1 (https://developers.google.com/apps-script/guides/triggers/installable), 2 (https://developers.google.com/apps-script/guides/triggers/events), 3 (https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app), 4 (https://developers.google.com/apps-script/guides/properties), 5 (https://developers.google.com/apps-script/guides/services/quotas).
### Google Sheets → Unitpost (rows as contacts, row changes as triggers)
Contacts or orders live in a Sheet that people (or other tools) keep adding to.
- Trigger in the tool: Apps Script installable Edit/Change trigger for rows people type in; a time-driven trigger (every 1/5/10/15/30 min) sweeping unsynced rows for rows other tools write.
- How it connects: UrlFetchApp POST per new row; write a 'synced' timestamp (or the contact id) back so a row is sent once.
- Where the API key lives: Script Properties.
- Into Unitpost: POST /contacts for new rows; POST /email with a template when a row needs an email now.
- How fast: Instant for edits; the sweep interval for rows written by other tools.
- Limit: A SIMPLE onEdit can't call external APIs (no authorized services, 30 s cap) — it must be an installable trigger.
- Limit: Same Apps Script quotas as Google Forms (20,000 fetches/day consumer).
- Watch out: Rows written by a script, the Sheets API or Zapier never fire edit/change triggers — use the time-driven sweep for those.
- Watch out: Edit triggers fire per cell: wait for a 'ready' checkbox or all required columns before sending.
- Watch out: For a one-time list, export CSV and import it in Contacts instead of row-by-row calls.
- Watch out: Ask where the list came from first: a sheet of scraped or bought leads can't be emailed (Acceptable Use Policy).
**Apps Script**
```ts
function sync() { // time-driven trigger, e.g. every 15 minutes
const sh = SpreadsheetApp.getActive().getSheetByName('Contacts');
const rows = sh.getDataRange().getValues(); // email, first, last, synced
const key = PropertiesService.getScriptProperties().getProperty('UNITPOST_API_KEY');
rows.slice(1).forEach((r, i) => {
if (!r[0] || r[3]) return;
const res = UrlFetchApp.fetch('https://www.unitpost.com/api/v1/contacts', { method: 'post', contentType: 'application/json',
headers: { Authorization: 'Bearer ' + key }, muteHttpExceptions: true,
payload: JSON.stringify({ email: r[0], first_name: r[1], last_name: r[2] }) });
const code = res.getResponseCode();
if (code < 300 || code === 409) sh.getRange(i + 2, 4).setValue(new Date());
});
}
```
Sources: 1 (https://developers.google.com/apps-script/guides/triggers), 2 (https://developers.google.com/apps-script/guides/triggers/installable), 3 (https://developers.google.com/apps-script/reference/script/clock-trigger-builder), 4 (https://developers.google.com/apps-script/guides/services/quotas).
### Google Docs as the content of an email or newsletter
You write the newsletter in a Doc and want to send it with Unitpost.
- Trigger in the tool: Manual (a menu/button in a bound script) or time-driven; Docs has no 'document changed' trigger.
- How it connects: Simplest: paste the text into a Unitpost template (or give it to Unit to lay out as a designed email). Automated: Drive export (text/markdown, text/plain or text/html) from Apps Script, poured into a template variable.
- Where the API key lives: Drive scope for the export; the Unitpost key in Script Properties.
- Into Unitpost: Same: a template filled from the export, sent as a campaign after review.
- How fast: On demand.
- Limit: Drive export is capped at 10 MB.
- Watch out: Exported Docs HTML is web HTML (stylesheet classes, not inline email styles) and its images point at Google-hosted URLs — don't mail it raw; use the text and a Unitpost template, and review before a broadcast.
- Watch out: getBody() reads only the first tab of a multi-tab Doc.
Sources: 1 (https://developers.google.com/workspace/drive/api/guides/ref-export-formats), 2 (https://developers.google.com/workspace/drive/api/reference/rest/v3/files/export), 3 (https://developers.google.com/apps-script/reference/document/document).
### Zapier → Unitpost (Webhooks by Zapier)
You already run your glue in Zapier on a paid plan.
- Trigger in the tool: Any Zapier trigger (form/sheet/CRM app, Zapier Forms, or Webhooks by Zapier Catch Hook).
- How it connects: Action 'Webhooks by Zapier' → POST (or Custom Request), Payload Type JSON, header Authorization: Bearer .
- Where the API key lives: The key sits in the step's header — anyone who can edit the Zap can see it; use a key limited to what the Zap does.
- Into Unitpost: Webhooks by Zapier POST /contacts, or POST /email with a template.
- How fast: Instant for webhook-based triggers; the polling interval otherwise (Google Sheets/Forms on Zapier usually poll).
- Limit: Webhooks by Zapier is a premium app: Professional, Team or Enterprise — not on the Free plan (Free is also limited to two-step Zaps and 100 tasks/month).
- Limit: Every step run is a task; polling triggers check every 15 min (Free), 2 min (Professional), 1 min (Team/Enterprise).
- Limit: Zapier's own MCP offering and an MCP-client step exist; a plain Webhooks step to the REST API is the stabler path.
- Watch out: There is no Unitpost app in Zapier's directory yet: use Webhooks by Zapier rather than searching for one.
- Watch out: Polling triggers can re-deliver after edits: send idempotency_key on events.
- Watch out: Free-plan users can't turn on a Zap with a Webhooks step: suggest Make, n8n or Apps Script instead.
Sources: 1 (https://help.zapier.com/hc/en-us/articles/8496326446989-Send-webhooks-in-Zaps), 2 (https://zapier.com/pricing), 3 (https://help.zapier.com/hc/en-us/articles/8496244568589-How-Zap-triggers-work).
### Make (make.com) → Unitpost (HTTP module)
No-code glue on a budget, or you already use Make.
- Trigger in the tool: Any Make trigger (polling on the scenario schedule) or Webhooks > Custom webhook (instant).
- How it connects: HTTP > Make a request: POST, JSON body, Authorization: Bearer (or the module's Credentials field).
- Where the API key lives: Make's keychain/Credentials, not a plain header, where possible.
- Into Unitpost: HTTP POST /contacts, or POST /email with a template.
- How fast: Instant with a webhook trigger; the schedule otherwise.
- Limit: Billing is in credits (1 credit per non-AI module run); Free has 1,000 credits/month.
- Limit: Scheduled scenarios run at most every 15 min on Free, every minute on paid plans (check Make's pricing page for current limits).
- Limit: Webhooks accept about 300 requests per 10 s; a webhook not attached to a scenario for 5 days is deactivated.
- Watch out: Every module run costs a credit, including polls that return items; iterators multiply runs.
- Watch out: There's no Unitpost app in Make yet — use the HTTP module.
Sources: 1 (https://apps.make.com/http), 2 (https://help.make.com/webhooks), 3 (https://help.make.com/credits), 4 (https://www.make.com/en/pricing).
### n8n → Unitpost (HTTP Request node, or MCP)
You self-host or want the cheapest no-code glue; also the easiest to point at Unitpost's MCP server.
- Trigger in the tool: Any trigger node, Schedule Trigger, or the Webhook node (production URL only once the workflow is active).
- How it connects: HTTP Request node, Send Body JSON, credential type Bearer Auth (or 'Import cURL' from the Unitpost docs). MCP Client nodes can call mcp.unitpost.com directly.
- Where the API key lives: n8n credentials (stored encrypted, not in the node).
- Into Unitpost: HTTP Request → POST /contacts or POST /email with a template.
- How fast: Instant with a Webhook trigger; the interval for Schedule/polling triggers.
- Limit: n8n Cloud bills per workflow execution (a whole run, any number of steps); self-hosted Community Edition has no execution cap.
- Watch out: Test and production webhook URLs differ — 'works in test, silent in production' is usually the test URL.
- Watch out: Verify Unitpost webhook signatures in a Code node before trusting them.
Sources: 1 (https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.httprequest/), 2 (https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/), 3 (https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.mcpclient), 4 (https://n8n.io/pricing/).
### Stripe → Unitpost (payments and subscriptions)
Emails driven by payments: welcome after checkout, failed-payment (dunning), trial ending, cancellation.
- Trigger in the tool: Stripe webhook events: checkout.session.completed (+ checkout.session.async_payment_succeeded), invoice.paid, invoice.payment_failed, customer.subscription.created|updated|deleted, customer.subscription.trial_will_end (3 days before), invoice.upcoming.
- How it connects: Your HTTPS endpoint or serverless function verifies the Stripe-Signature on the RAW body (stripe.webhooks.constructEvent), answers 2xx fast, then calls Unitpost.
- Where the API key lives: Stripe endpoint signing secret (whsec_…, different in test and live) and the Unitpost key in env vars.
- Into Unitpost: POST /email with the matching template from the webhook handler, idempotency_key = event.id.
- How fast: Instant (push).
- Limit: No plan gate; up to 16 event destinations; live-mode retries for up to 3 days.
- Watch out: No ordering guarantee and duplicates happen: pass the Stripe event.id as idempotency_key.
- Watch out: A framework that parses JSON first breaks signature verification — keep the raw body.
- Watch out: Stripe can send its own receipts, failed-payment and trial-ending emails: switch those off in Stripe if Unitpost sends them.
Sources: 1 (https://docs.stripe.com/webhooks), 2 (https://docs.stripe.com/billing/subscriptions/webhooks), 3 (https://docs.stripe.com/checkout/fulfillment), 4 (https://docs.stripe.com/billing/revenue-recovery/customer-emails).
### Shopify → Unitpost (orders, abandoned checkouts, customers)
A Shopify store wants order follow-ups, abandoned-checkout or post-purchase journeys from Unitpost.
- Trigger in the tool: Webhook topics orders/create, orders/paid, orders/fulfilled, checkouts/create, checkouts/update, customers/create.
- How it connects: Three routes: a webhook created in Settings → Notifications → Webhooks (no app); a custom or public app subscribing via the Admin API; or Shopify Flow's 'Send HTTP request' action posting straight to Unitpost.
- Where the API key lives: Verify X-Shopify-Hmac-Sha256 (base64 HMAC-SHA256 of the raw body); Flow can reference secrets in headers.
- Into Unitpost: orders/paid → POST /email with the follow-up template; customers/create → POST /contacts. Abandoned-checkout timing needs automations (or your own delayed job).
- How fast: Instant (push).
- Limit: Flow's Send HTTP request is on Grow, Advanced and Plus — not Basic.
- Limit: Merchants can't create new legacy admin custom apps since January 1, 2026 (existing ones keep working).
- Limit: Endpoint must answer within ~5 s; failures retry 8 times over 4 hours, then the subscription can be removed.
- Limit: Apps need Shopify's protected-customer-data approval for order/customer fields.
- Watch out: An abandoned checkout isn't an event: send checkout started, then let a Unitpost automation wait (e.g. 1–24 h) for order.paid and only remind when it doesn't come.
- Watch out: Dedupe on X-Shopify-Webhook-Id (pass it as idempotency_key).
- Watch out: Shopify sends its own order and abandoned-checkout emails: turn those off before sending them from Unitpost.
- Watch out: Only add customers who accepted marketing as newsletter contacts.
**Node.js**
```ts
const digest = crypto.createHmac('sha256', process.env.SHOPIFY_CLIENT_SECRET).update(rawBody).digest('base64');
const ok = crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(req.headers['x-shopify-hmac-sha256'] || ''));
if (!ok) return res.sendStatus(401);
```
Sources: 1 (https://shopify.dev/docs/apps/build/webhooks/subscribe/https), 2 (https://shopify.dev/docs/api/admin-graphql/latest/enums/WebhookSubscriptionTopic), 3 (https://help.shopify.com/en/manual/fulfillment/setup/notifications/webhooks), 4 (https://help.shopify.com/en/manual/shopify-flow/reference/actions/send-http-request), 5 (https://changelog.shopify.com/posts/legacy-custom-apps-can-t-be-created-after-january-1-2026).
### Supabase → Unitpost (signups, table changes, auth emails)
Your app runs on Supabase: welcome on signup, events from table changes, or sending Supabase's auth emails through Unitpost.
- Trigger in the tool: Database Webhooks on INSERT/UPDATE/DELETE (payload: type, table, record, old_record); Auth Hooks (Send Email Hook replaces the built-in auth emails).
- How it connects: Database Webhook → Edge Function (fetch to Unitpost), so the key never sits in a trigger. Auth emails: Send Email Hook → Edge Function → POST /email, or point Supabase's custom SMTP at Unitpost SMTP.
- Where the API key lives: supabase secrets set UNITPOST_API_KEY=… (read with Deno.env.get); hook requests are signed (Standard Webhooks).
- Into Unitpost: insert → Edge Function → POST /contacts then POST /email with the welcome template; auth emails via the hook or SMTP.
- How fast: Instant (after commit).
- Limit: Send Email Hook is on Free and Pro; hook timeouts: Postgres 2 s, HTTP 5 s.
- Limit: Supabase's built-in SMTP sends only a handful of emails per hour (team addresses only) — production auth email needs custom SMTP or the hook.
- Limit: pg_net calls time out after 2 s by default and aren't retried — a failed call is silent.
- Watch out: Because pg_net doesn't retry, use an outbox table + cron for anything that must not be missed, or check net._http_response.
- Watch out: With the Send Email Hook on, SMTP isn't used and you build the confirmation link from token_hash + redirect_to.
`TypeScript (Supabase Edge Function)`
```tsx
Deno.serve(async (req) => {
const { type, record } = await req.json();
if (type !== 'INSERT') return new Response('ignored');
const r = await fetch('https://www.unitpost.com/api/v1/contacts', { method: 'POST',
headers: { Authorization: `Bearer ${Deno.env.get('UNITPOST_API_KEY')}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ email: record.email, first_name: record.first_name }) });
return new Response(await r.text(), { status: r.ok || r.status === 409 ? 200 : 502 });
});
```
Sources: 1 (https://supabase.com/docs/guides/database/webhooks), 2 (https://supabase.com/docs/guides/database/extensions/pg_net), 3 (https://supabase.com/docs/guides/auth/auth-hooks/send-email-hook), 4 (https://supabase.com/docs/guides/auth/auth-smtp), 5 (https://supabase.com/docs/guides/functions/secrets).
### Firebase Auth / Firestore → Cloud Functions → Unitpost
Your app uses Firebase: welcome on signup, emails from Firestore writes.
- Trigger in the tool: Auth user created (onUserCreated / auth.user().onCreate); Firestore onDocumentCreated|Updated|Written.
- How it connects: A Cloud Function calls Unitpost with fetch or the unitpost SDK.
- Where the API key lives: Secret Manager via Firebase params (defineSecret).
- Into Unitpost: user created → POST /contacts + POST /email with the welcome template.
- How fast: Near-instant, asynchronous.
- Limit: Deploying functions needs the Blaze (pay-as-you-go) plan.
- Watch out: Delivery is at-least-once and unordered: use the uid / doc id as idempotency_key.
- Watch out: Anonymous users have no email — skip them.
Sources: 1 (https://firebase.google.com/docs/functions/auth-events), 2 (https://firebase.google.com/docs/functions/firestore-events), 3 (https://firebase.google.com/support/faq).
### Clerk → Unitpost (user.created, and sending Clerk's emails yourself)
Auth by Clerk: add new users as contacts, or deliver Clerk's verification emails through Unitpost.
- Trigger in the tool: Clerk webhooks (via Svix): user.created / user.updated / user.deleted; with 'Delivered by Clerk' off on a template, an email event carries the message for you to send (check the exact event name in the Dashboard's Event Catalog).
- How it connects: Route handler → verifyWebhook(req) (@clerk/nextjs/webhooks) → Unitpost.
- Where the API key lives: CLERK_WEBHOOK_SIGNING_SECRET (whsec_…); Svix headers.
- Into Unitpost: user.created → POST /contacts + POST /email; verification emails → POST /email.
- How fast: Near-instant but asynchronous and not guaranteed — don't depend on it for sign-in-critical flows without monitoring.
- Limit: Opt-out of Clerk delivery is per template.
- Watch out: At-least-once, unordered: dedupe on svix-id.
- Watch out: If Unitpost sends Clerk's verification codes, an outage in that handler blocks sign-ins.
Sources: 1 (https://clerk.com/docs/guides/development/webhooks/overview), 2 (https://clerk.com/docs/guides/customizing-clerk/email-sms-templates).
### Notion → Unitpost (database rows as contacts or events)
A Notion database tracks subscribers, leads or approvals.
- Trigger in the tool: Notion API webhooks (page.created, page.properties_updated, data_source.content_updated…), or a database automation's 'Send webhook' action; polling a data source as a fallback.
- How it connects: API webhooks carry ids only — the receiver reads the row with the Notion API, then calls Unitpost. 'Send webhook' posts properties in Notion's own format, so a relay (n8n/Make/function) reshapes it.
- Where the API key lives: Notion integration token; webhook signatures (X-Notion-Signature).
- Into Unitpost: relay → POST /contacts, or POST /email.
- How fast: API webhooks within minutes (most within a minute).
- Limit: 'Send webhook' is on paid plans; API rate limit ~3 requests/second per integration.
- Watch out: Delivery can be out of order; on a 'Send webhook' failure the automation pauses until someone resumes it.
Sources: 1 (https://developers.notion.com/reference/webhooks), 2 (https://developers.notion.com/reference/request-limits), 3 (https://www.notion.com/help/webhook-actions).
### Airtable → Unitpost (automation 'Run a script')
Contacts or orders live in Airtable.
- Trigger in the tool: Automation trigger 'When record matches conditions' (e.g. a Ready checkbox) or 'When a record is created'.
- How it connects: Action 'Run a script' with fetch() to Unitpost; record fields come in through input.config().
- Where the API key lives: Airtable script secrets (input.secret('UNITPOST_API_KEY')).
- Into Unitpost: Run a script → POST /contacts or POST /email.
- How fast: Near-instant.
- Limit: Automation runs per month: Free 100, Team 25,000, Business 100,000; a script makes at most 50 fetch calls per run (check whether scripts are on your plan).
- Watch out: 'When a record is created' can fire before fields are filled — trigger on a Ready condition.
**Airtable script**
```ts
const { email, firstName } = input.config();
const res = await fetch('https://www.unitpost.com/api/v1/contacts', { method: 'POST',
headers: { Authorization: `Bearer ${input.secret('UNITPOST_API_KEY')}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ email, first_name: firstName }) });
if (!res.ok && res.status !== 409) throw new Error(`Unitpost ${res.status}: ${await res.text()}`);
```
Sources: 1 (https://support.airtable.com/docs/run-a-script-action), 2 (https://support.airtable.com/articles/2277136852-airtable-plans-overview).
### Typeform / Tally / Webflow forms → Unitpost
A hosted form tool collects signups.
- Trigger in the tool: The tool's native 'new response' webhook (Typeform form_response, Tally FORM_RESPONSE, Webflow form_submission).
- How it connects: The native webhook can't reshape its JSON into Unitpost's body, so a small relay (serverless function, n8n, Make, Zapier Catch Hook) maps fields and calls Unitpost.
- Where the API key lives: Each tool signs its webhook (Typeform-Signature, Tally-Signature, x-webflow-signature — Webflow only for webhooks created through its API).
- Into Unitpost: relay → POST /contacts + POST /email with the welcome template.
- How fast: Instant.
- Limit: Tally webhooks are free; check Typeform's and Webflow's current plan pages for webhook availability.
- Watch out: Answer 2xx fast (Tally times out at 10 s); a 404/410 from the relay disables a Typeform webhook.
- Watch out: Add a marketing-consent checkbox to the form before adding people to newsletters.
Sources: 1 (https://www.typeform.com/developers/webhooks/), 2 (https://tally.so/help/webhooks), 3 (https://developers.webflow.com/data/docs/working-with-webhooks).
### HubSpot / Salesforce → Unitpost
The CRM is the system of record and Unitpost sends.
- Trigger in the tool: HubSpot workflow action 'Send a webhook'; Salesforce record-triggered Flow with an HTTP Callout.
- How it connects: HubSpot can map properties into a custom JSON body (no relay needed); Salesforce uses a Named/External Credential for the Authorization header.
- Where the API key lives: HubSpot stored secret (format the value as 'Bearer '); Salesforce Named Credential.
- Into Unitpost: Send a webhook → POST /contacts, or POST /email.
- How fast: Near-instant on enrollment; HubSpot retries for up to 3 days.
- Limit: HubSpot 'Send a webhook' needs Data Hub Professional or Enterprise; without it, use a no-code tool polling the CRM.
- Watch out: Decide which system owns marketing email and unsubscribes; push Unitpost topic.unsubscribed / email.bounced webhooks back to the CRM.
Sources: 1 (https://knowledge.hubspot.com/workflows/how-do-i-use-webhooks-with-hubspot-workflows), 2 (https://help.salesforce.com/s/articleView?id=platform.flow_http_callout.htm&language=en_US).
### Calendly → Unitpost (bookings)
Follow-ups and nurture around booked meetings.
- Trigger in the tool: Webhook subscriptions invitee.created, invitee.canceled, routing_form_submission.created.
- How it connects: Create the subscription with the API (POST /webhook_subscriptions); a relay maps the invitee to Unitpost.
- Where the API key lives: Calendly-Webhook-Signature (HMAC over t + '.' + body).
- Into Unitpost: invitee.created → POST /contacts + POST /email.
- How fast: Instant.
- Limit: Webhooks need a paid Calendly plan (Standard/Professional or higher), not Free.
- Watch out: Calendly sends its own confirmations and reminders — use Unitpost for follow-up, not duplicates.
- Watch out: Key journeys on the newest booking: a reschedule can show up as a cancel plus a new booking.
Sources: 1 (https://developer.calendly.com/receive-data-from-scheduled-events-in-real-time-with-webhook-subscriptions), 2 (https://calendly.com/help/calendly-api-overview).
### Unitpost → Slack (internal alerts)
The team wants a Slack message on bounces, complaints, failed sends, or new replies.
- Trigger in the tool: A Unitpost webhook subscribed to email.bounced / email.complained / email.failed / email.received / campaign.paused.
- How it connects: A relay verifies the Unitpost signature, formats { text } and posts to a Slack Incoming Webhook URL (or n8n/Make/Zapier in between).
- Where the API key lives: The Slack webhook URL is itself a secret — env var only.
- Into Unitpost: Webhook → relay → Slack.
- How fast: Instant.
- Limit: Slack incoming webhooks allow about 1 message per second.
- Watch out: Pointing a Unitpost webhook straight at Slack won't render — the payload formats differ.
- Watch out: Batch spikes (a bounce wave) into a digest.
**Node.js**
```ts
import { verifyWebhook } from 'unitpost';
const evt = verifyWebhook({ payload: rawBody, headers: req.headers, secret: process.env.UNITPOST_WEBHOOK_SECRET });
await fetch(process.env.SLACK_WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: `Unitpost ${evt.type}: ${evt.data?.to ?? ''}` }) });
```
Sources: 1 (https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks), 2 (https://docs.slack.dev/apis/web-api/rate-limits).
### WordPress / WooCommerce → Unitpost
A WordPress site or Woo store.
- Trigger in the tool: WooCommerce webhooks order.created|updated, customer.created; site mail through wp_mail.
- How it connects: Woo POSTs to a relay that verifies X-WC-Webhook-Signature and calls Unitpost; site mail via an SMTP plugin pointed at Unitpost SMTP.
- Where the API key lives: Woo webhook secret; SMTP password as a wp-config.php constant.
- Into Unitpost: order.updated (paid) → POST /email; customer.created → POST /contacts.
- How fast: Background (wp-cron by default) — low-traffic sites can lag without a real cron.
- Limit: A Woo webhook is disabled after 5 consecutive failed deliveries.
- Watch out: order.created can fire before payment: use order.updated with status processing/completed.
- Watch out: Woo sends its own order emails — avoid duplicates.
Sources: 1 (https://woocommerce.com/document/webhooks/), 2 (https://wpmailsmtp.com/docs/how-to-set-up-the-other-smtp-mailer-in-wp-mail-smtp/).
### Segment / RudderStack → Unitpost (track events)
Product events already flow through a CDP.
- Trigger in the tool: track (e.g. 'Order Completed') and identify calls.
- How it connects: A Segment Destination Function (onTrack / onIdentify) or the Webhooks (Actions) destination; RudderStack's Webhook destination plus a transformation.
- Where the API key lives: Function settings (settings.apiKey) / destination headers.
- Into Unitpost: onIdentify → POST /contacts; send email from your backend with POST /email.
- How fast: Near real time; Segment retries functions that throw RetryError for hours.
- Limit: Turn batching off: Unitpost takes one event / contact per call.
- Watch out: Map messageId to idempotency_key.
- Watch out: An event without an email (or a userId → email mapping) can't reach a contact.
Sources: 1 (https://segment.com/docs/connections/functions/destination-functions/), 2 (https://www.rudderstack.com/docs/destinations/webhooks/setup-guide/).
### Scheduled sends from your database (GitHub Actions, Vercel Cron, Supabase Cron)
Something due by date in your data (trial ends tomorrow, weekly digest).
- Trigger in the tool: A schedule: GitHub Actions on.schedule, vercel.json crons, Supabase Cron (pg_cron).
- How it connects: The job selects due rows, calls Unitpost per row, marks them done.
- Where the API key lives: Repository/env secrets; protect a Vercel cron route with CRON_SECRET.
- Into Unitpost: cron → POST /email with a template per due row; store the returned email id on the row.
- How fast: The schedule (best effort).
- Limit: GitHub schedules run at most every 5 minutes and can be delayed or dropped under load; Vercel Hobby crons run once a day (±59 min).
- Watch out: Crons can miss or double-run: process 'everything due since the last success' and send idempotency_key = row id + purpose.
- Watch out: For time relative to a person ('3 days after signup'), a Unitpost automation with a wait step usually replaces the cron.
Sources: 1 (https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows), 2 (https://vercel.com/docs/cron-jobs/usage-and-pricing), 3 (https://supabase.com/docs/guides/cron).
## SMS vs email: what's different
> Consent, quiet hours, per-segment billing, reach, and no opens or clicks. Read this once before your first SMS.
SMS runs on the same Unitpost you already use for email: same contacts, same segments, same API key and error envelope, same webhooks. But the channel itself is governed by carriers and regulators rather than inbox providers, and that changes seven things. Each is covered in depth in its own guide; this page is the map.
> **Paid plans only:** SMS is included on every paid plan and is not on the free plan: brands, sender requests, sends, and campaigns need one. On the free plan `POST /v1/sms` returns `403` with `limit_reached`. When a paid plan ends, numbers and campaigns are kept for a 7-day grace window and released at its end, with a reminder 7 days and 1 day before.
### Consent is opt-in, not opt-out
Email marketing is opt-out by default: you may send until someone unsubscribes. SMS is the reverse. A marketing SMS needs the recipient's prior express consent on record before the first message, and a send without it is skipped with the reason no_marketing_consent rather than attempted. Transactional SMS (receipts, alerts, verification codes) does not need marketing consent. Record consent where the recipient actually gave it: your signup form (POST /v1/contacts/{id}/sms-consent with a source), a keyword they texted, or the hosted preference page. Never mark a list as opted in on their behalf. A phone number on a contact (`phone` on POST or PATCH /v1/contacts) is not consent either: it only says where to text. To confirm an opt-in you recorded, POST /v1/contacts/{id}/sms-opt-in-request texts them "Reply YES"; their YES records a verified marketing opt-in.
- STOP opts the number out of the SMS channel entirely. It never touches the contact's email subscription, and vice versa.
- HELP returns your program's help text (your brand name, how to reach support, how to stop).
- START re-opts the number in. Carrier keywords are handled for you; do not build keyword handling yourself.
### Quiet hours
Email lands whenever you send it. Marketing SMS respects the recipient's local quiet hours: a message queued at midnight is deferred to the morning window, never dropped and never sent at 2am. The recipient's window comes from their phone number's country and region. A deferred message shows the reason quiet_hours_deferred; there is nothing to fix. Transactional messages are not window-gated.
### Billing is per segment, from the wallet
SMS is included on paid plans, and it spends the same monthly sending allowance as email: one wallet (/guides/wallet), not a separate SMS balance. Email is charged per thousand messages; SMS is charged per segment at the published rate for the recipient's country. 160 GSM-7 characters fit one segment (153 each when a message spans several); Unicode messages fit 70 (67 each when split). The composer and the template editor show the segment count as you type. A skipped recipient is never billed, and a campaign pauses rather than overdrawing the wallet. Number leases and carrier fees are quoted before you confirm and taken from the same wallet, never charged to your card separately.
### Reach: numbers and Sender IDs
A verified email domain can send to any inbox in the world. An SMS sending identity delivers to specific countries, by carrier rule. A US toll-free or 10-digit local (10DLC) number reaches US recipients only; a Canadian local number (instant, no registration) reaches Canada only. To reach other countries you enable an alphanumeric Sender ID: your brand name on the handset instead of a number, free, enabled per destination country. It is instant in most of Europe, Latin America, Asia-Pacific, and Africa; some countries (the United Kingdom, Spain, Singapore, and others) require a regulator registration that takes days to weeks; the United States, Canada, and China do not support Sender IDs at all. A recipient outside every sender's reach is skipped as out_of_reach, with the country and a link to the fix. See SMS Senders (/guides/sms-senders) for the full reach table.
### No opens or clicks
Carriers report three things: sent (accepted by the carrier), delivered (confirmed on the handset), and failed, with a carrier reason when there is one. There is no open tracking, no click tracking, and no engagement rate to request; the SMS dashboard and the per-message page show delivery, not engagement. If you need a click signal, link to a page you control and measure there.
### Brand registration comes first
Email setup is DNS records on a domain you own. SMS setup starts with a brand: your legal entity, website, and a contact email on that website's domain, which carriers or regulators approve before they will hand you a production number or a registered Sender ID. The brand page lists exactly which fields are still missing, and GET /v1/sms/brands returns the same status and missing list for scripts and agents. Creating a brand is free, from the dashboard or POST /v1/sms/brands; registering it with carriers and requesting numbers are paid steps a person confirms in the dashboard. See SMS Brands (/guides/sms-brands).
### Skip reasons: one vocabulary
Email tells you about bounces and complaints after the fact. SMS tells you before you send who will be skipped and why, and every surface uses the same seven words: campaign preflight, the campaign page, the message log, error.code on GET /v1/sms/{id}, and reason on the sms.suppressed webhook.
- out_of_reach: no sender on the message or campaign covers the recipient's country. Fix: request a sender for that country.
- unsupported_country: inside reach, but Unitpost has no compliance rule for the country yet (transactional still sends), or SMS to that country is paused for now. A pause covers every message, transactional included: a new send is refused with 403 `unsupported_country`, and a queued one is skipped without charge. It resumes on its own.
- no_phone: the contact has no SMS number. Fix: add one to the contact.
- opted_out: STOP, the suppression list, or an unreachable number. The recipient re-opts in by texting START or from the preference page.
- no_marketing_consent: marketing without express consent on record. Fix: record consent the recipient actually gave.
- quiet_hours_deferred: held for the recipient's local window. Delayed, not dropped; nothing to fix.
- sender_not_active: the sender lost approval or was released. Fix or replace the sender, then resume.
> **Where to go next:** Set up the identity in SMS Brands (/guides/sms-brands) and SMS Senders (/guides/sms-senders), write a template in SMS Templates (/guides/sms-templates), then send one message from SMS Messages (/guides/sms-messages) or a segment from SMS Campaigns (/guides/sms-campaigns).
## SMS Messages
> Send transactional and marketing SMS, and follow each message's delivery live.
The SMS log shows every message your workspace has sent or received, live — queued, sent, delivered, or failed, with the carrier's failure reason when there is one. Each row opens a per-message page with the sender number, segments billed, and the carrier's event timeline. Billing is per segment: 160 GSM-7 characters fit one segment (153 each when a message spans several); Unicode messages fit 70/67. The composer shows the segment count as you type. Sending needs a paid plan. New to the channel? Start with SMS vs email (/guides/sms-vs-email).
Marketing SMS is held to a stricter bar than email: the recipient must have prior express consent on record, and sends respect recipient-local quiet hours (a marketing message queued at midnight is deferred to the morning window, never dropped and never sent at 2am). Transactional messages — receipts, alerts, verification codes — are not window-gated.
> **STOP always wins:** When a recipient replies STOP, the carrier and Unitpost both record the opt-out immediately. Further sends to that number are suppressed on the SMS channel — it never touches their email subscription, and vice versa.
### Replies
Your toll-free, 10DLC and Canadian numbers receive replies; Sender IDs are one-way (the Phone numbers page marks which is which). A reply shows in the log as Inbound with the text, the number it came from, and a link to your message it answers when the carrier says which. Reply opens the composer addressed to that person from the same number. Each text you receive costs 1¢ in the US and Canada, whatever its length; the message page shows what it was billed. Each one also fires the `sms.received` webhook, which is how an app or an AI agent answers in its own words; see the SMS API's Start page.
Keyword replies are handled for you. STOP, CANCEL, END, QUIT, UNSUBSCRIBE and the other opt-out words opt the number out; START and UNSTOP opt it back in; HELP and INFO get your registered help text. Only a text that is just the keyword counts, so "please stop texting me" is a normal reply. A one-word answer such as "Cancel" is an opt-out, so phrase questions so that a single word answer can't be one ("Reply 1 to keep, 2 to move").
## SMS Brands
> The business identity carriers register before they will approve a phone number.
Before you can get a production phone number, carriers need to know who is sending. A brand is that record: your legal entity, your website, and a contact email on that website's domain. It is the first step in SMS setup and every number you request is tied to one. Brand registration needs a paid plan. New to the channel? Start with SMS vs email (/guides/sms-vs-email).
One brand covers both number types, so you fill this in once. A toll-free number needs the brand complete; a 10-digit local number additionally needs the brand verified with carriers, which adds a tax ID and a one-time fee. If your brand is missing something, the brand page lists exactly which fields are still required.
The contact email has to be on the same domain as the brand website. Carriers use it to confirm the business is really yours, and a mismatch is one of the most common rejection reasons, so we check it up front rather than letting a submission fail days later.
A brand is registered with US carriers, and the US numbers it backs (toll-free and 10-digit local) deliver to US recipients only — see the reach table in SMS Senders. A Canadian local number on the same brand reaches Canada (no registration; the brand is its owner, not a filing). Reaching other countries needs a Sender ID enabled for that country (free; see SMS Senders); a few countries still need a locally issued number, which is not available in Unitpost yet.
### Company documents
Some countries' regulators want to see company paperwork before they approve a Sender ID: a registration certificate, a letter of authorization (sometimes one per mobile carrier), the signatory's ID, a business number such as an Australian ABN. Those are facts about your company, not about one country, so they live on the brand — in the collapsed Company documents section at the bottom of the brand page, or in the optional last step when you create a brand. Upload each once and every country that needs it can use it.
- Each row is one document type. It shows the file on record (name, date), lets you download, replace or remove it, and the flags of the countries it unlocks.
- PDF, PNG or JPEG, up to 3 MB per file. If a file is bigger, compress it (most PDF tools have a "reduce file size" option).
- Documents are not locked when a brand is approved: you can add an Irish certificate after your US 10DLC brand is verified.
- A document that a registration under review used can't be removed until the regulator answers; replacing it is how you respond to a correction request.
- If one country wants a different version of a document other countries accepted (the Philippines asks for your articles of incorporation, say), upload a version for that country only: Only for in Fix & resubmit, or Upload a version for one country in the document's menu. Every other country keeps the shared file, and a registration that is already approved keeps the file it was approved with.
> **Privacy:** Documents are stored encrypted in private storage scoped to your workspace and are only ever sent to the carrier registry of a country you request a Sender ID for, as part of that registration. Each registration records exactly which version of each document it submitted. Unitpost does not review documents itself — the regulator does, and any correction request shows up on the Sender ID with the document it concerns.
> **An approved brand is locked:** Once carriers approve a brand, or a phone number is attached to it, its details are locked — editing them would invalidate what was approved. If your business details change, or you are sending on behalf of a different company or website, create a second brand instead. If carriers ask for a correction, the brand unlocks for exactly that.
> **Check readiness from your code:** GET /v1/sms/brands returns each brand's status plus a `missing` list, so a script or agent can report whether SMS is ready without opening the dashboard. A script or agent can also create and edit a brand (POST and PATCH /v1/sms/brands, free); registering it with carriers is a paid step a person confirms in the dashboard.
> **Entered once, reused for every registration:** Your brand's legal name, address, website, contact, industry and company ID are what carriers and regulators verify — for toll-free verification, 10DLC brand registration, and every Sender ID registration abroad. Unitpost maps the same brand onto each regulator's form, so a UK or Spanish Sender ID registration asks only for what that country needs beyond the brand. The brand also carries its one Sender ID string (defaults to the brand name) and its company documents; country-specific answers stay on the registration, never on the brand.
## SMS Senders
> The phone numbers and Sender IDs your SMS messages originate from.
SMS needs an origination identity the way email needs a verified domain. Start with the sandbox sender: it works immediately but only delivers to verified test numbers (a carrier rule for unverified senders). For production, New sender asks which countries you'll text and lists what each one needs: a US toll-free or 10-digit local number for US recipients, a Canadian local number for Canadian recipients, or an alphanumeric Sender ID for everywhere else. All of them need a brand first — see SMS Brands. Requesting a number or a Sender ID needs a paid plan. New to the channel? Start with SMS vs email (/guides/sms-vs-email).
Each number also carries a use case: what you send and how people opt in. Carriers read it to decide whether to approve the number, so it is submitted with the request and shown under the number afterwards. If you already have a number, the next one can reuse those answers instead of retyping them.
A sender moves through registration and verification states before it's active, and its status is re-checked at send time — a suspended or released number can't silently keep sending. The monthly number lease and the one-time carrier fees are quoted before you confirm, then taken from the sending wallet. See Wallet (/guides/wallet).
### Where a number can send
Every sending identity has a reach: the countries it can deliver to. It is a carrier rule, not a setting, and it depends on the kind of identity. Unitpost shows it as a flag next to every number (hover it for the list) and states it in the New number menu and both wizards before you pay for anything.
- Toll-free number — US only. International sends from a toll-free number are routed best-effort through a shared identity, losing your brand and with no delivery guarantee; Unitpost does not forward them.
- 10-digit local (10DLC) number — US only, by carrier rule.
- Canadian local number — Canada only. A 10-digit number issued in Canada (a long code), requested by picking Canada in New sender: no brand or campaign registration, active within minutes, a small monthly fee. A US toll-free number never reaches Canada, so sending to both countries takes both numbers. Replies (STOP, ARRET, HELP) come back to Unitpost like a 10DLC number's do.
- Sandbox — US only, and only to your verified test destinations.
- Alphanumeric Sender ID — the country it is enabled for. Enable one Sender ID per country; US, Canada and China do not support Sender IDs at all.
- Short codes and other countries' long codes — the country they are issued in. Not available in Unitpost yet.
### Sender IDs — your name instead of a number
An alphanumeric Sender ID is a name of up to 11 letters, digits or hyphens (at least one letter; no leading or trailing hyphen) that appears on the recipient's phone instead of a number — "Acme" rather than +44… . Sender IDs are free: there is no purchase and no monthly fee. Each brand has one Sender ID string (derived from the brand name; change it on the brand), and you enable it per destination country by picking those countries in New sender (or Add countries on the Sender ID). Each enabled country appears as its own row in the numbers table with the country flag.
- Instant countries (most of Europe, Latin America, Asia-Pacific and Africa): the Sender ID is requested directly and is active within a minute.
- Registration-required countries (for example the United Kingdom, Finland, Spain, Singapore, Belarus, Kazakhstan): the regulator reviews a registration first. It is composed from your brand and use case; a few countries ask for one or two extra answers — Spain's alias holder details — and only those are asked. Singapore's letter of authorization comes from the brand's company documents. Approval takes days to weeks; the row shows the regulator's status and, if it comes back, what to fix and a Resubmit button.
- Countries whose registration needs company documents (for example the UAE, Ireland, Saudi Arabia, Turkey, Vietnam, the Philippines, Indonesia) are unlockable: the dialog shows which documents each one needs, and the moment your brand holds them the country becomes requestable. Missing ones are named — "needs: registration certificate" — with a link to the brand's Company documents section. Documents are sent to that country's registry as part of the registration, and a correction request about a document shows on the Sender ID with a Replace link. India needs a separate DLT registration and is not available.
> **Unregistered Sender IDs get rewritten:** In a country that requires registration, an unregistered Sender ID is replaced by the carrier with "NOTICE" ("LIKELY-SCAM" in Singapore and Ireland) and the message may not be received. Unitpost therefore never sends from a Sender ID in such a country until its registration is approved — the sender stays pending until then.
> **Sender IDs are one-way:** Recipients can't reply to a Sender ID, so there is no STOP by text. Marketing sent from a Sender ID must still be opt-out-able: include a link to the hosted preference page in the message, or record opt-outs through POST /v1/contacts/{id}/sms-consent. Consent rules are unchanged — marketing needs express consent on record regardless of the identity.
> **Out-of-reach recipients are skipped, not attempted:** A message to a recipient outside the sender's reach is not sent: it lands as suppressed with the reason ("This number sends to US recipients only") and is never billed. The country comes from the phone number itself — a Canadian or Caribbean +1 number is labelled CA or its island, not US. Campaigns tell you how many recipients this affects before you send, and the public API returns each number's reach (GET /v1/sms/numbers → reach).
> **Keep marketing and OTP apart:** Carriers degrade deliverability for numbers that mix bulk marketing with time-sensitive transactional traffic. When you start sending campaigns, add a dedicated marketing sender so a blast can never slow your verification codes.
## SMS Dashboard
> Your SMS sending at a glance — volume and delivery over the last 30 days.
The SMS dashboard summarizes the channel's last 30 days: how many messages went out, how many the carriers confirmed delivered, and how many failed or were suppressed. It's the channel-scoped counterpart of the email dashboard — same idea, SMS-honest numbers (no opens or clicks; carriers don't report them). New to the channel? Start with SMS vs email (/guides/sms-vs-email).
From here, jump into the message log for per-message delivery detail, or into Senders to provision an origination number.
## SMS Activity
> The workspace activity stream, scoped to the SMS channel.
SMS Activity is the same workspace activity stream you know from email, locked to channel messages: sends, delivery receipts, retries, and opt-outs (STOP). Use it to answer "what happened on SMS recently" without scanning the full cross-resource feed. New to the channel? Start with SMS vs email (/guides/sms-vs-email).
## SMS Templates
> Reusable plain-text messages with {{variables}}, published before use.
SMS templates are plain text with the same {{variable}} merge fields as email templates — {{first_name}} and your custom contact fields resolve per recipient at send time, and a send fails closed rather than shipping a literal token. The editor shows the live segment count as you type (160 GSM-7 characters per segment; 70 for Unicode). New to the channel? Start with SMS vs email (/guides/sms-vs-email).
Templates start as drafts; publish one to use it in sends and campaigns. For marketing messages, end with an opt-out hint like "Reply STOP to opt out" — STOP is honored automatically either way.
## SMS Topics
> Per-category subscription preferences, tracked separately from email.
SMS topics work like email topics — contacts opt in and out per category — but the preferences are tracked per channel: "Product updates" can be on for email and off for SMS for the same contact. A campaign tied to a topic skips recipients who opted out of it. New to the channel? Start with SMS vs email (/guides/sms-vs-email).
STOP always wins on top: a recipient who replies STOP is suppressed on the SMS channel entirely, regardless of topic preferences — and it never touches their email subscription.
## SMS Campaigns
> Bulk SMS to a segment — consent-gated, quiet-hours aware, paced per sender.
An SMS campaign sends a published SMS template to every reachable contact in a segment. Marketing SMS is held to the strict bar: only recipients with prior express consent on record receive it, sends respect the recipient-local quiet-hours window (a blast fired at midnight waits for the morning window — deferred, never dropped), and delivery is paced to your sender's carrier throughput. Sending a campaign needs a paid plan. New to the channel? Start with SMS vs email (/guides/sms-vs-email).
### Who will actually get it
As soon as you pick a segment and a sender, the form checks every contact in the segment and tells you the outcome before the draft exists: how many will be reached, and how many will be skipped and why — outside the sender's reach (with the countries), without marketing consent, opted out, or without a phone number. "See who" lists them with a link to each contact. The send confirmation repeats the count, so the number on the button is the number that goes out.
Reach is decided per recipient from the phone number's country and the sender's reach (see SMS Senders). A US toll-free or 10DLC number reaches US recipients only, so a UK contact in the segment is skipped with that reason rather than attempted; the skip is never billed. Each skipped country comes with what would fix it and, when Unitpost can provide it, a link that opens the sender wizard on that country — "Request a United Kingdom sender →", "Request a Canadian local number →" — so the fix is one click away. A country that is not available yet says so plainly. Reach is not the only gate: a marketing campaign is also held to the countries Unitpost has a quiet-hours rule for (the US and Canada today), so a recipient a Sender ID reaches in a country without one is reported up front as unsupported_country rather than skipped after you press Send.
### Skip reasons — one vocabulary
Every place that says a recipient was skipped uses the same seven words, so what the form warned about is what the campaign page, the message log, the API and your webhooks report: out_of_reach (no sender covers the recipient's country), unsupported_country (Unitpost has no compliance rule for the country yet), no_phone (the contact has no SMS number), opted_out (STOP, suppression list, or unreachable), no_marketing_consent (marketing without express consent), quiet_hours_deferred (held for the recipient's local window — delayed, not dropped) and sender_not_active (the sender lost approval). Each comes with a sentence saying what would fix it. The public API returns the code as error.code on GET /v1/sms/{id}; webhooks send it as reason on sms.suppressed.
> **Budget pauses, never surprises:** If your messaging balance runs out mid-campaign, the campaign pauses — remaining recipients are untouched and you get an email saying why. The same happens if the sender number loses its approval before the send starts. Billing is per segment actually sent.
### Following a campaign
Click a campaign to open its page. It shows the sender, template, segment and topic it was built from, then the carrier's numbers: recipients admitted, sent (accepted by the carrier), delivered (confirmed on the handset), failed, suppressed (no consent, STOP, blocklist, or outside the sender's reach), deferred (held for quiet hours), and segments billed. Below that, every recipient is listed with the carrier's status and the reason where one was reported — a failed render, a missing opt-in, a carrier rejection — and the campaign's own activity log. The page updates live while a campaign sends.
From the campaign page you can send a draft, duplicate a campaign into a new draft, cancel one that is scheduled, sending or paused, and delete drafts and finished campaigns. Every send, delivery, failure, opt-out and inbound reply on the channel also appears under SMS activity (/dashboard/sms/activity).
# Email design suite
## Quickstart
> Install the package, write an email in React, and send it.
Write your email as a React component, turn it into HTML with `render()`, and send it with any provider. The HTML works in Outlook, Gmail, and Apple Mail. You don't need a Unitpost account.
### 1. Install
**Terminal**
```bash
npm install @unitpost/email zod
```
React and Next.js apps already have `react`. In a plain Node project, install it too. `zod` is a peer dependency. Node 18 or newer.
Or let `init` set the project up: it adds a starter email to `emails/` and an `email:dev` script to `package.json`, then prints the install command for anything missing. It keeps files and scripts that are already there.
**Terminal**
```bash
npx @unitpost/email init
```
### 2. Create an email
An email is a React component made of the library's components. Pass data in as props, like any other component.
`emails/welcome.tsx`
```tsx
import { Section, Heading, Text, Button } from "@unitpost/email/react";
export function WelcomeEmail({ name }: { name: string }) {
return (
Welcome, {name}Thanks for signing up. Confirm your email to get started.
);
}
```
### 3. Render and send
`render()` returns an HTML string. Send it with the Unitpost SDK, or with the provider you already use (see Integrations (/components#integrations)).
`send.tsx`
```tsx
import { render } from "@unitpost/email/react";
import { Unitpost } from "unitpost";
import { WelcomeEmail } from "./emails/welcome";
const unitpost = new Unitpost(process.env.UNITPOST_API_KEY);
await unitpost.email.send({
from: "Acme ",
to: "ada@example.com",
subject: "Welcome to Acme",
html: render(),
});
```
### 4. Preview while you build
Run the preview server next to your app. It lists every email in `emails/`, reloads when a file changes, and shows the email at desktop and mobile width, in light and dark, with its HTML, plain text and design checks. Preview props come from `Welcome.PreviewProps`. It bundles your emails with esbuild, so add it once: `npm install -D esbuild`. After `init`, `npm run email:dev` starts the same server.
**Terminal**
```bash
npx @unitpost/email dev
```
To see an email in a real inbox, set `UNITPOST_API_KEY` and `UNITPOST_FROM` and use Send test.
> **Using a coding agent?:** Run `npx skills add unitpostcom/email` and your agent learns every component and layout.
## Rendering
> Which function to call, what it takes, and what it returns.
Most apps only need `render()`. The other functions are for working without React, or for building editors and tools on top of the library.
| Function | Takes | Returns | Use it when |
| --- | --- | --- | --- |
| `render()` | A React element | An HTML string | You write emails in React. Import it from `@unitpost/email/react`. |
| `parseTsx(source)` | The same tags, as a string | An `EmailDocument` (JSON) | You don't use React, or the markup comes from the editor or an agent. |
| `renderToHtml(doc)` | An `EmailDocument` | An HTML string | You have a document from `parseTsx()`, `fromJsx()` or the API. |
| `renderToText(doc)` | An `EmailDocument` | A plain-text string | You want a plain-text part to send with the HTML. |
| `fromJsx()` | A React element | An `EmailDocument` (JSON) | You want to store an email as data, or turn it into plain text. |
| `printTsx(doc)` | An `EmailDocument` | Source code, as a string | You want to show a document as code. `printFragmentTsx()` does the same for a layout. |
`render()` is `fromJsx()` followed by `renderToHtml()`, and it's synchronous. It runs wherever your code runs: a server, a script, a serverless function. It doesn't use React DOM, so email components can't be mounted on a web page.
### Variables
Both `render()` and `renderToHtml()` take an optional second argument that fills `{{tokens}}` when you render. In JSX, write a token as a string, like `{"{{first_name}}"}`. Values are HTML-escaped, and a token without a value stays as written.
## Without React
> Write the same tags as a string.
Not using React? Write the same tags as a string, parse it with `parseTsx()`, and render it with `renderToHtml()`. It's the format the visual editor and agents use, and it needs no build step.
**welcome.ts**
```ts
import { parseTsx, renderToHtml } from "@unitpost/email";
const doc = parseTsx(`
Welcome, {{first_name}}
`);
const html = renderToHtml(doc, { first_name: "Ada" });
```
Props are the same as in React. In a string you can also write them in kebab-case (`padding-y`), which is how the editor prints them.
## Integrations
> Send the HTML with Unitpost or the provider you already use.
`render()` returns plain HTML, so anything that sends HTML email works. Each example sends the `WelcomeEmail` from the Quickstart (/components#quickstart).
### Unitpost
Send with the Unitpost SDK. Create an API key under Settings → API keys.
`send.tsx`
```tsx
// npm install unitpost
import { render } from "@unitpost/email/react";
import { Unitpost } from "unitpost";
import { WelcomeEmail } from "./emails/welcome";
const unitpost = new Unitpost(process.env.UNITPOST_API_KEY);
await unitpost.email.send({
from: "Acme ",
to: "ada@example.com",
subject: "Welcome to Acme",
html: render(),
});
```
### Unitpost SMTP
Prefer SMTP? Point any SMTP client at the Unitpost relay. The username is always `unitpost` and the password is an API key with `emails:send`. This example uses Nodemailer; the SMTP docs (/docs/smtp) cover other frameworks.
`send.tsx`
```tsx
// npm install nodemailer
import nodemailer from "nodemailer";
import { render } from "@unitpost/email/react";
import { WelcomeEmail } from "./emails/welcome";
const transporter = nodemailer.createTransport({
host: "smtp.unitpost.com",
port: 587,
secure: false, // STARTTLS
auth: { user: "unitpost", pass: process.env.UNITPOST_API_KEY },
});
await transporter.sendMail({
from: "Acme ",
to: "ada@example.com",
subject: "Welcome to Acme",
html: render(),
});
```
### AWS SES
Send with Amazon SES through the AWS SDK.
`send.tsx`
```tsx
// npm install @aws-sdk/client-sesv2
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";
import { render } from "@unitpost/email/react";
import { WelcomeEmail } from "./emails/welcome";
const ses = new SESv2Client({ region: "us-east-1" });
await ses.send(
new SendEmailCommand({
FromEmailAddress: "hello@acme.com",
Destination: { ToAddresses: ["ada@example.com"] },
Content: {
Simple: {
Subject: { Data: "Welcome to Acme" },
Body: { Html: { Data: render() } },
},
},
}),
);
```
### Brevo
Send with the Brevo SDK (v6).
`send.tsx`
```tsx
// npm install @getbrevo/brevo
import { BrevoClient } from "@getbrevo/brevo";
import { render } from "@unitpost/email/react";
import { WelcomeEmail } from "./emails/welcome";
const brevo = new BrevoClient({ apiKey: process.env.BREVO_API_KEY! });
await brevo.transactionalEmails.sendTransacEmail({
sender: { name: "Acme", email: "hello@acme.com" },
to: [{ email: "ada@example.com" }],
subject: "Welcome to Acme",
htmlContent: render(),
});
```
### Cloudflare
Send from a Cloudflare Worker with the Email Service `send_email` binding. Onboard your sending domain to Email Service first. `render()` needs Node.js compatibility, which Workers turn on by default from compatibility date 2026-08-04; on older dates, add the `nodejs_compat` flag.
`send.tsx`
```tsx
// wrangler.jsonc
// { "compatibility_flags": ["nodejs_compat"], "send_email": [{ "name": "EMAIL" }] }
import { render } from "@unitpost/email/react";
import { WelcomeEmail } from "./emails/welcome";
export default {
async fetch(request: Request, env: Env): Promise {
const { messageId } = await env.EMAIL.send({
from: "hello@acme.com",
to: "ada@example.com",
subject: "Welcome to Acme",
html: render(),
});
return Response.json({ messageId });
},
};
```
### Mailgun
Send with the Mailgun client.
`send.tsx`
```tsx
// npm install mailgun.js form-data
import formData from "form-data";
import Mailgun from "mailgun.js";
import { render } from "@unitpost/email/react";
import { WelcomeEmail } from "./emails/welcome";
const mailgun = new Mailgun(formData).client({
username: "api",
key: process.env.MAILGUN_API_KEY!,
});
await mailgun.messages.create("mg.acme.com", {
from: "Acme ",
to: ["ada@example.com"],
subject: "Welcome to Acme",
html: render(),
});
```
### SendGrid
Send with the SendGrid mail client.
`send.tsx`
```tsx
// npm install @sendgrid/mail
import sgMail from "@sendgrid/mail";
import { render } from "@unitpost/email/react";
import { WelcomeEmail } from "./emails/welcome";
sgMail.setApiKey(process.env.SENDGRID_API_KEY!);
await sgMail.send({
from: "hello@acme.com",
to: "ada@example.com",
subject: "Welcome to Acme",
html: render(),
});
```
### Postmark
Send with the Postmark client.
`send.tsx`
```tsx
// npm install postmark
import { ServerClient } from "postmark";
import { render } from "@unitpost/email/react";
import { WelcomeEmail } from "./emails/welcome";
const postmark = new ServerClient(process.env.POSTMARK_SERVER_TOKEN!);
await postmark.sendEmail({
From: "hello@acme.com",
To: "ada@example.com",
Subject: "Welcome to Acme",
HtmlBody: render(),
});
```
### Nodemailer
Send through any SMTP server with Nodemailer.
`send.tsx`
```tsx
// npm install nodemailer
import nodemailer from "nodemailer";
import { render } from "@unitpost/email/react";
import { WelcomeEmail } from "./emails/welcome";
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 587,
secure: false, // STARTTLS
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD },
});
await transporter.sendMail({
from: "Acme ",
to: "ada@example.com",
subject: "Welcome to Acme",
html: render(),
});
```
### AutoSend
Send through the AutoSend REST API.
`send.tsx`
```tsx
import { render } from "@unitpost/email/react";
import { WelcomeEmail } from "./emails/welcome";
await fetch("https://api.autosend.com/v1/mails/send", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.AUTOSEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: { email: "hello@acme.com", name: "Acme" },
to: { email: "ada@example.com", name: "Ada" },
subject: "Welcome to Acme",
html: render(),
}),
});
```
## Design suite surfaces
> The component library, builder, gallery, and playground share one document format. The npm package works standalone — no Unitpost account needed to render email HTML.
- Landing: https://www.unitpost.com/email ([Markdown](https://www.unitpost.com/email.md))
- Component reference (live previews + props): https://www.unitpost.com/components#components
- Pre-built layouts (headers, heroes, CTAs, footers as copyable TSX): https://www.unitpost.com/components#layouts
- Email builder (visual + code): https://www.unitpost.com/templates
- Template gallery (free to preview/copy): https://www.unitpost.com/templates/gallery
- Playground (render in the browser, no install): https://www.unitpost.com/playground
- Skill: `npx skills add unitpostcom/email`
- Install: `npm install @unitpost/email zod` (MIT license, Node 18+)
- Source: https://github.com/unitpostcom/email
## Email Components and Layouts
> Email component library that works in Outlook, Gmail, and Apple Mail.
MIT. No Unitpost account. No API key. `zod` is a peer (`^4`). Node 18+. Pass the HTML string to any sender.
## Install
```bash
npm install @unitpost/email zod
```
React and Next.js apps already have `react`. In a plain Node project, install it too.
## Quick start
Write the email as a React component:
```tsx
import { Section, Heading, Text, Button } from "@unitpost/email/react";
export function WelcomeEmail({ name }: { name: string }) {
return (
Welcome, {name}Thanks for signing up. Confirm your email to get started.
);
}
```
`render()` turns it into an HTML string. Send it with Unitpost or any other provider (Nodemailer, SES, SendGrid, Postmark, Mailgun, Brevo, Cloudflare, AutoSend):
```tsx
import { render } from "@unitpost/email/react";
const html = render();
```
No React? Write the same tags as a string: `renderToHtml(parseTsx("…"))`.
Coding agents: `npx skills add unitpostcom/email` teaches the components and layouts. No account.
Unresolved `{{tokens}}` render literally. Values are HTML-escaped; `javascript:` URLs are dropped.
## Surfaces
- [Component reference](https://www.unitpost.com/components) — props tables and live previews ([Markdown](https://www.unitpost.com/components.md))
- [Integrations](https://www.unitpost.com/components#integrations) — send the HTML with Unitpost, Nodemailer, SES, SendGrid, Postmark, Mailgun, Brevo, Cloudflare, AutoSend
- [Pre-built layouts](https://www.unitpost.com/components#layouts) — headers, heroes, CTAs, footers as copyable React code
- [Playground](https://www.unitpost.com/playground) — render in the browser, no account
- [Template gallery](https://www.unitpost.com/templates/gallery) — ready-to-send transactional and marketing samples ([Markdown](https://www.unitpost.com/templates/gallery.md))
- [GitHub](https://github.com/unitpostcom/email)
- [npm](https://www.npmjs.com/package/@unitpost/email)
- HTML landing: https://www.unitpost.com/email
## FAQ
### What is @unitpost/email?
An MIT-licensed email component library that works in Outlook, Gmail, and Apple Mail. Write emails as React components with Heading, Text and Button from @unitpost/email/react, and call render() to get the HTML. Send it with any provider. No Unitpost account and no API key. Not using React? parseTsx takes the same tags as a string.
### How do I install the email component library?
Run npm install @unitpost/email zod. React and Next.js apps already have react; in a plain Node project, install it too. zod is a peer dependency, and Node 18 or newer is required. For coding agents, run npx skills add unitpostcom/email to teach them the components and layouts.
### Do I need a Unitpost account to use it?
No. The package renders HTML locally and works fully offline. The playground and template gallery are also free without an account. Unitpost can send the HTML if you want a sender; any other API or SMTP relay works too.
### How do I compose and render an email?
Write the email as a React component with Section, Heading and Button from @unitpost/email/react, then call render() to get an HTML string. Pass that string to Unitpost, Nodemailer, AWS SES, SendGrid, Postmark, Mailgun, Brevo, Cloudflare, AutoSend or any other provider. Without React, write the same tags as a string and call parseTsx, then renderToHtml. className compiles to inline CSS, and {{tokens}} are filled in when you render.
### How do I style emails — Tailwind, CSS, or something else?
All of those, as long as they compile. Tailwind-style className on any block (class and tw work too) becomes inline CSS at render; responsive and pseudo variants go in a