# Unitpost — full documentation > Unitpost is an email platform for developers: send transactional and marketing email, 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: 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). Most of the dashboard exists to make the campaign path safe and repeatable, and to give you visibility into both. ### 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 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 — 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_..." ``` **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" ``` **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). ## 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 preset — Full, Sending, or Read only — or narrow it further. Copy the key when shown. > **Scope keys tightly:** A server that only sends mail should use a Sending key. Keys can never manage other keys, team, or billing. ### 2. Verify a sending domain Add your domain under Domains (/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": "

Thanks for signing up!

" }' ``` **Node.js** ```ts import { Unitpost } from "unitpost"; const unitpost = new Unitpost(process.env.UNITPOST_API_KEY); const { data, error } = await unitpost.email.send({ from: "Acme ", to: "customer@acme.com", subject: "Welcome aboard", html: "

Thanks for signing up!

", }); if (error) throw error; console.log(data.id); ``` **Python** ```python from unitpost import Unitpost unitpost = Unitpost() # reads UNITPOST_API_KEY result = unitpost.email.send({ "from": "Acme ", "to": "customer@acme.com", "subject": "Welcome aboard", "html": "

Thanks for signing up!

", }) print(result.data["id"]) ``` **Ruby** ```ruby require "unitpost" unitpost = Unitpost::Client.new # reads UNITPOST_API_KEY result = unitpost.email.send({ from: "Acme ", to: "customer@acme.com", subject: "Welcome aboard", html: "

Thanks for signing up!

", }) puts result.data["id"] ``` **PHP** ```php email->send([ 'from' => 'Acme ', 'to' => 'customer@acme.com', 'subject' => 'Welcome aboard', 'html' => '

Thanks for signing up!

', ]); if ($result->error) { throw $result->error; } echo $result->data['id']; ``` **Laravel** ```php use Unitpost\Laravel\Facades\Unitpost; $result = Unitpost::email()->send([ 'from' => 'Acme ', 'to' => 'customer@acme.com', 'subject' => 'Welcome aboard', 'html' => '

Thanks for signing up!

', ]); ``` **Go** ```go data, err := client.Email.Send(ctx, map[string]any{ "from": "Acme ", "to": "customer@acme.com", "subject": "Welcome aboard", "html": "

Thanks for signing up!

", }) ``` **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. > **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. Author templates in the visual editor, or in your app with `import { Heading, render } from "@unitpost/email/react"` and POST `{ name, html: render(), subject }`. Manage them under Templates (/dashboard/email/templates). 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. 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. ### 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. ## 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, batches appear under Emails → 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": "

…

" }, { "from": "you@yourdomain.com", "to": "b@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: "

…

" }, { from: "you@yourdomain.com", to: "b@example.com", subject: "Hi", html: "

…

" }, ], }); // Cancel the whole batch before it sends await unitpost.email.cancelBatch(data.id); ``` **Python** ```python from unitpost import Unitpost unitpost = Unitpost() # reads UNITPOST_API_KEY # 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": "

…

"}, {"from": "you@yourdomain.com", "to": "b@example.com", "subject": "Hi", "html": "

…

"}, ], }).data # Cancel the whole batch before it sends unitpost.email.cancel_batch(batch["id"]) ``` **Ruby** ```ruby require "unitpost" unitpost = Unitpost::Client.new # reads UNITPOST_API_KEY # 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: "

…

" }, { from: "you@yourdomain.com", to: "b@example.com", subject: "Hi", html: "

…

" }, ], }).data # Cancel the whole batch before it sends unitpost.email.cancel_batch(batch["id"]) ``` **PHP** ```php require 'vendor/autoload.php'; use Unitpost\Client; $unitpost = new Client(); // reads UNITPOST_API_KEY // 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" => "

…

"], ["from" => "you@yourdomain.com", "to" => "b@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" => "

…

"], ["from" => "you@yourdomain.com", "to" => "b@example.com", "subject" => "Hi", "html" => "

…

"], ], ])->data; // Cancel the whole batch before it sends Unitpost::email()->cancelBatch($batch["id"]); ``` **Go** ```go import ( "context" "github.com/unitpostcom/unitpost-go" ) client := unitpost.New() // reads UNITPOST_API_KEY // Scheduled batch — fires as a unit, cancelable as a unit batch, err := client.Email.Batch(ctx, map[string]any{ "scheduled_at": "2026-07-01T15:00:00Z", "emails": []any{ map[string]any{"from": "you@yourdomain.com", "to": "a@example.com", "subject": "Hi", "html": "

…

"}, map[string]any{"from": "you@yourdomain.com", "to": "b@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", "

…

"), java.util.Map.of("from", "you@yourdomain.com", "to", "b@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": "

…

