# 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 [`402` problem](#the-402-problem) and the [full `limit_type` list](#every-limit_type);
- [reading usage](#reading-usage-get-v1account) from `GET /v1/account`;
- the public plan catalogue at [`/plans.json`](#the-plan-catalogue-plansjson);
- what [`POST /v1/import`](#bundle-imports-post-v1import) does 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`](#the-plan-catalogue-plansjson)). 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.


  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`.



  The [Send API](/api-reference/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](/api-reference/send-api#monthly-fair-use-limit).


## The 402 problem

A refusal is an [RFC 7807 problem document](/api-reference/errors) with status
`402` and `code: plan_limit_exceeded`. On top of the standard fields it carries the
limit and your position against it:

```json
{
  "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`](#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](/api-reference/inboxes-for-platforms#limits-and-errors) |
| `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`).

```json
{
  "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.

```json
{
  "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](/api-reference/push-from-ci) 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:

```json
{
  "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](#the-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`](https://github.com/AltaCoda/sendops-push) Action prints the
refusal as one line and fails the step:

```text
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`.


  - [Errors](/api-reference/errors) — every other problem the API returns.
  - [Push from CI](/api-reference/push-from-ci) — the bundle format and the Action.
  - [Temporary inboxes for platforms](/api-reference/inboxes-for-platforms) — the inbox limits, which are partly `429`s.