API changelog

What changed in the API, MCP server, SDKs and CLI, newest first.

New operations, new fields and changes in behavior, newest first. A behavior change says what a caller that relied on the old behavior now sees. SDK and CLI releases list their own changes in their changelogs.

2026-10-28: One number's texts

Changed

  • GET /sms takes sender_id: one of your numbers or Sender IDs (snum_…, from GET /sms/numbers), and lists every text it sent or received, as its page in the dashboard does. A value it can't apply is ignored with a Unitpost-Warning, refused under Unitpost-Strict: true.

MCP, SDKs and CLI

  • MCP: sms_list takes sender_id.

2026-10-27: SMS campaigns pause on a run of failures, and renewals are set aside

Behavior changes

  • An SMS campaign pauses by itself when 10 texts in a row fail before reaching a carrier. It reads paused with pause_reason.code failure_streak, the sms_campaign.paused webhook carries the same code, and the texts not yet sent are held, so Resume sends them instead of each one failing in turn. A campaign whose last texts were still going out can go from sent back to paused; its completed_at is cleared until it finishes.
  • Number and campaign renewals due this billing period are set aside from the shared sending allowance ahead of time. GET /usage reports them as wallet.reserved_cents, and they come off what's left to send with, so the allowance runs out sooner than it used to.
  • A text scheduled into the next billing period is paid for when it goes out, not when it's scheduled. If the plan can't cover it then, it fails (sms.failed, with the reason in last_error).

2026-10-27: A key gets an opt-in permission only from a member who holds it

Behavior changes

  • GET /email/topics, GET /email/topics/{id}, GET /sms/topics, GET /sms/topics/{id} and GET /webhooks/{id} count subscribers or the delivery backlog; a count that takes too long answers 503 operation_timed_out with Retry-After, as other heavy reads do.
  • POST /api-keys refuses a key that names branding:write, apikeys:read or sms:purchase when the member creating it doesn't hold that permission (403 insufficient_scope, nothing created). Every teammate holds activity:read, and a teammate who can edit the workspace holds branding:write. Every other permission is granted as before.

2026-10-27: Deleting the logo's image or a Brand Kit graphic takes the branding permission

Behavior changes

  • DELETE /library-images/{id}?force=true on the image that is the workspace's logo or image, or a graphic pinned to a Brand Kit profile, is a 403 insufficient_scope unless the key holds branding:write: deleting it clears the logo or unpins the graphic from every profile, as editing them does. The dashboard applies the same rule to teammates.

2026-10-27: Buy SMS carrier resources by API

Added

  • POST /sms/quotes: Price a carrier purchase and bind what it buys: a 10DLC brand registration (or the paid resubmission of one carriers sent back), brand vetting, a 10DLC campaign registered from a messaging program with its local number, the number of an approved campaign, or a Canadian local number (product). Nothing is charged. Answers due_now_cents, monthly_cents, the line items, expires_at (10 minutes) and a single-use confirm_token, shown once. A toll-free number is 409 dashboard_required with error.dashboard_url: it needs an opt-in evidence file a person provides. Needs sms:manage.
  • GET /sms/quotes/{id}: A quote's price and state (open, expired, ordered, pending, failed) and, once confirmed, what its order made. A pending quote (the carrier's answer is unknown) settles from the billing ledger when it is read: ordered once the fee is captured or the number or campaign goes live, failed once the fee is refunded or the commitments are released. An ordered number or campaign whose commitments are released before it ever goes live (carriers refuse it, the number never leases) also reads failed and stops counting against the cap. Never the confirm token. Needs sms:read.
  • POST /sms/quotes/{id}/confirm: Buy what the quote binds with its confirm_token, through the same order the dashboard places (the plan's purchase rules, the fee taken before carriers are called and refunded if they refuse). Requires an Idempotency-Key. Counts against a monthly API spend cap the workspace owner sets in the dashboard (with none set, it's refused: api_spend_cap_not_set); the owner gets an email per purchase and the activity log names the key. While an order for the same purchase is still in flight, a new quote or confirm for it is 409 conflict, naming that quote. A 502 means the carrier's answer is unknown: the quote is pending and counted, so follow it. Refusals are answered before the shared hourly limits are spent. Needs sms:purchase, which no key preset includes, a key gets only from a member who holds it, and an app gets only when ticked on the consent page.

Changed

  • sms:purchase can be held by an API key or an app, ticked by hand (it was dashboard-only). No existing key or app gains it, and no preset includes it. Creating a key that holds it needs a member who holds it too (403 insufficient_scope otherwise); an app gets it only from a member who holds it.
  • New error codes: api_spend_cap_not_set, api_spend_cap_exceeded (403), quote_already_used, quote_stale, dashboard_required (409), idempotency_key_required (400).

