Add the official Node, Python, or Ruby SDK — or call the API directly.
You can integrate in whichever way fits your stack. The official SDKs (all published as `unitpost`) wrap the same REST API with idempotent retries, typed responses, 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.
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.
# Every request carries your key as a Bearer token plus a User-Agent.
curl https://api.example.com/v1/emails \
-H "Authorization: Bearer $UNITPOST_API_KEY" \
-H "Content-Type: application/json" \
-H "User-Agent: my-app/1.0"
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 for creating and scoping keys.
That's it — you're ready to send. Head to the Quickstart to make your first request, or jump straight to Templates & variables.
Create a key, verify a domain, and send your first email.
You can send your first email in a few minutes. There are three steps: create an API key, point a sending domain at us, and make a single POST request.
Go to API Keys and create a key. Choose a scope preset — Full, Sending, or Read only — and optionally narrow it to a fine-grained set of capabilities. Copy the key when it's shown; it's only revealed once.
Scope keys tightly
A server that only sends mail should use a Sending key, not Full. Keys can never hold privileged capabilities (managing other keys, team, workspace, or billing) — those stay in the dashboard.
Add your domain under Domains and publish the generated DNS records (a DKIM TXT record, a custom MAIL FROM, and a DMARC policy). Verification runs automatically — you don't need to click anything once DNS propagates.
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.
POST to /v1/emails with a from address on your domain, a recipient, a subject, and either inline html or a template id. Every request needs an Authorization header and a User-Agent.
The response includes the new email's id. Use GET /v1/emails/{id} to read its current status and lifecycle events, or watch it live on the Emails page in the dashboard.
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 a data object and any {{variable}} in the template is substituted at send time.
When you send to a known contact, that contact's custom fields are merged into the variable scope 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.
Templates are authored in the visual editor or in code mode with the @unitpost/email component library — manage them under Templates. The library — every block, its props, and copy-paste snippets — is documented under the Email section in this sidebar.
Let contacts opt out of one kind of mail without leaving your list.
A topic is a named subscription category — "Product updates," "Promotions," "Weekly digest" — that a contact can opt out of on its own. It's independent of which segments they're in and of the global unsubscribe: a contact can stay on your list, stay in their segments, and still silence just one kind of mail. Scope a campaign to a topic and anyone opted out of it is skipped at send.
The one field that matters is default_opt_in — it decides what "no explicit preference" means for a contact who has never touched the topic:
default_opt_in: true (the default) — an opt-out topic. Contacts are auto-enrolled and receive it unless they opt out. Use for newsletters and product updates.
default_opt_in: false — an opt-in topic. Contacts are silent until they explicitly subscribe. Use for beta invites or sensitive announcements.
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.
Create, list, update, and archive topics under /v1/topics. Names are unique per workspace — reusing one returns a 409. Deleting a topic archives it (soft): it disappears from campaign pickers and can't be newly targeted, but campaigns that already used it keep their history. It's permanently pruned about 30 days later, and only once no in-flight campaign still references it.
# Create an opt-out newsletter topic
curl https://api.example.com/v1/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://api.example.com/v1/contacts/customer@acme.com/topics \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "topic_id": "top_123", "subscribed": false }'
GET /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.
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.
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 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.
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.
Send up to 100 distinct messages — schedule and cancel as a unit.
POST to /v1/emails/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 either a JSON array of message objects, or an object { emails: [...], scheduled_at: "<ISO>" } to schedule the whole batch. Validation is all-or-nothing: if any item is invalid the entire batch is rejected and nothing is queued.
Every batch is grouped under a batch_id (returned on send). Read a live per-status rollup with GET /v1/emails/batches/{id}, and stop every still-scheduled message in one call with POST /v1/emails/batches/{id}/cancel. In the dashboard, batches appear under Emails → Batches.
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.
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.
From the dashboard, open the email on the Emails page and click Cancel. Via the API, PATCH /v1/emails/{id} with { "cancel": true }. You can also reschedule a still-pending email by PATCHing a new scheduled_at.
POST /v1/emails/batches/{id}/cancel stops every still-scheduled message in the batch in one call. Already-sent messages are unaffected. In the dashboard this is the Cancel batch button under Emails → 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.
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. the order id), not a random value per attempt — that way an automatic retry reuses the same key and is deduplicated. Keys are scoped to your workspace and retained long enough to cover realistic retry windows.
Requests are rate limited per workspace. Exceed the limit and you'll get a 429 with a Retry-After header — back off for that many seconds and retry. For large fan-outs, prefer a campaign (one message to a segment) or a batch over many individual requests.
How bad addresses are handled and where to review them.
Protecting your sender reputation is automatic. When an address hard bounces or marks your mail as spam, we add it to your suppression list with the reason recorded — so you stop mailing addresses that hurt your standing with mailbox providers. The suppression list is address-level and case-insensitive, and 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 — a send to a suppressed address is skipped and surfaced as a "Recipients excluded" event on the email's timeline rather than a delivery error. Review and manage the full list on the Suppressions tab, where each contact's contact record also shows a Mailable / Suppressed deliverability badge. You can remove an address there to make it mailable again — but think twice before un-suppressing a hard bounce or complaint, since re-sending can hurt your deliverability.
Suppression is separate from marketing unsubscribe
The suppression list (deliverability) is distinct from a contact's marketing subscribe/unsubscribe preference. An address can be unsubscribed from marketing yet still mailable for transactional sends, or suppressed outright regardless of marketing status. Manage deliverability blocks on the Suppressions page; manage marketing 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.
Receive signed event notifications at your own endpoint.
Webhooks let your application react to events as they happen — update a record when a message is delivered, suppress an address after a hard bounce, or sync a contact when it changes. Register an endpoint under Developers → Webhooks (or via the API), choose the events you care about, and we POST a signed JSON payload to your URL whenever one fires.
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.
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.
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.
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.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).
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.
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.
The svix-timestamp is part of the signed content, so an attacker can't alter it without breaking the signature — but a valid old payload could still be replayed. After verifying the signature, reject deliveries whose timestamp is outside a tolerance window (we recommend ±5 minutes) to defeat replay attacks. Our test sends and retries always use a fresh timestamp.
Test before you ship
Use the Send test action on any endpoint (dashboard or POST /v1/webhooks/{id}/test) to fire a sample signed event at your URL and confirm your verification works end to end. The test payload uses the SAME data shape as the real event (with an added "test": true marker), so it exercises your actual parsing.
The signing secret is shown once, when you create the endpoint. If you lose it, you don't lose the endpoint — rotate the secret from the ⋯ menu in the dashboard to generate a fresh whsec_… that's revealed once. Rotation takes effect immediately: deliveries after it are signed with the new secret, so roll it out to your verifier promptly. Rotate on a regular cadence and whenever a secret may have leaked.
If your endpoint doesn't return a 2xx, we retry with backoff: up to 5 attempts spaced 30s, 2m, 8m, then 32m apart. Respond quickly (ideally under a few seconds): acknowledge with a 2xx as soon as you've verified and stored the event, then do slower work asynchronously. A clearly-permanent response (400, 401, 403, 404, 410, 422) is not retried — fix the endpoint and use Replay on the event. Transient failures (408, 429, 5xx, timeouts, network errors) are retried.
Each endpoint shows a live status: Enabled (healthy), Failing (recent deliveries are erroring — an early warning), or Disabled. After a sustained run of failures we automatically disable an endpoint to stop sending to a dead URL; it then shows "Disabled · failing". Re-enable it from the ⋯ menu once the endpoint is fixed — re-enabling clears the failure streak and resumes delivery. You can also disable/enable an endpoint anytime from the ⋯ menu to pause delivery without losing its configuration.
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.
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.
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.
Deleting a segment or campaign archives it rather than removing it immediately. Archiving keeps history intact (a sent campaign still shows what it sent to) and avoids orphaning references. Archived items are hidden from pickers and lists by default, can be restored with one click, and are permanently 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, or sending) still needs — cancel or finish that campaign first. This is the rule that used to be a confusing 500 error; now it's 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.
Build emails from a small set of cross-client-tested components. Author them visually in the template editor, or in code using the constrained TSX dialect below. The same components render identically in the editor preview and in the email your recipients receive.
Quick start
In Code mode, compose your email from the components below. Insert dynamic data anywhere with {{variable}} — it's substituted at send time from the values you pass to the API.
<Section padding-y={32}>
<Heading level={1}>Welcome, {{first_name}}</Heading>
<Text>Thanks for joining. Confirm your email to get started.</Text>
<Button href="https://example.com/verify?token={{token}}">
Verify email
</Button>
</Section>
Inner content may include inline HTML for formatting: <strong>, <em>, <u>, <a href>, and <span style> (color / background-color). These round-trip through the visual editor.
<Text>Hi {{first_name}}, thanks for signing up.</Text>
Prop
Type
Default
Description
align
enumleft | center | right
left
Text alignment.
color
color
—
Text color.
font-size
number
16
Font size (px).
font-family
string
—
Override the document font for this block.
<Divider>
A thin horizontal rule to separate sections.
<Divider />
Prop
Type
Default
Description
color
color
#e4e4e7
Line color.
<Spacer>
Margins are unreliable across clients, so a Spacer renders as an explicit fixed-height cell.
<Spacer height={24} />
Prop
Type
Default
Description
heightrequired
number
24
Gap height (px).
<Markdown>
Author rich copy in Markdown — compiled to email-safe HTML.
<Markdown>**Welcome!** Here's _what's new_ this week. [See all](https://example.com)</Markdown>
Prop
Type
Default
Description
align
enumleft | center | right
left
Alignment.
color
color
—
Base text color.
font-size
number
16
Base font size (px).
<Code>
A monospace code block — handy for API keys and snippets.