"}, {"from": "you@yourdomain.com", "to": "b@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 under Emails → Batches (/dashboard/email/emails?status=batches). > **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. > **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. ## 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); no suppression, and delivery is retried. 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. > **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 ### Payload Every delivery is the same JSON envelope: a type (the dotted event name), an ISO created_at timestamp, 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", "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). ```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. previous_status is populated only on domain.updated (a status transition, e.g. PENDING → VERIFIED); it's null otherwise. ```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. ```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 } } ``` ### 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. - Publish MX records: Add the generated MX records to your DNS provider to route mail for this domain to us. - 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 containing the parsed subject, sender, recipient, and the body in both text and HTML. Attachments are extracted and made available for download via the API. # 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 and publish the MX records, and we'll parse incoming mail and POST it to your webhook as clean JSON. See the Inbound email (/docs#guide-inbound) guide for details. ## Templates > Author reusable email content for campaigns and the API. Templates are reusable email designs — a subject and a body built from the component library (or raw 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. > **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. ### 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 > **Some blocks are read-only:** Addresses on the Unitpost-wide list (reputation/abuse protection) appear with a “Platform” badge and can't be removed from your workspace. They're shown so you understand why a send was skipped — 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. ### 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. ### 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. ## 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. Create a key, choose a scope preset — Full, Sending, or Read only — and optionally narrow it to specific capabilities. The secret is shown once at creation; store it somewhere safe immediately. > **Scope tightly:** Give each integration the least it needs — a server that only sends mail gets a Sending key, not Full. Keys can never hold privileged capabilities (managing other keys, the team, the workspace, or billing); those stay in the dashboard behind a human login. Revoke a key the moment it's no longer needed or may have leaked; revocation is immediate. Pair keys with Webhooks to build a full integration: keys for outbound API calls, webhooks for inbound event notifications. ## 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. ## 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. # Email design suite ## Introduction > Email component library that works in Outlook, Gmail, and Apple Mail. Email component library that works in Outlook, Gmail, and Apple Mail. `@unitpost/email` (/email) is the same renderer the send engine uses — preview here is the email recipients get. Author as React components, as a TSX string, or visually in the template editor (/dashboard/email/templates). All three produce the same document. > **React when you want it:** `import { Heading, Button, render } from "@unitpost/email/react"` is the react-email-shaped path — types and JSX in your editor. The visual editor and agents use the same tags as a string (`parseTsx`) so the document can round-trip. Neither path is a second renderer. Style with Tailwind-style `className` (compiled to inline CSS), extra declarations via `custom-css`, or the component props. Recipients get the compiled markup — not a stylesheet they have to load. Insert dynamic data anywhere with `{{variable}}` — it's substituted at send time from the values you pass to the API, a contact's fields, or a campaign default. ## Installation > Add the package and render your first email in a few minutes. You don't need to install anything to use the components in the in-app editor — open any template, switch to Code mode, and start composing. For a coding agent, one Skill command. For your own codebase, the npm package. ### Skill (for agents) Teaches the catalog, layouts, and renderer. No account. ```bash npx skills add unitpostcom/email ``` ### 1. Install the package Install `@unitpost/email` (plus its `zod` peer dependency) — it ships the component types, the parser, and the same `renderToHtml` renderer the send engine uses. Source: github.com/unitpostcom/email (https://github.com/unitpostcom/email). ```bash # npm npm install @unitpost/email zod # pnpm pnpm add @unitpost/email zod # yarn yarn add @unitpost/email zod # Requires Node.js 18+. ``` ### 2. Write an email template In a React app, import the catalog and call `render`. Without React (or in the editor's Code mode), the same tags are a string passed to `parseTsx`. ```ts import { Section, Heading, Text, Button, render } from "@unitpost/email/react"; export function Welcome({ name }: { name: string }) { return (
Welcome, {name} Thanks for joining. Confirm your email to get started.
); } const html = render(); // No React: parseTsx(`
…
`) then renderToHtml(doc). ``` > **Prefer the visual builder?:** You never have to write code — the template editor (/dashboard/email/templates) builds the identical document with drag-and-drop blocks, and you can flip to Code mode any time to see (or edit) the TSX. ### 3. Send it Save the template in your workspace and reference it when you send, or pass the rendered HTML directly. Both go through the same delivery pipeline — see the API reference (/docs#api) for the full send surface. **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": "mike@example.com", "template": { "id": "tmpl_...", "variables": { "token": "abc123" } } }' ``` **Node.js** ```ts import { Unitpost } from "unitpost"; const unitpost = new Unitpost(process.env.UNITPOST_API_KEY); await unitpost.email.send({ from: "you@yourdomain.com", to: "mike@example.com", template: { id: "tmpl_...", variables: { token: "abc123" }, }, }); ``` ### 4. Next steps Explore the components below — start with Section (/components#section) and Row (/components#row) for layout, then Heading (/components#heading), Text (/components#text), and Button (/components#button) for content. Every block also accepts the common props (/components#common-props). ## 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 Agents (no account): ```bash npx skills add unitpostcom/email ``` Node: ```bash npm install @unitpost/email zod ``` ## Quick start Write React components (`@unitpost/email/react`) or the same tags as a string (`parseTsx`). Both compile to inbox-safe HTML. Style with Tailwind-style `className` or `custom-css`. ```tsx import { Section, Heading, Button, render } from "@unitpost/email/react"; const html = render(
Hi Mike
, ); ``` No React: `parseTsx("
…
")` then `renderToHtml(doc)`. 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)) - [Pre-built layouts](https://www.unitpost.com/components#layouts) — headers, heroes, CTAs, footers as copyable TSX - [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. Import Heading, Text, Button from @unitpost/email/react and call render(), or parseTsx a string if you are not using React. Pass the HTML to any sender. No Unitpost account and no API key. ### How do I install the email component library? For coding agents, run npx skills add unitpostcom/email — that teaches the catalog, React imports, parseTsx, render, and layouts with no account. For Node, run npm install @unitpost/email zod. zod is a peer; React is optional (only if you import @unitpost/email/react). Node 18+. ### 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? In a React app, import Section, Heading, Button from @unitpost/email/react and call render(). Without React, write the same tags as a string and call parseTsx then renderToHtml. Both produce the same document the visual editor uses. className compiles to inline CSS. {{tokens}} fill at send time. ### 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