Plans & limits

Search Documentation

Search across all developer documentation

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:

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..."
}
FieldMeaning
limit_typeWhich limit refused the action. One of the values in Every limit_type. Branch on this.
currentHow many the organization has (or has used this period) now. 0 for a feature the plan does not include.
limitThe plan’s number for this limit. 0 means the plan does not include it.
plan_tierThe organization’s tier: free, team or business.
upgrade_tierThe 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_urlThe dashboard billing page that lifts the limit, with upgrade and reason set. Safe to show to a person.
overage_availabletrue 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_availabletrue on Team for seats: an extra seat can be bought on the same plan.
unit_centsPresent with overage_available or purchase_available: the price in cents, per unit of overage or per extra seat.
would_addPresent when the refused request tried to add more than one at once — a bulk contact import, a bundle with several new segments.
detailA 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_typeKindWhat it limitsWhere you meet it on /v1
seatscountMembers of the organizationDashboard only (invitations)
templatescountEmail templatesPUT /v1/templates/{slug} creating a template, POST /v1/import
domainscountSending domainsDashboard only
aws_accountscountConnected AWS accounts — one on every planDashboard only
active_workflowscountWorkflows in the active statePOST /v1/workflows/{id}/activate
enrollmentsconsumptionWorkflow enrollments this periodEnrollment 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
segmentscountSegmentsPOST /v1/segments, POST /v1/import
broadcastsconsumptionBroadcasts sent this periodPOST /v1/broadcasts/{id}/send
contactscountContacts (archived contacts do not count). A ceiling: never overage, never billedPOST /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_mintsconsumptionTemporary inboxes minted this periodPOST /v1/inboxes
inbox_livecountTemporary inboxes live at onceNot a 402: the live cap is the 429 cap_org described in Temporary inboxes for platforms
inbound_domainscountInbound (receiving) domainsPOST /v1/inbound/domains
inbound_msgsconsumptionInbound messages this periodNever refused: mail over the limit is still delivered, and the organization is notified
sitescountWebsite identity sitesDashboard only
cdn_domainscountCustom image domainsDashboard only
cdn_storagecountStored image bytes. Bytes, in current, limit and plan.axesPOST /v1/assets, POST /v1/import (the bundle’s images)
verified_senderscountVerified sendersPOST /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_typeMeansWhere you meet it
inboxesThe plan does not include temporary inboxes (Free)POST /v1/inboxes
platform_profileThe platform inbox profile is Business onlySwitching the organization’s temporary-inbox profile to platform in the dashboard
audit_logThe plan does not include the audit log (Free)The dashboard’s audit log
notification_presetsNotification presets are Business onlyPart of the contract; no current route refuses with it. Handle it like the other features
overageOverage billing is available only on BusinessThe 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.

FieldMeaning
tier, retention_days, api_rate_limit_per_minuteThe plan, as before. Always present.
version2 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_enabledtrue 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.
periodThe 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[].axisThe axis, named as in a 402’s limit_type.
axes[].kindcount (how many exist now) or consumption (used this period).
axes[].usedHow many. null when the axis could not be counted — never read it as zero.
axes[].limitThe cap. null when the axis is not enforced; 0 when the plan does not include it.
axes[].enforcedWhether reaching limit refuses the next action.
axes[].overageThe 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[].ceilingtrue 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"
    }
  ]
}
FieldMeaning
versionThe plan matrix this document describes (2). Not an API version.
currencyAlways usd.
plans[].tierfree, team or business, in that order.
plans[].price_centsThe monthly price.
plans[].annual_price_centsThe annual price, or null when the tier is not sold annually.
plans[].limitsEvery 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[].overageWhat 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[].supportemail 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=team

An 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