v1

Unitpost Documentation

Guides to get you sending, plus a complete reference for every API endpoint — generated live from the spec, so it never drifts.

Base URL: /api/v1

Getting started

Task-focused walkthroughs. Code samples follow the language you pick in the API reference below.

Install the SDK

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.

1. Add the SDK

Pick your language tab. The API tab needs nothing installed — any HTTP client works.

# No package to install — call the REST API with any HTTP client.
# Keep your key in an environment variable:
export UNITPOST_API_KEY="up_live_..."

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.

# 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.

Quickstart

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.

1. Create an API key

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.

2. Verify a sending domain

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.

3. Send

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.

curl https://api.example.com/v1/emails \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "you@yourdomain.com",
    "to": "customer@acme.com",
    "subject": "Welcome aboard",
    "html": "<p>Thanks for signing up!</p>"
  }'

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

Templates & variables

Reference a saved template and pass per-recipient data.

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.

curl https://api.example.com/v1/emails \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "you@yourdomain.com",
    "to": "customer@acme.com",
    "template": { "id": "tmpl_123" },
    "data": { "first_name": "Sam", "plan": "Pro" }
  }'

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.

Subscription topics

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.

Opt-in vs. opt-out

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.

Managing topics

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 }'

Per-contact preferences

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.

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 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:

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

Sending a receipt or password reset?

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

Batch sending

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.

Group, inspect, cancel

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.

# Schedule the whole batch
curl https://api.example.com/v1/emails/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": "<p>…</p>" },
      { "from": "you@yourdomain.com", "to": "b@example.com", "subject": "Hi", "html": "<p>…</p>" }
    ]
  }'

# Cancel the whole batch before it sends
curl -X POST https://api.example.com/v1/emails/batches/$BATCH_ID/cancel \
  -H "Authorization: Bearer $API_KEY"

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 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.

A whole batch

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.

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. 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.

Rate limits

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.

Bounces, complaints & suppression

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.

Webhooks

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.

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

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.

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).

// 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.

{
  "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.

{
  "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.

import { createHmac, timingSafeEqual } from "node:crypto";

// secret = "whsec_..." from the dashboard (shown once)
export function verify(rawBody, headers, secret) {
  const id = headers["svix-id"];
  const ts = headers["svix-timestamp"];
  const sigHeader = headers["svix-signature"]; // "v1,<base64> ..."

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64url");
  const expected = createHmac("sha256", key)
    .update(`${id}.${ts}.${rawBody}`)
    .digest("base64");

  return sigHeader.split(" ").some((part) => {
    const sig = part.split(",", 2)[1];
    if (!sig || sig.length !== expected.length) return false;
    return timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  });
}

Reject stale timestamps

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.

Rotating the signing secret

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.

Retries & failures

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.

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.

  1. 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.
  2. 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.
  3. 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 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.

Product guides

A page-by-page tour of the dashboard — Domains, Templates, Contacts, Segments, Campaigns, and more.

API Reference

Every endpoint, generated from the live spec.

@unitpost/email

Component library

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>

Layout

<Section>

Container

Use a Section to band content together — a hero, a card, a footer. It can hold any leaf block and can be nested inside another Section.

<Section padding-x={24} padding-y={24}>
  <Text>Grouped content</Text>
</Section>
PropTypeDefaultDescription
background-colorcolorFill color behind the section.
padding-xnumber24Horizontal inner padding (px).
padding-ynumber24Vertical inner padding (px).

<Row>

Container

Rows render as a single table row with one cell per column, so they stay side-by-side even in Outlook. A Row may only contain Column components.

<Row column-gap={8}>
  <Column width={50}>
    <Text>Left</Text>
  </Column>
  <Column width={50}>
    <Text>Right</Text>
  </Column>
</Row>
PropTypeDefaultDescription
column-gapnumber8Horizontal gap (px) between columns. Set 0 for flush.
stack-on-mobilebooleantrueBest-effort: let columns wrap on narrow viewports (table emails stay side-by-side in Outlook).
background-colorcolorFill color behind the row.

<Column>

Container

Columns are table cells. `width` is a percentage of the row; the widths of the columns in a row should add up to ~100.

<Column width={50}>
  <Text>Column content</Text>
</Column>
PropTypeDefaultDescription
widthnumber50Width as a percentage (1–100) of the parent Row.
background-colorcolorFill color behind the column.
padding-xnumber8Horizontal inner padding (px).
padding-ynumber0Vertical inner padding (px).

Content

<Heading>

A title (H1–H4). Inner text supports {{variables}}.

<Heading level={2}>Welcome, {{first_name}}</Heading>
PropTypeDefaultDescription
levelenum1 | 2 | 3 | 42Heading level — controls size/weight.
alignenumleft | center | rightleftText alignment.
colorcolorText color.
font-familystringOverride the document font for this heading.

<Text>

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>
PropTypeDefaultDescription
alignenumleft | center | rightleftText alignment.
colorcolorText color.
font-sizenumber16Font size (px).
font-familystringOverride the document font for this block.

<Divider>

A thin horizontal rule to separate sections.

<Divider />
PropTypeDefaultDescription
colorcolor#e4e4e7Line color.

<Spacer>

Margins are unreliable across clients, so a Spacer renders as an explicit fixed-height cell.

<Spacer height={24} />
PropTypeDefaultDescription
heightrequirednumber24Gap 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>
PropTypeDefaultDescription
alignenumleft | center | rightleftAlignment.
colorcolorBase text color.
font-sizenumber16Base font size (px).

<Code>

A monospace code block — handy for API keys and snippets.

<Code>npm install @unitpost/email</Code>
PropTypeDefaultDescription
background-colorcolor#f4f4f5Block background.
colorcolor#1a1a1aCode text color.

Media

<Image>

A responsive image, optionally wrapped in a link.

<Image src="https://example.com/logo.png" alt="Logo" width={120} />
PropTypeDefaultDescription
srcrequiredurlImage URL (use an absolute, hosted URL).
altstringAlternative text (shown if the image can't load).
hrefurlMake the image a link to this URL.
widthnumberContainer (frame) width in px. The image scales to fill it; defaults to full content width.
heightnumberOptional frame height in px. By default the height adapts to the image's aspect ratio — set this to pin an explicit height.
objectFitenumcover | contain | fillHow the image fills the frame when a height is set: cover (crop), contain (letterbox), or fill (stretch). Client support varies.
backgroundColorcolorFrame background shown around the image when “contain” leaves gaps.
borderRadiusnumberCorner rounding in px.
alignenumleft | center | rightcenterHorizontal alignment.

Interactive

<Button>

A call-to-action — a styled, padded link that looks like a button.

<Button href="https://example.com/verify?token={{token}}">Verify email</Button>
PropTypeDefaultDescription
hrefrequiredurl#Destination URL. {{variables}} are allowed.
alignenumleft | center | rightleftHorizontal alignment of the button.
background-colorcolor#2563ebButton fill color.
text-colorcolor#ffffffLabel color.
border-radiusnumber6Corner radius (px).
inner-padding-xnumber24Horizontal padding inside the button (px).
inner-padding-ynumber12Vertical padding inside the button (px).

Common props

Every block accepts these in addition to its own props.

PropTypeDefaultDescription
margin-bottomnumber16Vertical space (px) below the block.
custom-csscssExtra inline CSS declarations merged onto the block's root element (e.g. "letter-spacing: 1px; opacity: 0.9"). Inlined so it survives every client.

Per-side spacing is also supported via padding and margin objects in the visual editor's inspector.