Changelog
Dated record of every change to the Public API, and the 90-day notice period for breaking changes.
Every change to the /v2 API is recorded here, newest first. Dates are the day the
change reached the API, not the day it was written up.
Each entry is labelled with what it does to your integration:
| Label | What it means for you |
|---|---|
| Added | New endpoint, scope, response field, or accepted value. Safe — nothing you already send or read changes. |
| Changed | Existing behaviour changed in a backward-compatible way. Worth reading; no action required. |
| Deprecated | Still works, and now carries a removal date. Migrate before that date. |
| Removed | Gone. Only ever appears at least 90 days after the matching Deprecated entry. |
| Fixed | Behaviour that did not match this documentation now does. |
Breaking-change policy
A breaking change is announced here at least 90 days before it takes effect. The Deprecated entry names both the removal date and what to use instead; the endpoint keeps working normally for the whole notice period.
What we treat as breaking
- Removing an endpoint, a response field, or a query parameter.
- Renaming anything in a request or response.
- Making a previously optional request field required, or narrowing what an existing field accepts.
- Changing the type of a response field.
- Changing the meaning of an existing value.
What we do not treat as breaking
- Adding an endpoint.
- Adding a field to a response.
- Adding an optional query parameter or request field.
- Adding a new value to an enum in a response.
- Adding a new scope.
That fifth one is the one that bites integrations, so it is worth stating plainly:
a response enum can gain values without notice. On 2026-08-03 the account
status vocabulary gained disconnected, and it will gain more as the product
grows. Parse defensively — treat an unrecognised value as "some state I do not
handle yet" rather than throwing. A client that switches exhaustively over these
values will break on an addition we do not consider breaking.
How you will hear about it
Deprecations appear in this changelog with their removal date. Check it before you build against an endpoint, and after any integration failure you cannot explain.
2026-09-28
Added — senderEsp on warmup activity items
GET /v2/workspaces/{workspaceId}/accounts/{id}/warmup/activity now returns senderEsp
on every item: google and microsoft for the Google and Microsoft families, other
for plain SMTP, otherwise the lower-cased brand (zoho, yahoo, …).
Deprecated — provider on warmup activity items
provider is now an alias of senderEsp and carries the same value. Note the value
format changed with it: it was the raw provider name (GMAIL, OUTLOOK); it is now the
senderEsp value above. provider will be removed on 2026-12-27. Read senderEsp instead.
2026-09-22
Changed — mailbox checks now carry distinct error codes
POST /accounts/{id}/warmup/enable (scope warmup:manage) can refuse a mailbox
because of our send-and-receive check. Those two refusals used to return the
generic conflict and invalid_request, so telling them apart meant reading
message. They now have codes of their own:
| Code | Status | Means |
|---|---|---|
mailbox_verification_pending | 409 | The check is still running. Nothing to fix. Warmup starts by itself when it passes, so stop retrying. |
mailbox_verification_failed | 400 | The mailbox failed the check. Fix the mailbox itself, then try again. |
This is an enum gaining values, which is non-breaking per the policy above. A
client that branched on conflict or invalid_request for these cases needs
updating.
Fixed — partner-connected mailboxes start warming on their own
POST /accounts/oauth was documented as "warmup is not started; call
warmup/enable". In fact a mailbox connected this way starts warming by itself
once its DNS records and the send-and-receive check pass, within your plan's
warming-mailbox cap. The documentation now says so. You don't need to call
warmup/enable; calling it while the check is still running returns
409 mailbox_verification_pending.
2026-09-21
Removed — warmupLevel on warmup settings
| Method | Path (under /v2/workspaces/{workspaceId}) | Scope |
|---|---|---|
GET | /accounts/{id}/warmup/settings | warmup:read |
PATCH | /accounts/{id}/warmup/settings | warmup:manage |
Warmup levels (NOVICE … GRANDMASTER) no longer exist. The GET response no
longer carries warmupLevel, and a PATCH that sends it is rejected with
400 invalid_request, since unknown fields are refused. To control volume, send
warmupMaxLimit instead (see Added below).
This removal is immediate and has no 90-day notice period. It is an exception
to the breaking-change policy above: there is no matching Deprecated entry. If
your integration sets or reads warmupLevel, move it to warmupMaxLimit.
Added — warmupMaxLimit and warmupCurrentLimit on warmup settings
PATCH /accounts/{id}/warmup/settings accepts warmupMaxLimit, the most warmup
emails the mailbox may send in a day. Warmup still starts low and climbs toward
this number as the mailbox's reputation allows. It must be an integer of at least
5. A value below 5 returns 400 invalid_request. A value above your plan's
per-mailbox cap (maxWarmupPerAccount on the GET response) returns
400 invalid_request with a message that names the cap.
When maxWarmupPerAccount is null, there is no upper limit.
GET /accounts/{id}/warmup/settings now returns warmupCurrentLimit: the daily
sending limit in force today, somewhere between warmupMinLimit and
warmupMaxLimit.
A lower warmupMaxLimit takes effect at the mailbox's next local midnight. A
plan change that leaves a mailbox's max above the new plan's cap lowers the max
to the cap, and sending drops to it immediately.
Changed — warmup-settings fields report what is in force
These fields on GET /accounts/{id}/warmup/settings keep their names, but their
meaning has changed. The null on maxWarmupPerAccount and the new meaning of the
band fields count as breaking under the policy above. Like the removal, they take
effect immediately with no notice period.
maxWarmupPerAccountcan now benull, which means the plan has no per-mailbox cap. It used to return a number even for an unlimited plan. Treatnullas unlimited, not as zero.warmupMinLimitis fixed at5for every mailbox and can't be changed.replyRateMin/replyRateMaxandthreadTendencyMin/threadTendencyMaxare the bands currently in force. In auto mode (replyRateModeLEVEL,replyRateCustom: false) they follow the mailbox's current daily limit, and move up and down with it. A custom reply rate is still reported as its fixed value.replyRateMode: "LEVEL"keeps its wire value and now means "auto": sending it switches a custom reply rate back to the band for the current daily limit.replyRateon account responses (GET /accounts,GET /accounts/{id}) is the custom value, or in auto mode the top of the band for the current daily limit.
The maxLimitMin / maxLimitMax filters on GET /accounts now accept values up
to 100 (was 40).
2026-09-11
Fixed — new workspaces are shared with every owner
POST /v2/workspaces now makes every owner of your organization, the key's
creator included, an owner of the workspace it creates. Before this fix the
creator was added as an admin only and other owners were not added at all, so a
workspace provisioned over the API was invisible to the rest of your owners.
Workspaces already created that way are repaired in the same release. Nothing
changes in what you send or read.
2026-09-10
Added — workspace budgets
| Method | Path | Scope |
|---|---|---|
GET | /v2/workspaces/quotas | workspace:quota |
PATCH | /v2/workspaces/quotas | workspace:quota |
Read and reallocate each workspace's daily warmup budget — its share of your
organization's total daily cap. GET returns the cap (orgLimit; null means
unlimited, not zero), how much is allocated, and every workspace's
dailyWarmupBudget. PATCH takes { quotas: [{ workspaceId, dailyWarmupBudget }] }
and changes only the workspaces listed. The cap is checked once across the whole
request before anything is written, so one call can move budget between
workspaces even when the organization is fully allocated.
POST /v2/workspaces also accepts an optional dailyWarmupBudget, so a workspace
can be created and funded in one call. Sending it requires workspace:quota in
addition to workspace:provision; a budget over the cap is rejected with 400
and no workspace is created.
On either route, an allocation that would exceed the cap returns the new error
code quota_exceeds_org_limit, not invalid_request: the request is valid, and
the fix is to lower another workspace's budget or upgrade.
workspace:quota is a new scope, separate from workspace:provision so that
budget control is never granted by accident to a key issued only to create
workspaces. Like provisioning, it needs a personal (ti_pk_) key belonging to an
organization owner; workspace keys are refused. The per-workspace
PATCH /v2/workspaces/{workspaceId} still does not edit the budget, because a
budget is carved out of the organization-wide cap.
Changed — GET /v2/workspaces accepts workspace keys
| Method | Path | Key | Scope | Returns |
|---|---|---|---|---|
GET | /v2/workspaces | personal (ti_pk_), creator is an organization owner | workspace:provision | every workspace in the organization |
GET | /v2/workspaces | workspace (ti_ws_) | workspace:read | only the workspace the key is bound to |
A workspace key used to be refused on this route whatever its scopes. It is now
accepted and gets back a single-element array holding its own workspace — the same
shape a personal key gets, so you never branch on key type. role on that element
is the role the key acts at. Nothing changes for personal keys, and
POST /v2/workspaces still refuses workspace keys: creating a workspace is an
organization-level action.
Changed — POST /accounts/oauth is documented as partner use only
Documentation only; behaviour is unchanged. The endpoint has always refused every
call with 403 partner_not_configured unless the organization registered and
verified a partner callback. It is now labelled partner use only so it is not
mistaken for a general-purpose way to connect a mailbox — for that, use
POST /accounts/oauth-url.
2026-09-08
Added — connect an OAuth mailbox headlessly
| Method | Path (under /v2/workspaces/{workspaceId}) | Scope |
|---|---|---|
POST | /accounts/oauth | accounts:write |
For partners who run their own OAuth consent screen and hold the mailbox's
refresh token themselves. Send the provider, the emailAddress, a live
accessToken and its expiresAt; the mailbox is connected without anyone
opening a browser.
There is no refreshToken field, and sending one is rejected. That is the
shape of the integration rather than an omission: you keep the refresh token, and
TrulyInbox calls your refresh endpoint when the access token expires. Register
that endpoint under Settings → API keys before your first call — without a
verified registration this endpoint returns 403 partner_not_configured, because
a mailbox connected this way could never be renewed.
The mailbox is identified by the token, not by your payload. We query the
provider's own profile endpoint and reject a mismatch with 422 email_mismatch.
So a wrong emailAddress fails loudly instead of attaching someone else's token
to the row you named.
expiresAt must be more than five minutes out — a token expiring inside our
early-refresh window would call your endpoint back immediately.
Warmup is not started. Call POST /accounts/{id}/warmup/enable once the DNS
gate passes. One mailbox belongs to exactly one workspace: connecting an address
that already exists elsewhere is rejected.
Added — create and list workspaces
| Method | Path | Scope |
|---|---|---|
GET | /v2/workspaces | workspace:provision |
POST | /v2/workspaces | workspace:provision |
Provision a workspace per end customer instead of asking someone to click through
the panel. These are the only /v2 routes with no {workspaceId} segment —
they are how you obtain one.
workspace:provision is a new scope, deliberately separate from
workspace:manage: managing a workspace you were given is a different privilege
from minting new ones. Both routes additionally require a personal (ti_pk_)
key belonging to an organization owner. A workspace-scoped (ti_ws_) key is
refused whatever its scopes, because such a key is pinned to one workspace by
construction.
The key's creator becomes the admin member of every workspace it creates, so mint the key from a service account you control rather than a personal login — if that person leaves the organization, member management breaks across all of them.
Deleting a workspace over the API is not supported.
Changed — three 403s and a 422 now carry distinct error codes
Previously every 403 returned forbidden and every 422 returned
unprocessable_entity, so telling apart "you are out of mailbox capacity" from
"you are out of warmup capacity" meant reading message prose we ask you not to
parse. Four conditions now have codes of their own:
| Code | Status | Means |
|---|---|---|
plan_limit_reached | 403 | Connected-mailbox cap reached — upgrade or remove a mailbox. |
warmup_limit_reached | 403 | Warming-mailbox cap reached — pause one, or upgrade. |
partner_not_configured | 403 | No verified partner callback registered for this organization. |
email_mismatch | 422 | The access token belongs to a different mailbox. |
The remedies differ, which is why the codes now do. This is an enum gaining
values — non-breaking per the policy above — but a client that branched on
forbidden to detect a capacity problem needs updating, since these no longer
report as forbidden.
2026-09-07
Added — connect a single Microsoft mailbox
| Method | Path (under /v2/workspaces/{workspaceId}) | Scope |
|---|---|---|
GET | /accounts/microsoft/consent-url | accounts:write |
Connect one Microsoft mailbox without running your own OAuth app. Pass the
email you want connected; you get back a link and its expiresAt. Send the
link to the mailbox owner — the mailbox is connected when they complete consent
in their browser, not by this call.
The link is single-use and expires in 30 minutes, and it is pinned to the
address you named: if a different Microsoft account signs in, nothing is
connected. That check is what confirms ownership, since the person clicking
through is your customer rather than you. It compares against both identities
Microsoft exposes for a mailbox, so an onmicrosoft.com sign-in still matches a
custom-domain address on the same mailbox.
When the owner finishes they land on a result page carrying
partnerConnectStatus=success|failed and, on failure,
partnerConnectReason=missing_state|consent_denied|expired_or_invalid_link|email_mismatch|error.
Treat that as a hint, not a receipt — poll GET /accounts to confirm. Warmup is
enabled automatically on the connected mailbox, subject to your plan's capacity.
Connecting a mailbox that is already in this workspace re-authorizes it in place. One that is connected in a different workspace is rejected.
POST /accounts/oauth-url is unchanged and still covers Gmail, Google Workspace,
Outlook and MS365 — use it when you do not need the mailbox pinned in advance.
Added — domain on the Microsoft admin-consent URL
GET /v2/workspaces/{workspaceId}/provider-workspaces/microsoft/consent-url now
accepts an optional domain (the customer's Microsoft domain, or their tenant
GUID). Two things change when you pass it:
- The consent prompt is pinned to that tenant. Without it the admin consents for whichever Microsoft account they happen to be signed into, which need not be the tenant you meant.
- The response gains
tenantId, so you can go straight toPOST /provider-workspacesinstead of reading the GUID out of the redirect or asking the admin for it.
A domain with no Microsoft tenant behind it returns 422 rather than quietly
falling back to the generic prompt. Omitting domain behaves exactly as before.
Resolving the tenant proves it exists — not that consent was granted. The POST
still verifies the grant before anything is stored.
2026-08-10
Added — workspace settings and members
Manage the workspace itself and the people in it.
| Method | Path (under /v2/workspaces/{workspaceId}) | Scope |
|---|---|---|
GET | `` (the base path itself) | workspace:read |
PATCH | `` (the base path itself) | workspace:manage |
GET | /members | workspace:read |
POST | /members/invite | workspace:manage |
PATCH | /members/{userId} | workspace:manage |
DELETE | /members/{userId} | workspace:manage |
Two new scopes come with them: workspace:read and workspace:manage.
workspace:manage requires an Admin role in the workspace — a higher bar than
the other *:manage scopes, which need only Editor. A key granted the scope but
held by an Editor is rejected.
Creating and deleting workspaces is not part of this surface and stays in the web
app. GET /members lists organization owners alongside members; they hold
implicit admin on every workspace and are marked isOrgOwner: true.
2026-08-04
Added — billing
| Method | Path (under /v2/workspaces/{workspaceId}) | Scope |
|---|---|---|
GET | /billing/summary | billing:read |
GET | /billing/capabilities | billing:read |
Read the organization's plan, current usage against its limits, and the caller's
resolved capabilities. New scope: billing:read. It is read-only and has no
write counterpart — plan changes go through checkout in the web app.
2026-08-03
Added — account lifecycle, bulk operations, and provider workspaces
The largest expansion of the surface so far — 21 endpoints.
Account lifecycle
POST /accounts— connect an SMTP/IMAP mailbox.POST /accounts/oauth-url— get a consent URL for Gmail, Google Workspace, Outlook, or Microsoft 365. OAuth mailboxes need a browser, so this returns a URL rather than connecting.POST /accounts/{id}/disconnect— stop warmup, keep the data.DELETE /accounts/{id}— permanent, returns204.
Bulk operations — batched forms of the single-account routes, up to 50 ids per call, partial-succeed:
POST /accounts/bulk-statusPOST /accounts/bulk/warmup/enablePOST /accounts/bulk/warmup/pausePOST /accounts/bulk/tagsPOST /analytics/accounts/stats
Bulk endpoints draw on their own rate-limit bucket. See Rate limits.
Provider workspaces — connect a whole Google Workspace or Microsoft 365 tenant and import its mailboxes:
GET /provider-workspacesandPOST /provider-workspacesGET /provider-workspaces/microsoft/consent-urlGET /provider-workspaces/{providerWorkspaceId}/previewPOST /provider-workspaces/{providerWorkspaceId}/syncGET /provider-workspaces/{providerWorkspaceId}/sync-status
Deliverability and diagnostics
GET /accounts/{id}/setup-scoreandPOST /accounts/{id}/setup-score/refreshGET /accounts/{id}/deliverability— fixed 30-day window.GET /accounts/{id}/warmup/activity— paginated per-message send history.
Operational
GET /v2/health— unauthenticated liveness probe. The only endpoint outside the workspace-scoped path.GET /rate-limit— your own remaining budget for all three buckets. Requires no scope: a key that cannot read its own budget cannot back off correctly.
Changed — disconnected account status
disconnected was added to the status filter on GET /accounts and to the
status-counts object returned by the bulk status endpoint.
Additive, and therefore not a breaking change under the policy above — but see the note there about parsing enums defensively.
2026-06-19
Added — initial release
The first published /v2 surface, 21 endpoints across five domains:
| Domain | Endpoints |
|---|---|
| Accounts | List and read accounts, DNS check, account overview. |
| Warmup | Read warmup state and settings; update settings; enable, pause, and resume. |
| Tags | List and create workspace tags; replace an account's tag set. |
| Analytics | Workspace overview, trends, leaderboard, deliverability, per-account stats. |
| Notifications | List; mark one read; mark all read. |
Authentication is by API key as a bearer token, authorized by the scopes stamped on the key intersected with the key's role in the target workspace. See Authentication.
No endpoint, field, or accepted value has been removed since the initial release.
Authentication & errors
API key types, scopes, and workspace rules; the two-layer rate limits and X-RateLimit-* headers; the uniform response envelope and 6-digit error codes.
Partner integration
Embed TrulyInbox warmup in your own product — you run the OAuth consent screen and keep the refresh token, and we call you back for access tokens.