MCP, SDKs and CLI

  • MCP: sms_quotes_create, sms_quotes_get, sms_quotes_confirm (its idempotency_key is required; the tool tells the agent to show the price and get the user's yes first). CLI: unitpost sms quotes create|get|confirm. SDKs: sms.quotes.create, sms.quotes.get, sms.quotes.confirm, in each language's casing.

2026-10-27: Opt many contacts out of a topic at once

Added

  • POST /email/topics/{id}/opt-out: Opt many contacts out of a topic in one call, as a background job: the contacts you list (contact_ids or emails, up to 1,000) or every member of a segment (segment_id, any size; also needs segments:read). Each is opted out as POST /contacts/{id}/topics does it, with topic.unsubscribed and an activity row only for a contact that was subscribed; suppressions and other topics are untouched. Answers 202 with the job (follow it with GET /jobs/{id}); pass an Idempotency-Key so a retry answers the same job. At most five opt-outs run at once in a workspace (409 job_in_progress past that). There is no bulk opt-in. Needs topics:write.
  • POST /sms/topics/{id}/opt-out: The same for an SMS topic, where SMS is available.

Changed

  • GET /jobs and GET /jobs/{id} show topic opt-out jobs (kind: "contacts.topic_opt_out") to a key holding topics:read; cancelling or retrying one takes topics:write.

MCP, SDKs and CLI

  • MCP: email_topics_opt_out, sms_topics_opt_out (with idempotency_key). CLI: unitpost email topics opt-out, unitpost sms topics opt-out. SDKs: topics.optOut and sms.topics.optOut, in each language's casing, each taking an idempotency key.

2026-10-27: Request logs

Added

  • GET /logs: Your workspace's API request log, newest first: each call's method, path, status, time taken, the API key that made it and its x-request-id. Never the caller's IP and never a body. Filters: status (a code, or a class such as 4xx), api_key_id, method, path_prefix (matched literally), request_id (the last 24 hours unless created_after is given), created_after / created_before; a bad value is a 422. An email address or phone number in a path reads {email} / {phone}. Kept 30 days. Needs apikeys:read, which no key preset includes, and has its own per-workspace rate limit.

MCP, SDKs and CLI

  • MCP: logs_list. CLI: unitpost logs list. SDKs: logs.list, in each language's casing.

2026-10-27: Paged SMS setup lists, prefixed workspace ids, safer replies

Changed

  • GET /sms/registrations, GET /sms/test-destinations, GET /sms/consent-setups, GET /sms/billing/resources and GET /sms/messaging-programs are cursor-paginated like every other list (limit, after, before), newest first.
  • GET /whoami and PATCH /workspace return the workspace's id with its ws_ prefix, and GET /whoami's rate_limit.window_seconds reports the limit's window.
  • The OpenAPI spec declares the Unitpost-Warning response header on every operation that can send it.

Behavior changes

  • POST /email/threads/{id}/reply never carries a copy of one of your own sends to another address: a forward of it is a 403, a reply to it goes without the quote, and include_quote: true on it is a 403. What you send isn't readable through the API.
  • POST /segments/preview and GET /segments/{id}/preview share a per-workspace limit with the log searches and key usage reads: GET /activity with q or channel=email, GET /email with q, source or outcome, GET /sms with q or type, GET /email/threads with q, GET /suppressions with q, and GET /api-keys/{id}/usage. Past it, a 429 with Retry-After.
  • A retry, with its Idempotency-Key, of a send that was accepted before send-time keys were checked returns the first answer instead of a 422.

MCP, SDKs and CLI

  • MCP: the five SMS setup list tools take limit, after and before.
  • MCP: a connection with no new parameters lists the same tools as before. Newer tools come in toolsets: add ?toolsets= to the server URL, or send an X-MCP-Toolsets header, with groups such as campaigns, templates, audience or inbox, default for the standard set, or all. ?readonly=1 (X-MCP-Readonly) lists only read tools and refuses writes; ?scoped=1 (X-MCP-Scoped) hides tools the credential can't call. The server instructions list every group.
  • MCP (behavior change): email_templates_test_send is marked destructive, so clients ask before it, and takes idempotency_key. docs_search returns the Node.js and cURL snippets unless you pass language (or all). email_campaigns_list_affected returns 200 addresses per page unless you pass limit (the API keeps 10,000). A tool result's text is compact JSON. The server instructions are shorter; the full rules are in the unitpost://guides/agent-rules resource.

2026-10-27: Activity log, API key reads, several topics at once, search on the logs

Added

  • GET /activity: The workspace activity log, newest first: what changed, when, who (actor.name) and, for a change made over the API, MCP or Ask Unit, which key (actor.via). Filters: subject (+ subject_id), contact_id, campaign_id, q, channel. Needs activity:read, which no key preset includes: tick it on the key that reads it.
  • GET /api-keys/{id}/usage: One key's last 24 hours: requests, 4xx, 5xx and 429 answers, and when it was last used. Needs apikeys:read, which no key preset includes.
  • PATCH /contacts/{id}/topics: Change several of a contact's topic preferences in one call: { topics: [{ topic_id, subscribed }] }, up to 50, each topic once; topics you leave out keep their preference. The contact and every topic are checked before any is set (an unknown topic is a 404 naming it, and nothing changes). Answers 200 with the resolved preferences, in the order sent. POST/PUT /contacts/{id}/topics still set one and answer 201. Needs topics:write.

Changed

  • GET /api-keys works with an API key or MCP sign-in holding apikeys:read (it took a dashboard session only). Creating and revoking keys still take a dashboard session.
  • GET /email takes q (an exact recipient, or part of the subject or sender), source (api, smtp, test, batch, campaign, reply) and outcome (delivered, opened, clicked, unsubscribed, bounced, complained, failed, pending; with campaign_id, who opened or clicked it); GET /sms takes q (the text, or 3+ digits of either number) and type (transactional, marketing).

Behavior changes

  • New capabilities activity:read and, for keys, apikeys:read are in no preset: no existing key or app gains them. Every teammate already sees the activity log in the dashboard. A GET /api-keys request that carries an Authorization: Bearer header is answered for that key, even beside a dashboard session cookie.
  • q, source, outcome and type were ignored by these lists before, so a value they can't apply is still ignored and named in Unitpost-Warning; Unitpost-Strict: true refuses it.

MCP, SDKs and CLI

  • MCP: activity_list, api_keys_list, api_keys_usage, contact_topics_update. CLI: unitpost workspace list-activity, unitpost api-keys list|usage, unitpost contacts topics update. SDKs: workspace.listActivity, apiKeys.usage, email.topics.updateTopics, in each language's casing.

2026-10-27: List filters, topic counts, richer stats, plan limits

Changed

  • GET /suppressions takes q (part of the address), reason and a created_after / created_before range.
  • GET /webhooks/{id}/deliveries takes status (one or more, comma-separated, e.g. failed,exhausted) and event_type; GET /webhooks/{id} returns its backlog: the queued and failed deliveries a flush would send.
  • Topics (/email/topics, /sms/topics) return subscriber_count and unsubscribed_count, the numbers the dashboard shows.
  • GET /email/stats splits bounced into bounced_permanent and bounced_transient, adds suppressed, unsubscribed and the unsubscribe rate, and returns the window it counted (from, to) and domain_id. It takes domain_id, a from / to period instead of days, and with grain (5m, 1h, 1d) and tz the send volume per bucket as series, as the dashboard home charts it.
  • GET /usage returns the plan's limits (domains, API keys, webhook endpoints, contacts, log retention, scheduling window, …; null when unlimited) and features (SMS, inbound email, topics, granular permissions, branding removal).

Behavior changes

  • These lists ignored the new parameters before, so a value one can't apply (a typo, an unknown domain) is still ignored: it is named in the Unitpost-Warning header and the answer is the one you got before. Send Unitpost-Strict: true to have it refused with a 422 (404 for an unknown domain_id). The contacts list's filters work the same way. With days, GET /email/stats answers the days window as before, and a from / to sent beside it is what's ignored.
  • Paging any list with after or before no longer skips a row when the row you paged from has stopped matching the list's filter (a delivery you replayed, a contact that unsubscribed).
  • bounced_permanent counts permanent bounces only, as the hard-bounce rate that can pause a campaign does; a bounce of undetermined type counts as soft.

MCP, SDKs and CLI

  • MCP: the same parameters on suppressions_list, webhooks_list_deliveries and email_stats; the counts, backlog, limits and features in the tools' answers.

2026-10-27: Who am I, an email's events, original messages, template render

Added

  • GET /whoami: The calling credential: its workspace, the API key (name, prefix, preset) or the sign-in (app and member), the capabilities it may use and the rate limit. Any credential; never the secret.
  • GET /email/{id}/events: One sent email's delivery events, oldest first, each with its detail under the webhook's names (a bounce's diagnostic_code, a click's link). Cursor-paginated (emev_ ids). Needs emails:read.
  • GET /email/received/{id}/raw: A short-lived signed URL for a received message's original MIME (.eml): how to read a body over 256 KB. A copy of your own send is a 403. Needs emails:read.
  • POST /email/templates/{id}/render: The subject, HTML and text a send of the template would carry, with your footer and branding, and the variables left without a value. Sends and stores nothing; any unknown key is a 422. Needs templates:read.

Changed

  • GET /email/{id} also returns cc, bcc, reply_to and tags as the email was sent (never its body).

MCP, SDKs and CLI

  • MCP: workspace_whoami, email_list_events, email_received_raw_url, email_templates_render.
  • CLI: unitpost whoami shows the workspace's name and, for an API key, its capabilities.

2026-10-27: SMS operations name the fields they ignore

Behavior changes

  • The SMS operations now name a body field they don't read in the Unitpost-Warning response header, as the others do, and refuse it with a 422 when you send Unitpost-Strict: true: send, update, the consent record, templates, campaigns, their send and reschedule.
  • POST /sms/campaigns/{id}/send without scheduled_at warns that it used the default (the draft's saved time, or now). An SMS campaign PATCH with archived names the edits sent beside it, which it doesn't apply.

MCP, SDKs and CLI

  • CLI: contacts imports cancel, brand-kits imports create|get|accept|cancel, sms brands list|create|get|update and sms numbers list also run the matching commands; the existing names keep working.

2026-10-27: SMS brand drafts and carrier registrations from the API

Added

  • POST /sms/brands: Create an SMS brand, a draft when partial (name is enough; missing says what's still needed). Free; one per website domain; the tax id is write-only. Registering it with carriers is a paid step in the dashboard. Takes an Idempotency-Key. Needs sms:manage.
  • GET /sms/brands/{id}: One brand with every field you can edit; the tax id only as tax_id_on_file. Needs sms:read.
  • PATCH /sms/brands/{id}: Edit a brand (null clears an optional field), or { archived } alone. A brand carriers hold refuses carrier-field edits with 409 brand_locked, naming each field. Needs sms:manage.
  • GET /sms/registrations: Carrier registrations with their review state and the carrier's feedback; a 10DLC campaign carries its number. Needs sms:read.

Changed

  • SMS brands read back every field you can edit: dba_name, sender_id_name, vertical, legal_organization_type, registration_number_type, tax_country, stock_symbol, stock_exchange, contact, address, revision, archived_at.

Behavior changes

  • The registration wizard's own checks (its question plan, readiness score, compiled carrier copy, opt-in page check) stay in the dashboard with the submission they serve; read missing on the brand instead.

MCP, SDKs and CLI

  • MCP: sms_brands_create / _get / _update and sms_registrations_list. Ask Unit can start and edit a brand draft (never its tax id), prepare and edit a number setup draft, and compare a brand registration.
  • SDKs: sms.createBrand / getBrand / updateBrand, sms.registrations.list, in each language's casing. CLI: unitpost sms create-brand|get-brand|update-brand, sms registrations list.

2026-10-26: SMS setup from the API: messaging programs, test texts, charges

Added

  • GET /sms/messaging-programs: Your messaging programs: what a registration says your texts are for, the opt-in, help and stop copy, sample messages. archived=true includes archived ones. Needs sms:read.
  • POST /sms/messaging-programs: Create a program, checked by the carriers' rules (the 422 names the field). Writing a program never changes a registration. Takes an Idempotency-Key. Needs sms:manage.
  • GET /sms/messaging-programs/{id}: One program, archived ones too. Needs sms:read.
  • PATCH /sms/messaging-programs/{id}: Edit a program: only the fields you send change, null clears one. revision moves on a material change. An archived program is a 409. Needs sms:manage.
  • DELETE /sms/messaging-programs/{id}: Archive a program; registrations keep the copy they made. Needs sms:manage.
  • POST /sms/messaging-programs/{id}/duplicate: Copy a program into a new editable one. Takes an Idempotency-Key. Needs sms:manage.
  • GET /sms/consent-setups: Where and how people opt in to your texts. Needs sms:read.
  • GET /sms/billing/resources: What your workspace pays for SMS every month, per number and 10DLC campaign, with the next charge. Needs sms:read.
  • GET /sms/test-destinations: The numbers a template's test text may go to besides the simulator, and whether each is verified. Needs sms:read.
  • DELETE /sms/test-destinations/{id}: Remove a test number. Needs sms:manage.
  • GET /sms/templates/{id}/usage: What uses an SMS template, and whether an edit or unpublish would be accepted now. Needs templates:read.
  • POST /sms/templates/{id}/test-send: Send one test text of a template, only to the simulator or a number you verified (403 otherwise), billed like any text. Takes an Idempotency-Key. Needs sms:send.

Behavior changes

  • Ordering a number, carrier registrations and vetting, a brand's documents, releasing a number, adding or verifying a test number, and paying stay in the dashboard: a person approves money, signs carrier forms and proves a phone is theirs (SMS purchases came to the API on 2026-10-27 as quote then confirm, with the owner's cap).

MCP, SDKs and CLI

  • MCP: sms_messaging_programs_*, sms_consent_setups_list, sms_billable_resources_list, sms_test_destinations_list / _delete, sms_templates_usage and sms_templates_test_send (it and the program creates take idempotency_key). Ask Unit can read, save, edit, archive and copy saved use cases, read consent setups and monthly charges, remove a test number, and pick a sender for a test text.
  • SDKs: sms.messagingPrograms.list / create / get / update / delete / duplicate, sms.consentSetups.list, sms.billing.listResources, sms.testDestinations.list / delete, sms.templates.usage / testSend, in each language's casing. CLI: unitpost sms messaging-programs …, sms consent-setups list, sms billing list-resources, sms test-destinations …, sms templates usage|test-send.

2026-10-26: Edit and import Brand Kits from the API

Added

  • POST /brand-kits: Create an empty Brand Kit profile (name, make_default). At most five per workspace (403 limit_reached). Needs branding:write.
  • PATCH /brand-kits/{id}: Edit a profile: voice notes, tone, description, industry, website, core pages, the palette and logo candidates (extracted), the pinned graphics (library images, img_…). Only the fields you send change. Needs branding:write.
  • DELETE /brand-kits/{id}: Delete a profile; the oldest remaining one becomes the default. The last profile can't be deleted (409). Needs branding:write.
  • POST /brand-kits/{id}/reset: Clear a profile's content, keeping its name and default status. Needs branding:write.
  • POST /brand-kits/{id}/set-default: Make a profile the default. Needs branding:write.
  • POST /brand-kits/{id}/graphics: Add a graphic from a public https image url: saved to the image library and pinned with its role. Needs branding:write.
  • POST /brand-kits/imports: Build a profile from a website (url) or the brand's own words (text), in the background (202). Applied when ready, or with review: true held as a proposal to accept. Needs branding:write.
  • GET /brand-kits/imports/{id}: One import: status, the proposal once ready, warnings. Needs templates:read.
  • POST /brand-kits/imports/{id}/accept: Apply a review import's proposal to a profile, its logos brought into the image library. Needs branding:write.
  • POST /brand-kits/imports/{id}/cancel: Discard a review import or stop its crawl. Needs branding:write.

Changed

  • A Brand Kit's graphics carry image_id (img_…) and path (/img/…) beside library_image_id.

Behavior changes

  • An import adds its site as a sending domain only for a caller that may add domains (domains:write); an import started over the API or MCP never adds one. Add the domain with POST /email/domains.
  • MCP's brand_kits_import returns the import's id as bki_… and also accepts branding:write.

MCP, SDKs and CLI

  • MCP: brand_kits_create / _update / _delete / _reset / _set_default / _add_graphic and brand_kit_imports_*; brand_kits_import stays as it was. Ask Unit can create, edit, reset, delete and make a profile the default, add a graphic by URL, and follow an import it started; accepting or discarding a review import stays in Settings → Brand.
  • SDKs: brandKits.create / update / delete / reset / setDefault / addGraphic / import / getImport / acceptImport / cancelImport, in each language's casing. CLI: unitpost brand-kits ….

2026-10-26: Your image library, branding and workspace name

Added

  • GET /library-images: Your image library, newest first, cursor-paged. Each image has a path (/img/…) to put in a template and a public url. Needs templates:read.
  • POST /library-images: Add an image from a public https url (fetched once, no redirects) or as base64 data with its content_type: PNG, JPEG, GIF or WebP up to 5 MB, counted against your plan's image storage. Takes an Idempotency-Key. Needs templates:write.
  • GET /library-images/{id}: One image. Needs templates:read.
  • PATCH /library-images/{id}: Change an image's suggested alt text. Needs templates:write.
  • DELETE /library-images/{id}: Delete an image. Refused with 409 (resource_in_use, naming what uses it) while a template, a campaign or sent mail shows it, unless you add ?force=true. Needs templates:write.
  • GET /branding: The look your marketing footer and hosted unsubscribe page share: colors, font, company name and postal address, the page's copy, the logo, and whether the "Sent with Unitpost" mark shows. Needs templates:read.
  • PATCH /branding: Change any of those fields; null returns one to its default. The font is one of the dashboard's list. Hiding the "Sent with Unitpost" mark needs a paid plan (403, limit_reached). Needs branding:write.
  • PUT /branding/logo: Replace the logo your emails and unsubscribe page show, from a url or base64 data. Needs branding:write.
  • DELETE /branding/logo: Remove the logo (the workspace image shows instead, else none). Needs branding:write.
  • PATCH /workspace: Rename the workspace. Needs branding:write.
  • PUT /workspace/image: Replace the workspace's image (its avatar in the dashboard), from a url or base64 data. Needs branding:write.
  • DELETE /workspace/image: Remove the workspace's image. Needs branding:write.

Changed

  • A new key capability, branding:write, covers branding, the logo and the workspace's name and image. No key preset includes it, a full-access key too: tick it on the key to let it change how your mail looks. Members who can edit the workspace hold it already.
  • Deleting an image (here and in the dashboard) also checks Brand Kit graphics and the brand logo: each is named in the 409, and force=true returns the logo to its fallback and unpins the graphic.
  • An app you connect through OAuth (MCP, the CLI) starts without branding:write on the consent page; tick it to let the app change branding.

MCP, SDKs and CLI

  • MCP: library_images_*, branding_get / branding_update / branding_set_logo / branding_remove_logo, workspace_update / workspace_set_image / workspace_remove_image. Ask Unit can list images, change their alt text and delete one no template, campaign or setting uses, read and change branding (it asks first), remove the logo, preview the unsubscribe page and rename the workspace.
  • SDKs: libraryImages (with listAll; create takes an idempotency key, minted when you pass none), branding.get / update / setLogo / removeLogo, workspace.update / setImage / removeImage, in each language's casing. CLI: unitpost library-images …, unitpost branding …, unitpost workspace ….

2026-10-26: Webhook replay, email retry, batches and sender addresses

Added

  • POST /webhooks/{id}/deliveries/{delivery}/replay: Deliver one event to a webhook again, with the same event id. The webhook must be enabled. Needs webhooks:manage.
  • POST /webhooks/{id}/flush: Deliver a webhook's backlog: the deliveries held while it was suspended, and with include_failed the ones that failed for good. Needs webhooks:manage.
  • POST /email/{id}/retry: Send a failed email again on the same id, when its failure was temporary (a campaign recipient, a test, a suppressed or rejected send is a 409 saying why). Takes an Idempotency-Key. Needs emails:send.
  • GET /email/batches: Your batch sends, newest first, each with its live per-status rollup. Needs emails:read.
  • GET /email/senders: Your saved From addresses, filterable by domain_id. Needs domains:read.
  • POST /email/senders: Save a From address (address, display_name) on one of your domains. Needs domains:write.
  • GET /email/senders/{id}: One saved From address. Needs domains:read.
  • PATCH /email/senders/{id}: Change a saved From address's display_name. Needs domains:write.
  • DELETE /email/senders/{id}: Remove a saved From address. Templates and campaigns keep their From. Needs domains:write.
  • POST /contacts/export: Export contacts to a CSV file, every contact or the ones GET /contacts's filters match. Runs as a job (202): follow it, then download its file. One export at a time. Needs contacts:read.
  • GET /jobs: Your background jobs, newest first: the kinds your key may read. Filter with status, kind.
  • POST /jobs/{id}/cancel: Stop a queued or running job; work done stays. Needs the capability that starts its kind.
  • POST /jobs/{id}/retry: Resume a failed job from where it stopped. Needs the capability that starts its kind.
  • GET /jobs/{id}/download: A finished export's CSV as a signed URL (5 minutes). Needs contacts:read.
  • POST /segments/preview: Count an unsaved dynamic filter: matches, the subscribed set a campaign would mail, a sample (with contacts:read). Needs segments:read.
  • GET /segments/{id}/preview: The same counts for a saved segment, and whether it is archived. Needs segments:read.
  • POST /segments/all-contacts: The managed "All subscribed contacts" segment, made the first time (an archived one is restored). A 409 when a segment that doesn't target everyone holds its name. Needs segments:write.
  • PATCH /segments/{id}/contacts/{contact}: Opt a member in or out of one static segment's sends (subscribed) without removing it. Needs segments:write.

Changed

  • GET /email/campaigns/{id}/quality/affected pages: limit (up to 10,000, the default) and after (the last row's email). Past 10,000 addresses there was no way to read the rest.
  • GET /email/templates/{id}/usage carries campaign_count, how many campaigns use the template in all (campaigns lists the newest 25).
  • GET /jobs/{id} also returns contacts export jobs (kind: contacts.export).
  • Operations that ignore a body field they don't read now name it in a Unitpost-Warning response header on success, with the closest real name (fist_name → first_name). Send Unitpost-Strict: true to have such a field refused with a 422 instead, as newer operations do; nothing changes then. SMS operations follow.
  • POST /email/campaigns/{id}/send without scheduled_at says in Unitpost-Warning that it used the default (the draft's saved time, or now).

MCP, SDKs and CLI

  • MCP: webhooks_replay_delivery, webhooks_flush, email_retry (takes idempotency_key), email_batches_list and email_senders_*. Ask Unit can do the same from the dashboard's own routes.
  • SDKs: email.retry (takes an idempotency key, minted when you pass none), email.listBatches, email.domains.listSenders / createSender / getSender / updateSender / deleteSender, webhooks.replayDelivery and webhooks.flush, in each language's casing. CLI: unitpost email batches list, unitpost email senders ….
  • Ask Unit says when a list it read stops at its size limit, instead of reading the newest rows as all of them.
  • MCP: a result carries warning beside data when the API sent a Unitpost-Warning.
  • The activity log names who made a change over the API, MCP or Ask Unit: the API key (API key "Production"), the member and the app they signed in with, or the member via Ask Unit.

2026-10-26: Inbox and campaign-report access match the dashboard

Changed

  • GET /email with campaign_id is that campaign's report, so a key with campaigns:read reads it too (as an SMS campaign's recipients), beside emails:read.
  • GET /email/received/{id} carries sent_by_us.

Behavior changes

  • Marking a conversation read, reporting spam, blocking a sender, replying and deleting take emails:read and emails:send, as in the dashboard: a read-only key changes nothing.
  • GET /email/received/{id} answers text and html as null for a copy of one of your own sends that came back to your receiving domain (sent_by_us: true), as the conversation read does: sent bodies aren't on the API. Mail you received is unchanged.
  • GET /email/received/{id}/attachments/{attachmentId} answers 403 (forbidden) for such a copy: its files are what you sent. Its attachments stay listed with their names and sizes.

2026-10-26: Template history, copies and gallery emails

Added

  • POST /email/templates/{id}/duplicate: Copy a template into a new draft: its name with " (copy)" (or your name), subject, editor and body; never its status or history. Takes an Idempotency-Key. Needs templates:write.
  • POST /email/templates/from-library: Add a ready-made gallery email (slug) to your templates as a draft Design template. Takes an Idempotency-Key. Needs templates:write.
  • GET /email/templates/{id}/usage: What uses a template, and whether a change would be accepted now: the campaigns that reference it, its live campaigns and automations, queued sends, can_edit_content and can_unpublish with the reason. Needs templates:read.
  • GET /email/templates/{id}/versions: A template's history, newest first: a version for every publish and every content save while published. Page with the last version as after. Needs templates:read.
  • POST /email/templates/{id}/versions/{version}/restore: Put a version's subject and body back. Refused with 409 while the template's content is locked (a live campaign renders it); on a published template it adds a version. Needs templates:write.

Changed

  • Templates carry category (marketing or transactional) on every read, and PATCH /email/templates/{id} takes category on any editor. A change to it is a content edit: refused with 409 while a live campaign renders the template.
  • A single read of a Simple template carries text, its body in the format you write it in; edit it and send it back as text. It is null for Design and Code templates, and for a Simple email the format can't carry whole.

Behavior changes

  • PATCH /email/templates/{id} used to ignore a category key; it now applies it, and a change to it under a live campaign is a 409, like any content edit.
  • A text or html body update now keeps the template's preview text, declared variables, default sender and tracking settings; it used to reset them. Send preview_text to change the preview text.
  • Restyling, converting or recategorizing a template whose stored body can't be read is a 409 (send a whole new body); it used to replace that body with an empty one.
  • A whole template read sent back to PATCH now also leaves out its unchanged text and category, so a rename stays a rename. An edited html on a Simple read still converts the template to Code, as before.
  • A template read copied into POST /email/templates keeps its category, where a copy used to be transactional. A Simple read makes a Simple copy, where it used to make a Code template with the same look; when its html was edited, html is used as before and warnings says so. A create that names both html and text on its own is still a 422.

MCP, SDKs and CLI

  • MCP: email_templates_duplicate and email_templates_from_library (both take idempotency_key), email_templates_usage, email_templates_list_versions and email_templates_restore_version.
  • SDKs: email.templates.duplicate, fromLibrary, usage, listVersions and restoreVersion (in each language's own casing).
  • CLI: unitpost email templates duplicate|from-library|usage|list-versions|restore-version.
  • Ask Unit can add a gallery email, list a template's versions and, after asking you, restore one.

2026-10-26: Work your inbox from the API

Added

  • GET /email/threads: Your inbox's conversations, newest activity first; q to search. Page with next_cursor. Needs emails:read.
  • GET /email/threads/{id}: One conversation, oldest first: what you received (bodies, Reply-To, verdicts, attachments) and what you sent from it (status and recipients; never a body). A long one comes newest page first (limit, default 50): pass older_cursor as before for the messages before. Needs emails:read.
  • DELETE /email/threads/{id}: Delete a conversation for good, as the inbox does: its received mail is erased (it still counts toward usage); what you sent from it stays on your sent emails. Needs emails:read and emails:send.
  • POST /email/threads/{id}/reply: Reply, reply all or forward a received message, as the inbox does: your text / html on top, the original quoted under it, the reply in the conversation. Sends now; takes an Idempotency-Key. Needs emails:send and emails:read.

Changed

  • Received emails (GET /email/received, GET /email/received/{id}) carry thread_id and reply_to (answer these before from); the single read also carries headers.

MCP, SDKs and CLI

  • MCP: email_threads_list, email_threads_get, email_threads_delete and email_threads_reply (it takes idempotency_key).
  • SDKs: email.threads.list, get, delete and reply (in each language's own casing).
  • CLI: unitpost email threads list|get|delete|reply.
  • Ask Unit can read your inbox and, after asking you, delete a conversation or send a reply.
  • The skill's "Answering inbound mail" uses the conversation tools; deleting a conversation is no longer dashboard-only. Read state, Spam and blocking stay in the dashboard.

2026-10-26: A campaign's results, from the API

Added

  • GET /email/campaigns/{id}/stats: A campaign's numbers, as its page shows them: delivered, unique opens and clicks, unsubscribes, bounces and complaints, their rates, recipients and who didn't receive it. A rate is null until there's something to measure, and open and click rates are null when that tracking was off. Needs campaigns:read.
  • GET /email/campaigns/stats: The same numbers for up to 100 campaigns in one read (ids, comma-separated). An id that isn't one of your email campaigns is left out. Needs campaigns:read.
  • GET /email/campaigns/{id}/recipients: Who the campaign would reach if you sent it now, member by member: sendable, missing_data, suppressed or excluded, with the reason. Filter with status; page with next_cursor. Needs campaigns:read and contacts:read.
  • GET /email/campaigns/{id}/quality/affected: After an automatic quality pause: the addresses that hard-bounced or marked the campaign as spam (up to 10,000). They're already suppressed. Needs campaigns:read.
  • POST /email/campaigns/{id}/quality/remove-affected: While the campaign is paused for its delivery quality, take every flagged contact out of its static segment. Another campaign or a filter segment is a 409; a repeat removes nothing. It doesn't resume the campaign. Needs segments:write.
  • POST /email/campaigns/{id}/quality/request-review: Ask our team to review a campaign whose Resume answered 409 review_required. A repeat within a day answers already_requested: true; a campaign paused while we review the whole account is a 409. Needs campaigns:send.
  • GET /sms/campaigns/{id}/recipients: How each recipient of an SMS campaign fared: the carrier's answer, or why no text was sent (skipped, with reason_code), plus the skipped counts by reason over the whole campaign (on the first page). Search with q (digits of the number). Needs campaigns:read.

Changed

  • GET /email/campaigns and GET /sms/campaigns take topic_id and segment_id: the campaigns scoped to a topic or aimed at a segment.

Behavior changes

  • Removing the flagged addresses after a quality pause, in the dashboard or the API, now records each removal on the contact's activity and sends segment.left for each contact (which also starts any automation triggered by leaving the segment), like every other way a contact leaves a segment. It used to remove them silently. It now also reaches every flagged contact, not only the first 10,000, and is offered only while the campaign is paused for its quality, as on the campaign page.

MCP, SDKs and CLI

  • MCP: email_campaigns_stats, email_campaigns_list_stats, email_campaigns_list_recipients, email_campaigns_list_affected, email_campaigns_remove_affected, email_campaigns_request_review and sms_campaigns_list_recipients; email_campaigns_list and sms_campaigns_list take topic_id and segment_id.
  • SDKs: email.campaigns.stats, listStats, listRecipients, listAffected, removeAffected, requestReview, and sms.campaigns.listRecipients (in each language's own casing).
  • CLI: unitpost email campaigns stats|list-stats|list-recipients|list-affected|remove-affected|request-review, sms campaigns list-recipients.
  • Ask Unit can read a campaign's results and compare campaigns, show who a campaign would reach, list the addresses a quality pause flagged, and, after asking you, remove them from the audience or request a review. It can list the campaigns on a topic or segment.

2026-10-26: Everything on a contact's page, from the API

Added

  • GET /contacts/{id}/addresses: Every email address and SMS number a contact has, each with its status and, for a number, its SMS consent. Needs contacts:read.
  • GET /contacts/{id}/activity: A contact's most recent messages, newest first: the emails sent to them and texts both ways (limit up to 100; no cursor). Emails also need emails:read and texts sms:read; without them those rows are left out. For the full history, list GET /email or GET /sms with contact_id.
  • GET /contacts/{id}/segments: The segments a contact is in: static ones with their per-segment opt-out, and dynamic ones whose filter they match now. Needs contacts:read.
  • POST /contacts/imports/{id}/retry: Re-run a failed import from its saved rows, without uploading them again. Contacts the failed run added are skipped. Needs contacts:write.
  • POST /contacts/{id}/sms-opt-in-request: Text a contact "Reply YES to get our marketing texts". A YES within 72 hours records a verified marketing opt-in. It only confirms consent you already recorded, is billed like any text, and takes an Idempotency-Key. Needs sms:send.

Changed

  • Contacts carry phone: the contact's SMS number in E.164, or null. Set it on POST /contacts and PATCH /contacts/{id} in international format (+ and the country code); phone: null on a PATCH removes the contact's SMS numbers when you also send Unitpost-Strict: true; without it the numbers are kept and Unitpost-Warning says so, so a sync that sends null for a blank field never removes one. A number is not consent: record consent with POST /contacts/{id}/sms-consent. A number that can't be saved (empty, malformed, or another contact has it) is left out and named in Unitpost-Warning, and the rest of the request is saved; with Unitpost-Strict: true it is a 422 (409 when taken) and nothing is saved.
  • GET /contacts filters like the dashboard's contacts table: q (email, first or last name, and 3 or more digits of a phone number), status (subscribed or unsubscribed), phone (has or none), segment_id (also needs segments:read) and segment_subscription (subscribed or opted_out, for a static segment).
  • GET /email filters by to (one recipient address) and contact_id (emails linked to the contact or sent to its address).

Behavior changes

  • GET /contacts/{id}/sms-consent: marketing_eligible is false for a number on your SMS suppression list, as the dashboard shows it. It used to read true when the consent record alone allowed marketing.

MCP, SDKs and CLI

  • MCP: contacts_addresses, contacts_activity, contact_segments_list, contact_imports_retry and sms_consent_request_opt_in (it takes idempotency_key); contacts_list takes the new filters, email_list takes to and contact_id, and contacts_create / contacts_update take phone.
  • SDKs: contacts.addresses, activity, listSegments, retryImport, and sms.requestSmsOptIn (in each language's own casing).
  • CLI: unitpost contacts addresses|activity <id>, contacts segments list|add|remove, contacts imports retry <id>, sms consent request-opt-in <id>.
  • Ask Unit can read a contact's addresses and segments, set or remove their phone number (removing asks you first; it never records consent), retry a failed import, and send the YES-to-confirm text (it asks you first). Its delete card names the campaigns that would lose the contact.

2026-10-26: Ask Unit catches up with the API, and a fix

Behavior changes

  • /contacts/{id}/topics (GET, POST, PUT) with an email in place of the id finds the contact. It answered 404 for every email.

MCP, SDKs and CLI

  • Ask Unit can now delete a contact, template or webhook; archive a segment, topic or campaign; set a contact's topic subscription; import up to 500 contacts and follow, stop or list imports; rename or delete a contact field; and read or cancel an email batch. Each of these asks you first, on a card that names the record. It can also take a contact out of a segment, which runs straight away, like adding one.
  • Some jobs stay with you, on purpose: deleting a sending domain (the dashboard asks you to type its name), rotating a webhook's signing secret (the new secret is shown only once), sending a batch of up to 100 different emails, downloading a received attachment (Ask Unit opens the email for you), and recording SMS consent.

2026-10-26: Rate limits on every surface, and fixes

Changed

  • A public Rate limits page: the workspace budget shared by keys, the MCP server, the CLI and OAuth sign-ins, the RateLimit-* and Retry-After headers, and which operations have a limit of their own.

Behavior changes

  • POST /email/domains: adding domains has a per-workspace limit of its own, on every surface. Past it, 429 rate_limit_exceeded with Retry-After; nothing is created.
  • POST /email/templates/{id}/test-send by an OAuth sign-in (the CLI, an MCP client) also counts against that person's test-send limit, as it does in the dashboard.
  • /contacts/{id}/sms-consent with an email in place of the id finds the contact. It answered 404 for every email.

MCP, SDKs and CLI

  • MCP: a tool call counts once against the per-IP limit, at the same limit as the API (it counted twice, at a fixed lower limit); in a JSON-RPC batch the first call rides on the request's own charge and each further call counts once. A rate-limited tool call's error carries retry_after_seconds. brand_kits_import now goes through the same checks as every other tool: the workspace budget, and a suspended workspace is refused.
  • Ask Unit: a domain check it runs is a real check (it was answered as a background poll), and saving an attachment to the image library counts against the library's upload limit.

2026-10-26: Turn receiving on and off from the API

Changed

  • PATCH /email/domains/{id} takes capabilities: { receiving: "enabled" | "disabled" } (sending is accepted only as "enabled"). Turning receiving on needs a verified (or degraded) domain; otherwise it's a 409 and none of the request's other fields apply. Turning it off is always allowed. Turning it on is limited to a few times an hour per domain.
  • Every domain read carries capabilities and a read-only receiving object: status (disabled, not_started, pending, verified, or held_elsewhere when another workspace receives this name), the MX record to publish, the last checks (mx_found, mx_lowest_priority, other_mx_found) and checked_at. The MX record isn't in records.
  • POST /email/domains/{id}/verify also runs the inbound MX check when receiving is on, and starts accepting mail once our MX record wins.
  • A receiving change fires one domain.updated webhook, including a PATCH that changes nothing else.

MCP, SDKs and CLI

  • MCP: email_domains_update takes body.capabilities.receiving; email_domains_verify runs the MX check.
  • SDKs: the domain update model takes capabilities; domain reads carry capabilities and receiving. CLI: email domains update <id> --capabilities '{"receiving":"enabled"}'.
  • Ask Unit can turn receiving on and off (it asks you first) and run the MX check.

2026-10-26: Precise campaign error codes; agents always name the send time

Behavior changes

  • POST /email/campaigns: a refusal keeps its status and now says which it is. Inline recipients past your plan's contact limit is 403 limit_reached (details[].field recipients); a sender domain that isn't added or is blocked is 403 domain_not_found or domain_blocked (field from). Every 403 used to be domain_not_verified, which now means only an added domain that isn't verified yet.

MCP, SDKs and CLI

  • MCP: email_campaigns_send and sms_campaigns_send require body.scheduled_at: a time to schedule, or null to send now. A call without it is refused and nothing is sent. Over REST, the SDKs and the CLI a Send without a time still uses the saved time, or sends now.
  • Ask Unit names the send time on every campaign send, and asks you when if you didn't say.

2026-10-26: Edit, unschedule and duplicate campaigns from the API

Added

  • POST /email/campaigns/{id}/unschedule: Move a scheduled email campaign back to a draft and clear its send time. Needs campaigns:send. A campaign that has started sending can't be unscheduled: cancel it, then duplicate it.
  • POST /sms/campaigns/{id}/unschedule: The same for an SMS campaign.
  • POST /email/campaigns/{id}/duplicate: Copy a campaign into a new draft: template, segment, sender, subject, preview text, tracking, variable fallbacks and exclusions, never the send time. Optional { "name" }. Send an Idempotency-Key so a retry returns the same copy. Needs campaigns:write. The copy goes to the whole segment, including people who got the original, unless you add them to excluded_contact_ids.
  • POST /sms/campaigns/{id}/duplicate: The same for an SMS campaign (template, segment, sender, variable fallbacks).

Changed

  • PATCH /email/campaigns/{id} edits a draft's name, subject, from, preview_text, template_id and segment_id, with the same checks as create (a published marketing template, a segment that isn't archived, a verified sending domain). A value equal to the draft's is no edit, so you can PATCH back the object you read.

Behavior changes

  • PATCH /email/campaigns/{id} used to ignore name, subject, from, preview_text, template_id and segment_id. It now applies them. Read-only fields such as id and status are still ignored.
  • PATCH /email/campaigns/{id} answers 422 instead of 200 for a body that isn't a JSON object and for recipients (inline recipients are create-only). Nothing is changed. Edits sent beside archived are still not applied: the campaign is archived or restored as before, and the edits are named in Unitpost-Warning (422 under Unitpost-Strict: true).
  • excluded_contact_ids on campaign create and PATCH must be contact ids from your workspace. Anything else is a 422 naming excluded_contact_ids[i]; it used to be saved and never matched anyone.
  • A misnamed saved send time on PATCH /email/campaigns/{id} (send_at, scheduledAt, …) is a 422 naming the key. Use scheduled_at.

MCP, SDKs and CLI

  • MCP: email_campaigns_unschedule, email_campaigns_duplicate, sms_campaigns_unschedule and sms_campaigns_duplicate (the duplicates take idempotency_key).
  • SDKs: unschedule and duplicate on email and SMS campaigns; duplicate sends an idempotency key you pass, or one it makes up, so its retries are safe.
  • CLI: unitpost email campaigns unschedule|duplicate <id> and the same under sms campaigns.
  • Ask Unit can unschedule an email campaign (it asks you first).

2026-10-26: A misnamed send time is refused instead of sending now

Behavior changes

  • POST /email: a time-like key other than scheduled_at or scheduledAt (send_at, schedule_at, deliver_at, send_time, …) is a 422 naming it, and nothing is sent. The email used to go out at once.
  • POST /email/batch: an item carrying its own send time is a 422 naming emails[i].<key>; a misnamed batch-level time is a 422 naming it. Nothing is sent. Those items used to go out at once.
  • POST /sms: a time-like key other than scheduled_at is a 422, and nothing is sent.
  • POST /email/campaigns, POST /sms/campaigns and PATCH /sms/campaigns/{id}: a misnamed saved send time is a 422 ("Nothing was saved"). It used to be dropped, so a later Send without a body went out at once.
  • The 422 says how to send now: leave the time out on one-off and batch sends, pass scheduled_at: null on a campaign Send, or null to clear a campaign's saved time.

MCP, SDKs and CLI

  • MCP (behavior change): a REST tool refuses a top-level argument it doesn't take, with "Request fields go inside body". email_campaigns_send({ id, scheduled_at }) used to drop the time and send now; the time goes in body.scheduled_at.

2026-10-26: Clearer agent tools

Changed

  • GET /email/campaigns and GET /segments document the archived query parameter they already honored (archived=true lists archived rows).

MCP, SDKs and CLI

  • MCP: a write tool's body sent as JSON text that encodes an object is read as that object (it was a -32602). email_campaigns_list takes archived.
  • MCP and the agent skill list the steps that still need the dashboard, and explain how to answer inbound mail in its thread (in_reply_to).
  • SDKs and CLI: an optional archived parameter on campaign and segment lists (--archived).

Search docs and guides

Search the docs and product guides.