Plans & limits
Every SendOps plan — Free, Team and Business — puts a number on each
thing it measures: seats, templates, segments, active workflows, broadcasts per
period, contacts and so on. When an action would take your organization past one
of those numbers, the API refuses it with 402 plan_limit_exceeded and tells
you which limit, where you stand, and what lifts it.
This page covers the contract an integration needs:
- the
402problem and the fulllimit_typelist; - reading usage from
GET /v1/account; - the public plan catalogue at
/plans.json; - what
POST /v1/importdoes with a bundle that does not fit.
Some things hold on every plan:
- One AWS account per organization.
- Website identity sites: none on Free, one on Team and Business.
- Custom image domains: none on Free, one on Team, five on Business. These are hard caps with no overage.
- Contacts are a ceiling. They are never priced and never billed as overage, on any plan.
- Overage exists only on monthly Business, on exactly eleven limits (see
/plans.json). With overage switched on, those limits stop refusing and the usage above them is billed at the end of the period. Annual Business has no overage, so its limits are hard caps.
Organizations on their original plan
An organization that existed before these plans keeps the limits it had until it changes plan tier. GET /v1/account reports it as plan.version: 1, with every axis enforced: false. Its refusals carry the same 402 shape but never offer overage or a seat purchase, and creating a template past its original allowance is still 422 validation_failed, not 402.
Send API sends are not a 402 limit
The Send API (early access) has a monthly fair-use number of live sends per plan: 3,000 on Free, 100,000 on Team and 1,000,000 on Business. Reaching it is a 429 send_quota_exceeded on POST /v1/send, not a 402, and it is not reported by GET /v1/account. The dashboard’s Billing page shows usage, the limit and the reset date. See Monthly fair-use limit.
The 402 problem
A refusal is an RFC 7807 problem document with status
402 and code: plan_limit_exceeded. On top of the standard fields it carries the
limit and your position against it:
{
"type": "https://developers.sendops.dev/api-reference/errors/plan_limit_exceeded",
"title": "Plan limit exceeded",
"status": 402,
"code": "plan_limit_exceeded",
"detail": "plan limit reached: segments (3/3 on free plan)",
"limit_type": "segments",
"current": 3,
"limit": 3,
"plan_tier": "free",
"upgrade_tier": "team",
"upgrade_url": "https://app.sendops.dev/acme/billing?reason=segments&upgrade=team",
"overage_available": false,
"purchase_available": false,
"request_id": "req_01HX7QY3..."
}
| Field | Meaning |
|---|---|
limit_type | Which limit refused the action. One of the values in Every limit_type. Branch on this. |
current | How many the organization has (or has used this period) now. 0 for a feature the plan does not include. |
limit | The plan’s number for this limit. 0 means the plan does not include it. |
plan_tier | The organization’s tier: free, team or business. |
upgrade_tier | The tier that lifts the limit — team from Free, business from Team, and business for a Business-only feature. Empty on Business, where there is no self-serve step up. |
upgrade_url | The dashboard billing page that lifts the limit, with upgrade and reason set. Safe to show to a person. |
overage_available | true on monthly Business with overage switched off, for a limit that has overage: switching overage on in the dashboard lifts the cap without changing plan. |
purchase_available | true on Team for seats: an extra seat can be bought on the same plan. |
unit_cents | Present with overage_available or purchase_available: the price in cents, per unit of overage or per extra seat. |
would_add | Present when the refused request tried to add more than one at once — a bulk contact import, a bundle with several new segments. |
detail | A sentence a person can read. Feature refusals and the contacts ceiling use a specific one (“Temporary inboxes are available on the Team plan and above.”). Do not parse it. |
A 402 is terminal. Retrying the same request will not succeed until the plan
changes, overage is switched on, or usage drops below the limit — deleting a
segment, or a new period starting for the per-period limits. Surface
upgrade_url to the person who can act on it.
Every limit_type
Seventeen are usage axes, the same names GET /v1/account reports in
plan.axes. A count axis is how many exist now; a consumption axis is how
many were used in the current period.
limit_type | Kind | What it limits | Where you meet it on /v1 |
|---|---|---|---|
seats | count | Members of the organization | Dashboard only (invitations) |
templates | count | Email templates | PUT /v1/templates/{slug} creating a template, POST /v1/import |
domains | count | Sending domains | Dashboard only |
aws_accounts | count | Connected AWS accounts — one on every plan | Dashboard only |
active_workflows | count | Workflows in the active state | POST /v1/workflows/{id}/activate |
enrollments | consumption | Workflow enrollments this period | Enrollment runs in the background, so it does not arrive as a 402: a refused enrollment is recorded as a skipped decision with reason plan_limit |
segments | count | Segments | POST /v1/segments, POST /v1/import |
broadcasts | consumption | Broadcasts sent this period | POST /v1/broadcasts/{id}/send |
contacts | count | Contacts (archived contacts do not count). A ceiling: never overage, never billed | POST /v1/contacts, PUT /v1/contacts/{email}, PUT /v1/contacts/by-external-id/{external_id}, POST /v1/contacts/bulk, POST /v1/lists/{id}/members. Activity ingest rejects the item instead, with reason contact_limit |
inbox_mints | consumption | Temporary inboxes minted this period | POST /v1/inboxes |
inbox_live | count | Temporary inboxes live at once | Not a 402: the live cap is the 429 cap_org described in Temporary inboxes for platforms |
inbound_domains | count | Inbound (receiving) domains | POST /v1/inbound/domains |
inbound_msgs | consumption | Inbound messages this period | Never refused: mail over the limit is still delivered, and the organization is notified |
sites | count | Website identity sites | Dashboard only |
cdn_domains | count | Custom image domains | Dashboard only |
cdn_storage | count | Stored image bytes. Bytes, in current, limit and plan.axes | POST /v1/assets, POST /v1/import (the bundle’s images) |
verified_senders | count | Verified senders | POST /v1/verified-senders, POST /v1/verified-senders/{id}/confirm |
The rest are features a plan includes or does not. They have no number to count
against, so current and limit are 0:
limit_type | Means | Where you meet it |
|---|---|---|
inboxes | The plan does not include temporary inboxes (Free) | POST /v1/inboxes |
platform_profile | The platform inbox profile is Business only | Switching the organization’s temporary-inbox profile to platform in the dashboard |
audit_log | The plan does not include the audit log (Free) | The dashboard’s audit log |
notification_presets | Notification presets are Business only | Part of the contract; no current route refuses with it. Handle it like the other features |
overage | Overage billing is available only on Business | The dashboard’s overage switch. Not an axis: it names the switch |
Treat an unknown limit_type like any other 402: show detail and upgrade_url.
Reading usage: GET /v1/account
GET /v1/account (scope api.account.view) carries the plan and where the
organization stands on every axis — the same numbers as the dashboard’s
Billing → Usage page. The snapshot is recomputed at most every 30 seconds,
and the response itself is cached for 60 (Cache-Control: max-age=60).
{
"plan": {
"tier": "business",
"retention_days": 365,
"api_rate_limit_per_minute": null,
"version": 2,
"overage_enabled": true,
"period": { "start": "2026-10-01T00:00:00Z", "end": "2026-11-01T00:00:00Z" },
"axes": [
{ "axis": "segments", "kind": "count", "used": 7, "limit": 500, "enforced": true, "overage": { "unit_cents": 100, "per_units": 1 }, "ceiling": false },
{ "axis": "enrollments", "kind": "consumption", "used": 6, "limit": 2000000, "enforced": true, "overage": { "unit_cents": 25, "per_units": 1000 }, "ceiling": false },
{ "axis": "contacts", "kind": "count", "used": 9, "limit": 5000000, "enforced": true, "overage": null, "ceiling": true },
{ "axis": "sites", "kind": "count", "used": null, "limit": 1, "enforced": true, "overage": null, "ceiling": false }
]
}
}The axes array above is trimmed. The real one has all seventeen axes, always in
the same order.
| Field | Meaning |
|---|---|
tier, retention_days, api_rate_limit_per_minute | The plan, as before. Always present. |
version | 2 for the current plans. 1 for an organization on its original plan (see the note at the top of this page): every axis then reads enforced: false and limit: null. |
overage_enabled | true when the organization may go past every axis that has an overage price, billed at the end of the period. Only ever true on monthly Business. |
period | The period the consumption axes count in, [start, end), UTC. Monthly windows anchored on the subscription’s renewal date on Team and Business; the calendar month on Free. |
axes[].axis | The axis, named as in a 402’s limit_type. |
axes[].kind | count (how many exist now) or consumption (used this period). |
axes[].used | How many. null when the axis could not be counted — never read it as zero. |
axes[].limit | The cap. null when the axis is not enforced; 0 when the plan does not include it. |
axes[].enforced | Whether reaching limit refuses the next action. |
axes[].overage | The price past limit: unit_cents for every per_units above it. null when this plan cannot go past it. On Team only seats has one (extra seats are bought as an add-on); on Business it applies while overage_enabled is true. |
axes[].ceiling | true for contacts: a ceiling that is never priced. |
version, overage_enabled, period and axes are present together or not at
all.
The plan catalogue: /plans.json
GET https://api.sendops.dev/plans.json is the plan matrix itself: every tier’s
price, every limit and every overage price. It is public: no credential, CORS
enabled for sendops.dev, and cacheable for five minutes with an ETag (a
conditional request answers 304). The pricing page on sendops.dev is generated
from it, so it is the authority for the numbers.
{
"version": 2,
"currency": "usd",
"plans": [
{
"tier": "free",
"price_cents": 0,
"annual_price_cents": null,
"limits": {
"seats": 1, "templates": 3, "retention_days": 7, "domains": 1, "aws_accounts": 1,
"active_workflows": 1, "enrollments_per_period": 1000, "segments": 3,
"broadcasts_per_period": 10, "contacts": 50000, "inbox_mints_per_period": 0,
"inbox_live": 0, "inbound_domains": 0, "inbound_msgs_per_period": 0, "sites": 0,
"cdn_domains": 0, "cdn_storage_bytes": 104857600, "verified_senders": 0,
"audit_window_days": 0, "allow_presets": false, "allow_platform_profile": false,
"allow_audit_log": false, "allow_inboxes": false
},
"overage": [],
"support": "email"
},
{
"tier": "team",
"price_cents": 2900,
"annual_price_cents": null,
"limits": {
"seats": 5, "templates": 50, "retention_days": 90, "domains": 10, "aws_accounts": 1,
"active_workflows": 10, "enrollments_per_period": 100000, "segments": 50,
"broadcasts_per_period": 200, "contacts": 500000, "inbox_mints_per_period": 3000,
"inbox_live": 25, "inbound_domains": 1, "inbound_msgs_per_period": 10000, "sites": 1,
"cdn_domains": 1, "cdn_storage_bytes": 5368709120, "verified_senders": 10,
"audit_window_days": 90, "allow_presets": false, "allow_platform_profile": false,
"allow_audit_log": true, "allow_inboxes": true
},
"overage": [
{ "axis": "seats", "unit_cents": 600, "per_units": 1 }
],
"support": "email"
},
{
"tier": "business",
"price_cents": 14900,
"annual_price_cents": 163900,
"limits": {
"seats": 25, "templates": 200, "retention_days": 365, "domains": 50, "aws_accounts": 1,
"active_workflows": 100, "enrollments_per_period": 2000000, "segments": 500,
"broadcasts_per_period": 2000, "contacts": 5000000, "inbox_mints_per_period": 30000,
"inbox_live": 500, "inbound_domains": 10, "inbound_msgs_per_period": 100000, "sites": 1,
"cdn_domains": 5, "cdn_storage_bytes": 53687091200, "verified_senders": 100,
"audit_window_days": 365, "allow_presets": true, "allow_platform_profile": true,
"allow_audit_log": true, "allow_inboxes": true
},
"overage": [
{ "axis": "seats", "unit_cents": 500, "per_units": 1 },
{ "axis": "templates", "unit_cents": 50, "per_units": 1 },
{ "axis": "domains", "unit_cents": 200, "per_units": 1 },
{ "axis": "active_workflows", "unit_cents": 200, "per_units": 1 },
{ "axis": "enrollments", "unit_cents": 25, "per_units": 1000 },
{ "axis": "segments", "unit_cents": 100, "per_units": 1 },
{ "axis": "broadcasts", "unit_cents": 50, "per_units": 1 },
{ "axis": "inbox_mints", "unit_cents": 500, "per_units": 1000 },
{ "axis": "inbound_domains", "unit_cents": 500, "per_units": 1 },
{ "axis": "inbound_msgs", "unit_cents": 100, "per_units": 1000 },
{ "axis": "cdn_storage", "unit_cents": 50, "per_units": 1073741824 }
],
"support": "priority"
}
]
}
| Field | Meaning |
|---|---|
version | The plan matrix this document describes (2). Not an API version. |
currency | Always usd. |
plans[].tier | free, team or business, in that order. |
plans[].price_cents | The monthly price. |
plans[].annual_price_cents | The annual price, or null when the tier is not sold annually. |
plans[].limits | Every limit as a number. 0 means not included. The *_per_period keys are the consumption axes; cdn_storage_bytes is bytes; retention_days and audit_window_days are how far back data and the audit log reach. The allow_* booleans are the features behind limit_type notification_presets, platform_profile, audit_log and inboxes. |
plans[].overage | What the tier can buy past its limits: unit_cents for every per_units above the limit. Team’s single entry is the extra seat, bought as an add-on. Business’s eleven are metered overage, billed monthly while overage is on. Empty on Free. |
plans[].support | email or priority. |
The limit keys differ slightly from the axis names in limit_type and
plan.axes: enrollments_per_period is the enrollments axis,
broadcasts_per_period is broadcasts, inbox_mints_per_period is
inbox_mints, inbound_msgs_per_period is inbound_msgs, and
cdn_storage_bytes is cdn_storage.
Bundle imports: POST /v1/import
A bundle is one intended state, so a bundle that does not fit the plan fails whole. The import checks it before writing anything:
- segments — every segment the bundle creates, and every archived one it brings back, counts;
- templates — every template it creates counts;
- image bytes (
cdn_storage) — the bytes of every new or changed image count.
Workflows are not checked: an import creates them as drafts, so the
active_workflows cap applies when you activate one. Archiving with
prune=true runs after the creates, so it does not make room for the same
push.
A dry run (dry_run=true, the default) answers 200 with
plan.applicable: false and a plan.plan_limit naming the cap:
{
"dry_run": true,
"plan": {
"applicable": false,
"prune": false,
"totals": { "create": 2, "unchanged": 14 },
"objects": [ ... ],
"diagnostics": [],
"plan_limit": {
"axis": "segments",
"current": 3,
"limit": 3,
"would_be": 5,
"upgrade_url": "https://app.sendops.dev/acme/billing?reason=segments&upgrade=team"
}
},
"applied": false,
"objects": []
}An apply (dry_run=false) is refused with the standard
402 problem, plus plan_limit (as above) and plan (the
full dry run), so a pipeline can print the axis and the numbers without a second
call. Nothing is written. would_add is set when the bundle adds more than one.
A bundle that already fails validation is not checked against the plan: its diagnostics come first. Business with overage on passes the metered axes.
The sendops-push Action prints the
refusal as one line and fails the step:
Import refused: plan limit — segments 5/3. Upgrade: https://app.sendops.dev/acme/billing?reason=segments&upgrade=teamAn organization on its original plan is not checked this way. Past
its template allowance, the import answers 200 with the kinds before templates
applied, applied: false, applied_until: "templates" and the refusal in
error.
Where to go next
- Errors — every other problem the API returns.
- Push from CI — the bundle format and the Action.
- Temporary inboxes for platforms — the inbox limits, which are partly
429s.