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 /smstakessender_id: one of your numbers or Sender IDs (snum_…, fromGET /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 aUnitpost-Warning, refused underUnitpost-Strict: true.
MCP, SDKs and CLI
- MCP:
sms_listtakessender_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
pausedwithpause_reason.codefailure_streak, thesms_campaign.pausedwebhook 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 fromsentback topaused; itscompleted_atis cleared until it finishes. - Number and campaign renewals due this billing period are set aside from the shared sending allowance ahead of time.
GET /usagereports them aswallet.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 inlast_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}andGET /webhooks/{id}count subscribers or the delivery backlog; a count that takes too long answers503 operation_timed_outwithRetry-After, as other heavy reads do.POST /api-keysrefuses a key that namesbranding:write,apikeys:readorsms:purchasewhen the member creating it doesn't hold that permission (403 insufficient_scope, nothing created). Every teammate holdsactivity:read, and a teammate who can edit the workspace holdsbranding: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=trueon the image that is the workspace's logo or image, or a graphic pinned to a Brand Kit profile, is a403 insufficient_scopeunless the key holdsbranding: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. Answersdue_now_cents,monthly_cents, the line items,expires_at(10 minutes) and a single-useconfirm_token, shown once. A toll-free number is409 dashboard_requiredwitherror.dashboard_url: it needs an opt-in evidence file a person provides. Needssms:manage.GET /sms/quotes/{id}: A quote's price and state (open,expired,ordered,pending,failed) and, once confirmed, what its order made. Apendingquote (the carrier's answer is unknown) settles from the billing ledger when it is read:orderedonce the fee is captured or the number or campaign goes live,failedonce the fee is refunded or the commitments are released. Anorderednumber or campaign whose commitments are released before it ever goes live (carriers refuse it, the number never leases) also readsfailedand stops counting against the cap. Never the confirm token. Needssms:read.POST /sms/quotes/{id}/confirm: Buy what the quote binds with itsconfirm_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 anIdempotency-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 is409 conflict, naming that quote. A502means the carrier's answer is unknown: the quote ispendingand counted, so follow it. Refusals are answered before the shared hourly limits are spent. Needssms: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:purchasecan 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_scopeotherwise); 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(itsidempotency_keyis 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_idsoremails, up to 1,000) or every member of a segment (segment_id, any size; also needssegments:read). Each is opted out asPOST /contacts/{id}/topicsdoes it, withtopic.unsubscribedand an activity row only for a contact that was subscribed; suppressions and other topics are untouched. Answers202with the job (follow it withGET /jobs/{id}); pass anIdempotency-Keyso a retry answers the same job. At most five opt-outs run at once in a workspace (409 job_in_progresspast that). There is no bulk opt-in. Needstopics:write.POST /sms/topics/{id}/opt-out: The same for an SMS topic, where SMS is available.
Changed
GET /jobsandGET /jobs/{id}show topic opt-out jobs (kind: "contacts.topic_opt_out") to a key holdingtopics:read; cancelling or retrying one takestopics:write.
MCP, SDKs and CLI
- MCP:
email_topics_opt_out,sms_topics_opt_out(withidempotency_key). CLI:unitpost email topics opt-out,unitpost sms topics opt-out. SDKs:topics.optOutandsms.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 itsx-request-id. Never the caller's IP and never a body. Filters:status(a code, or a class such as4xx),api_key_id,method,path_prefix(matched literally),request_id(the last 24 hours unlesscreated_afteris given),created_after/created_before; a bad value is a422. An email address or phone number in a path reads{email}/{phone}. Kept 30 days. Needsapikeys: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/resourcesandGET /sms/messaging-programsare cursor-paginated like every other list (limit,after,before), newest first.GET /whoamiandPATCH /workspacereturn the workspace's id with itsws_prefix, andGET /whoami'srate_limit.window_secondsreports the limit's window.- The OpenAPI spec declares the
Unitpost-Warningresponse header on every operation that can send it.
Behavior changes
POST /email/threads/{id}/replynever carries a copy of one of your own sends to another address: a forward of it is a403, a reply to it goes without the quote, andinclude_quote: trueon it is a403. What you send isn't readable through the API.POST /segments/previewandGET /segments/{id}/previewshare a per-workspace limit with the log searches and key usage reads:GET /activitywithqorchannel=email,GET /emailwithq,sourceoroutcome,GET /smswithqortype,GET /email/threadswithq,GET /suppressionswithq, andGET /api-keys/{id}/usage. Past it, a429withRetry-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 a422.
MCP, SDKs and CLI
- MCP: the five SMS setup list tools take
limit,afterandbefore. - 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 anX-MCP-Toolsetsheader, with groups such ascampaigns,templates,audienceorinbox,defaultfor the standard set, orall.?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_sendis marked destructive, so clients ask before it, and takesidempotency_key.docs_searchreturns the Node.js and cURL snippets unless you passlanguage(orall).email_campaigns_list_affectedreturns 200 addresses per page unless you passlimit(the API keeps 10,000). A tool result's text is compact JSON. The server instructions are shorter; the full rules are in theunitpost://guides/agent-rulesresource.
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. Needsactivity: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. Needsapikeys: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 a404naming it, and nothing changes). Answers200with the resolved preferences, in the order sent.POST/PUT /contacts/{id}/topicsstill set one and answer201. Needstopics:write.
Changed
GET /api-keysworks with an API key or MCP sign-in holdingapikeys:read(it took a dashboard session only). Creating and revoking keys still take a dashboard session.GET /emailtakesq(an exact recipient, or part of the subject or sender),source(api,smtp,test,batch,campaign,reply) andoutcome(delivered,opened,clicked,unsubscribed,bounced,complained,failed,pending; withcampaign_id, who opened or clicked it);GET /smstakesq(the text, or 3+ digits of either number) andtype(transactional,marketing).
Behavior changes
- New capabilities
activity:readand, for keys,apikeys:readare in no preset: no existing key or app gains them. Every teammate already sees the activity log in the dashboard. AGET /api-keysrequest that carries anAuthorization: Bearerheader is answered for that key, even beside a dashboard session cookie. q,source,outcomeandtypewere ignored by these lists before, so a value they can't apply is still ignored and named inUnitpost-Warning;Unitpost-Strict: truerefuses 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 /suppressionstakesq(part of the address),reasonand acreated_after/created_beforerange.GET /webhooks/{id}/deliveriestakesstatus(one or more, comma-separated, e.g.failed,exhausted) andevent_type;GET /webhooks/{id}returns itsbacklog: the queued and failed deliveries a flush would send.- Topics (
/email/topics,/sms/topics) returnsubscriber_countandunsubscribed_count, the numbers the dashboard shows. GET /email/statssplitsbouncedintobounced_permanentandbounced_transient, addssuppressed,unsubscribedand theunsubscriberate, and returns the window it counted (from,to) anddomain_id. It takesdomain_id, afrom/toperiod instead ofdays, and withgrain(5m,1h,1d) andtzthe send volume per bucket asseries, as the dashboard home charts it.GET /usagereturns the plan'slimits(domains, API keys, webhook endpoints, contacts, log retention, scheduling window, …; null when unlimited) andfeatures(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-Warningheader and the answer is the one you got before. SendUnitpost-Strict: trueto have it refused with a422(404for an unknowndomain_id). The contacts list's filters work the same way. Withdays,GET /email/statsanswers thedayswindow as before, and afrom/tosent beside it is what's ignored. - Paging any list with
afterorbeforeno 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_permanentcounts 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_deliveriesandemail_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), thecapabilitiesit 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'sdiagnostic_code, a click'slink). Cursor-paginated (emev_ids). Needsemails: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 a403. Needsemails: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 a422. Needstemplates:read.
Changed
GET /email/{id}also returnscc,bcc,reply_toandtagsas 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 whoamishows 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-Warningresponse header, as the others do, and refuse it with a422when you sendUnitpost-Strict: true: send, update, the consent record, templates, campaigns, their send and reschedule. POST /sms/campaigns/{id}/sendwithoutscheduled_atwarns that it used the default (the draft's saved time, or now). An SMS campaign PATCH witharchivednames 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|updateandsms numbers listalso 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 (nameis enough;missingsays 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 anIdempotency-Key. Needssms:manage.GET /sms/brands/{id}: One brand with every field you can edit; the tax id only astax_id_on_file. Needssms:read.PATCH /sms/brands/{id}: Edit a brand (nullclears an optional field), or{ archived }alone. A brand carriers hold refuses carrier-field edits with409 brand_locked, naming each field. Needssms:manage.GET /sms/registrations: Carrier registrations with their review state and the carrier's feedback; a 10DLC campaign carries its number. Needssms: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
missingon the brand instead.
MCP, SDKs and CLI
- MCP:
sms_brands_create/_get/_updateandsms_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=trueincludes archived ones. Needssms:read.POST /sms/messaging-programs: Create a program, checked by the carriers' rules (the422names the field). Writing a program never changes a registration. Takes anIdempotency-Key. Needssms:manage.GET /sms/messaging-programs/{id}: One program, archived ones too. Needssms:read.PATCH /sms/messaging-programs/{id}: Edit a program: only the fields you send change,nullclears one.revisionmoves on a material change. An archived program is a409. Needssms:manage.DELETE /sms/messaging-programs/{id}: Archive a program; registrations keep the copy they made. Needssms:manage.POST /sms/messaging-programs/{id}/duplicate: Copy a program into a new editable one. Takes anIdempotency-Key. Needssms:manage.GET /sms/consent-setups: Where and how people opt in to your texts. Needssms:read.GET /sms/billing/resources: What your workspace pays for SMS every month, per number and 10DLC campaign, with the next charge. Needssms:read.GET /sms/test-destinations: The numbers a template's test text may go to besides the simulator, and whether each is verified. Needssms:read.DELETE /sms/test-destinations/{id}: Remove a test number. Needssms:manage.GET /sms/templates/{id}/usage: What uses an SMS template, and whether an edit or unpublish would be accepted now. Needstemplates:read.POST /sms/templates/{id}/test-send: Send one test text of a template, only to the simulator or a number you verified (403otherwise), billed like any text. Takes anIdempotency-Key. Needssms: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_usageandsms_templates_test_send(it and the program creates takeidempotency_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). Needsbranding: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. Needsbranding:write.DELETE /brand-kits/{id}: Delete a profile; the oldest remaining one becomes the default. The last profile can't be deleted (409). Needsbranding:write.POST /brand-kits/{id}/reset: Clear a profile's content, keeping its name and default status. Needsbranding:write.POST /brand-kits/{id}/set-default: Make a profile the default. Needsbranding:write.POST /brand-kits/{id}/graphics: Add a graphic from a public https imageurl: saved to the image library and pinned with itsrole. Needsbranding: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 withreview: trueheld as a proposal to accept. Needsbranding:write.GET /brand-kits/imports/{id}: One import: status, the proposal once ready, warnings. Needstemplates:read.POST /brand-kits/imports/{id}/accept: Apply a review import's proposal to a profile, its logos brought into the image library. Needsbranding:write.POST /brand-kits/imports/{id}/cancel: Discard a review import or stop its crawl. Needsbranding:write.
Changed
- A Brand Kit's
graphicscarryimage_id(img_…) andpath(/img/…) besidelibrary_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 withPOST /email/domains. - MCP's
brand_kits_importreturns the import's id asbki_…and also acceptsbranding:write.
MCP, SDKs and CLI
- MCP:
brand_kits_create/_update/_delete/_reset/_set_default/_add_graphicandbrand_kit_imports_*;brand_kits_importstays 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 apath(/img/…) to put in a template and a publicurl. Needstemplates:read.POST /library-images: Add an image from a public httpsurl(fetched once, no redirects) or as base64datawith itscontent_type: PNG, JPEG, GIF or WebP up to 5 MB, counted against your plan's image storage. Takes anIdempotency-Key. Needstemplates:write.GET /library-images/{id}: One image. Needstemplates:read.PATCH /library-images/{id}: Change an image's suggestedalttext. Needstemplates:write.DELETE /library-images/{id}: Delete an image. Refused with409(resource_in_use, naming what uses it) while a template, a campaign or sent mail shows it, unless you add?force=true. Needstemplates: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. Needstemplates:read.PATCH /branding: Change any of those fields;nullreturns 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). Needsbranding:write.PUT /branding/logo: Replace the logo your emails and unsubscribe page show, from aurlor base64data. Needsbranding:write.DELETE /branding/logo: Remove the logo (the workspace image shows instead, else none). Needsbranding:write.PATCH /workspace: Rename the workspace. Needsbranding:write.PUT /workspace/image: Replace the workspace's image (its avatar in the dashboard), from aurlor base64data. Needsbranding:write.DELETE /workspace/image: Remove the workspace's image. Needsbranding: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, andforce=truereturns the logo to its fallback and unpins the graphic. - An app you connect through OAuth (MCP, the CLI) starts without
branding:writeon 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(withlistAll;createtakes 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. Needswebhooks:manage.POST /webhooks/{id}/flush: Deliver a webhook's backlog: the deliveries held while it was suspended, and withinclude_failedthe ones that failed for good. Needswebhooks: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 a409saying why). Takes anIdempotency-Key. Needsemails:send.GET /email/batches: Your batch sends, newest first, each with its live per-status rollup. Needsemails:read.GET /email/senders: Your saved From addresses, filterable bydomain_id. Needsdomains:read.POST /email/senders: Save a From address (address,display_name) on one of your domains. Needsdomains:write.GET /email/senders/{id}: One saved From address. Needsdomains:read.PATCH /email/senders/{id}: Change a saved From address'sdisplay_name. Needsdomains:write.DELETE /email/senders/{id}: Remove a saved From address. Templates and campaigns keep their From. Needsdomains:write.POST /contacts/export: Export contacts to a CSV file, every contact or the onesGET /contacts's filters match. Runs as a job (202): follow it, then download its file. One export at a time. Needscontacts:read.GET /jobs: Your background jobs, newest first: the kinds your key may read. Filter withstatus,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). Needscontacts:read.POST /segments/preview: Count an unsaved dynamic filter: matches, the subscribed set a campaign would mail, a sample (withcontacts:read). Needssegments:read.GET /segments/{id}/preview: The same counts for a saved segment, and whether it is archived. Needssegments:read.POST /segments/all-contacts: The managed "All subscribed contacts" segment, made the first time (an archived one is restored). A409when a segment that doesn't target everyone holds its name. Needssegments:write.PATCH /segments/{id}/contacts/{contact}: Opt a member in or out of one static segment's sends (subscribed) without removing it. Needssegments:write.
Changed
GET /email/campaigns/{id}/quality/affectedpages:limit(up to 10,000, the default) andafter(the last row'semail). Past 10,000 addresses there was no way to read the rest.GET /email/templates/{id}/usagecarriescampaign_count, how many campaigns use the template in all (campaignslists 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-Warningresponse header on success, with the closest real name (fist_name→first_name). SendUnitpost-Strict: trueto have such a field refused with a422instead, as newer operations do; nothing changes then. SMS operations follow. POST /email/campaigns/{id}/sendwithoutscheduled_atsays inUnitpost-Warningthat it used the default (the draft's saved time, or now).
MCP, SDKs and CLI
- MCP:
webhooks_replay_delivery,webhooks_flush,email_retry(takesidempotency_key),email_batches_listandemail_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.replayDeliveryandwebhooks.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
warningbesidedatawhen the API sent aUnitpost-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 /emailwithcampaign_idis that campaign's report, so a key withcampaigns:readreads it too (as an SMS campaign's recipients), besideemails:read.GET /email/received/{id}carriessent_by_us.
Behavior changes
- Marking a conversation read, reporting spam, blocking a sender, replying and deleting take
emails:readandemails:send, as in the dashboard: a read-only key changes nothing. GET /email/received/{id}answerstextandhtmlasnullfor 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}answers403(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 yourname), subject, editor and body; never its status or history. Takes anIdempotency-Key. Needstemplates:write.POST /email/templates/from-library: Add a ready-made gallery email (slug) to your templates as a draft Design template. Takes anIdempotency-Key. Needstemplates: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_contentandcan_unpublishwith the reason. Needstemplates: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 lastversionasafter. Needstemplates:read.POST /email/templates/{id}/versions/{version}/restore: Put a version's subject and body back. Refused with409while the template's content is locked (a live campaign renders it); on a published template it adds a version. Needstemplates:write.
Changed
- Templates carry
category(marketingortransactional) on every read, andPATCH /email/templates/{id}takescategoryon any editor. A change to it is a content edit: refused with409while 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 astext. It isnullfor Design and Code templates, and for a Simple email the format can't carry whole.
Behavior changes
PATCH /email/templates/{id}used to ignore acategorykey; it now applies it, and a change to it under a live campaign is a409, like any content edit.- A
textorhtmlbody update now keeps the template's preview text, declared variables, default sender and tracking settings; it used to reset them. Sendpreview_textto 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
PATCHnow also leaves out its unchangedtextandcategory, so a rename stays a rename. An editedhtmlon a Simple read still converts the template to Code, as before. - A template read copied into
POST /email/templateskeeps 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 itshtmlwas edited,htmlis used as before andwarningssays so. A create that names bothhtmlandtexton its own is still a422.
MCP, SDKs and CLI
- MCP:
email_templates_duplicateandemail_templates_from_library(both takeidempotency_key),email_templates_usage,email_templates_list_versionsandemail_templates_restore_version. - SDKs:
email.templates.duplicate,fromLibrary,usage,listVersionsandrestoreVersion(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;qto search. Page withnext_cursor. Needsemails: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): passolder_cursorasbeforefor the messages before. Needsemails: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. Needsemails:readandemails:send.POST /email/threads/{id}/reply: Reply, reply all or forward a received message, as the inbox does: yourtext/htmlon top, the original quoted under it, the reply in the conversation. Sends now; takes anIdempotency-Key. Needsemails:sendandemails:read.
Changed
- Received emails (
GET /email/received,GET /email/received/{id}) carrythread_idandreply_to(answer these beforefrom); the single read also carriesheaders.
MCP, SDKs and CLI
- MCP:
email_threads_list,email_threads_get,email_threads_deleteandemail_threads_reply(it takesidempotency_key). - SDKs:
email.threads.list,get,deleteandreply(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 isnulluntil there's something to measure, and open and click rates arenullwhen that tracking was off. Needscampaigns: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. Needscampaigns:read.GET /email/campaigns/{id}/recipients: Who the campaign would reach if you sent it now, member by member:sendable,missing_data,suppressedorexcluded, with the reason. Filter withstatus; page withnext_cursor. Needscampaigns:readandcontacts: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. Needscampaigns: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 a409; a repeat removes nothing. It doesn't resume the campaign. Needssegments:write.POST /email/campaigns/{id}/quality/request-review: Ask our team to review a campaign whose Resume answered409 review_required. A repeat within a day answersalready_requested: true; a campaign paused while we review the whole account is a409. Needscampaigns: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, withreason_code), plus the skipped counts by reason over the whole campaign (on the first page). Search withq(digits of the number). Needscampaigns:read.
Changed
GET /email/campaignsandGET /sms/campaignstaketopic_idandsegment_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.leftfor 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_reviewandsms_campaigns_list_recipients;email_campaigns_listandsms_campaigns_listtaketopic_idandsegment_id. - SDKs:
email.campaigns.stats,listStats,listRecipients,listAffected,removeAffected,requestReview, andsms.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. Needscontacts:read.GET /contacts/{id}/activity: A contact's most recent messages, newest first: the emails sent to them and texts both ways (limitup to 100; no cursor). Emails also needemails:readand textssms:read; without them those rows are left out. For the full history, listGET /emailorGET /smswithcontact_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. Needscontacts: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. Needscontacts: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 anIdempotency-Key. Needssms:send.
Changed
- Contacts carry
phone: the contact's SMS number in E.164, ornull. Set it onPOST /contactsandPATCH /contacts/{id}in international format (+and the country code);phone: nullon a PATCH removes the contact's SMS numbers when you also sendUnitpost-Strict: true; without it the numbers are kept andUnitpost-Warningsays so, so a sync that sendsnullfor a blank field never removes one. A number is not consent: record consent withPOST /contacts/{id}/sms-consent. A number that can't be saved (empty, malformed, or another contact has it) is left out and named inUnitpost-Warning, and the rest of the request is saved; withUnitpost-Strict: trueit is a422(409when taken) and nothing is saved. GET /contactsfilters like the dashboard's contacts table:q(email, first or last name, and 3 or more digits of a phone number),status(subscribedorunsubscribed),phone(hasornone),segment_id(also needssegments:read) andsegment_subscription(subscribedoropted_out, for a static segment).GET /emailfilters byto(one recipient address) andcontact_id(emails linked to the contact or sent to its address).
Behavior changes
GET /contacts/{id}/sms-consent:marketing_eligibleisfalsefor a number on your SMS suppression list, as the dashboard shows it. It used to readtruewhen the consent record alone allowed marketing.
MCP, SDKs and CLI
- MCP:
contacts_addresses,contacts_activity,contact_segments_list,contact_imports_retryandsms_consent_request_opt_in(it takesidempotency_key);contacts_listtakes the new filters,email_listtakestoandcontact_id, andcontacts_create/contacts_updatetakephone. - SDKs:
contacts.addresses,activity,listSegments,retryImport, andsms.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 answered404for 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-*andRetry-Afterheaders, 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_exceededwithRetry-After; nothing is created.POST /email/templates/{id}/test-sendby 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-consentwith an email in place of the id finds the contact. It answered404for 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_importnow 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}takescapabilities: { receiving: "enabled" | "disabled" }(sendingis accepted only as"enabled"). Turning receiving on needs a verified (or degraded) domain; otherwise it's a409and 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
capabilitiesand a read-onlyreceivingobject:status(disabled,not_started,pending,verified, orheld_elsewherewhen another workspace receives this name), the MXrecordto publish, the lastchecks(mx_found,mx_lowest_priority,other_mx_found) andchecked_at. The MX record isn't inrecords. POST /email/domains/{id}/verifyalso runs the inbound MX check when receiving is on, and starts accepting mail once our MX record wins.- A receiving change fires one
domain.updatedwebhook, including a PATCH that changes nothing else.
MCP, SDKs and CLI
- MCP:
email_domains_updatetakesbody.capabilities.receiving;email_domains_verifyruns the MX check. - SDKs: the domain update model takes
capabilities; domain reads carrycapabilitiesandreceiving. 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. Inlinerecipientspast your plan's contact limit is403 limit_reached(details[].fieldrecipients); a sender domain that isn't added or is blocked is403 domain_not_foundordomain_blocked(fieldfrom). Every 403 used to bedomain_not_verified, which now means only an added domain that isn't verified yet.
MCP, SDKs and CLI
- MCP:
email_campaigns_sendandsms_campaigns_sendrequirebody.scheduled_at: a time to schedule, ornullto 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. Needscampaigns: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 anIdempotency-Keyso a retry returns the same copy. Needscampaigns:write. The copy goes to the whole segment, including people who got the original, unless you add them toexcluded_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'sname,subject,from,preview_text,template_idandsegment_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 ignorename,subject,from,preview_text,template_idandsegment_id. It now applies them. Read-only fields such asidandstatusare still ignored.PATCH /email/campaigns/{id}answers422instead of200for a body that isn't a JSON object and forrecipients(inline recipients are create-only). Nothing is changed. Edits sent besidearchivedare still not applied: the campaign is archived or restored as before, and the edits are named inUnitpost-Warning(422underUnitpost-Strict: true).excluded_contact_idson campaign create and PATCH must be contact ids from your workspace. Anything else is a422namingexcluded_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 a422naming the key. Usescheduled_at.
MCP, SDKs and CLI
- MCP:
email_campaigns_unschedule,email_campaigns_duplicate,sms_campaigns_unscheduleandsms_campaigns_duplicate(the duplicates takeidempotency_key). - SDKs:
unscheduleandduplicateon email and SMS campaigns;duplicatesends 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 undersms 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 thanscheduled_atorscheduledAt(send_at,schedule_at,deliver_at,send_time, …) is a422naming it, and nothing is sent. The email used to go out at once.POST /email/batch: an item carrying its own send time is a422namingemails[i].<key>; a misnamed batch-level time is a422naming it. Nothing is sent. Those items used to go out at once.POST /sms: a time-like key other thanscheduled_atis a422, and nothing is sent.POST /email/campaigns,POST /sms/campaignsandPATCH /sms/campaigns/{id}: a misnamed saved send time is a422("Nothing was saved"). It used to be dropped, so a later Send without a body went out at once.- The
422says how to send now: leave the time out on one-off and batch sends, passscheduled_at: nullon a campaign Send, ornullto 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 inbody.scheduled_at.
2026-10-26: Clearer agent tools
Changed
GET /email/campaignsandGET /segmentsdocument thearchivedquery parameter they already honored (archived=truelists archived rows).
MCP, SDKs and CLI
- MCP: a write tool's
bodysent as JSON text that encodes an object is read as that object (it was a-32602).email_campaigns_listtakesarchived. - 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
archivedparameter on campaign and segment lists (--archived).