---
title: "Quotas"
description: "Effective limits and current usage, in one call."
url: "https://saved.sh/docs/api/quotas"
---

```bash
curl -fsS "https://api.saved.sh/v1/workspaces/$WID/quotas" \
  -H "Authorization: Bearer $TOKEN"
```

```json
{
  "billing_state": "trial",
  "quotas": [
    { "resource": "workers_per_workspace",      "limit": 1,  "used": 1 },
    { "resource": "backups_per_workspace",      "limit": 5,  "used": 3 },
    { "resource": "api_keys_per_workspace",     "limit": 2,  "used": 0 },
    { "resource": "members_per_workspace",      "limit": 1,  "used": 1 },
    { "resource": "destinations_per_workspace", "limit": 1,  "used": 0 },
    { "resource": "connections_per_workspace",  "limit": 1,  "used": 0 },
    { "resource": "max_stored_bytes",           "limit": 21474836480, "used": 4192104448 },
    { "resource": "max_artifact_bytes",         "limit": 5368709120,  "used": null },
    { "resource": "max_retention_days",         "limit": 7,  "used": null }
  ]
}
```

Permission: `workspaces:read`.

The response is the **complete set with defaults filled in**, so you never have to know which
limits exist. Read it rather than hard-coding the tables below.

## Reading it [#reading-it]

| Field           | Meaning                                                     |
| --------------- | ----------------------------------------------------------- |
| `billing_state` | Which set of limits applies                                 |
| `resource`      | The identifier that appears in a `quota_exceeded` message   |
| `limit`         | The ceiling. **`0` means unlimited**                        |
| `used`          | Current usage, or `null` where usage is not a running count |

<Callout type="warn">
  **`limit: 0` is unlimited, not zero.** Treating it as a ceiling would make every paid
  workspace look like it had exhausted a quota it does not have.
</Callout>

`used` is `null` for limits that constrain a single value rather than counting things:
`max_artifact_bytes` bounds one artifact, and `max_retention_days` bounds a policy. There is
nothing to count.

## The limits [#the-limits]

| Resource                     | Trial  | Paid      | Bounds                        |
| ---------------------------- | ------ | --------- | ----------------------------- |
| `workers_per_workspace`      | 1      | 10        | Provisioned workers           |
| `backups_per_workspace`      | 5      | 100       | Backup definitions            |
| `api_keys_per_workspace`     | 2      | 20        | Active API keys               |
| `members_per_workspace`      | 1      | 25        | Members                       |
| `destinations_per_workspace` | 1      | 25        | Linked buckets                |
| `connections_per_workspace`  | 1      | 25        | OAuth connections             |
| `max_stored_bytes`           | 20 GiB | unlimited | Total held in our storage     |
| `max_artifact_bytes`         | 5 GiB  | unlimited | A single artifact             |
| `max_retention_days`         | 7      | unlimited | `expire_after` and `lock_for` |

Workspaces per user is 5 on both.

<Callout type="error">
  **`max_retention_days` on trial is 7.** A trial workspace cannot set `expire_after: 90d`, and
  since retention is **write-once**, a backup created on trial keeps whatever policy it was
  given. Upgrading does not retroactively loosen it. If you intend a long retention, set it on
  a paid plan, or expect to create the backup again.
</Callout>

## Checking before you create [#checking-before-you-create]

```bash
curl -fsS "https://api.saved.sh/v1/workspaces/$WID/quotas" -H "Authorization: Bearer $TOKEN" \
  | jq -r '.quotas[] | select(.limit != 0 and .used != null and .used >= .limit)
           | "at limit: \(.resource) \(.used)/\(.limit)"'
```

Worth running before a bulk `apply`, which otherwise fails partway through with the earlier
backups created and the later ones not.

## When a limit is hit [#when-a-limit-is-hit]

```json
{
  "error": {
    "code": "quota_exceeded",
    "message": "this workspace already has 5 backups",
    "request_id": "req_..."
  }
}
```

Returned as `409`. The message names the resource and the count.

Creating endpoints check the quota **first**, so a refused call creates nothing. Retrying will
fail identically until the underlying count changes: delete something, or upgrade.

## Storage limits behave differently [#storage-limits-behave-differently]

`max_stored_bytes` and `max_artifact_bytes` are not enforced at request time, because nothing
about an API call knows how large a dump will turn out to be. They bind during a run, and
exceeding one fails the run rather than returning an HTTP error.

Watch `used` against `limit` rather than waiting for a failure at 02:00.

`max_stored_bytes` counts only artifacts held in **our** storage. Copies delivered to your own
buckets do not count toward it, which is one reason
[`skip_permanent`](/docs/backups/delivery) is the cheapest way to run us.

## Billing state [#billing-state]

`billing_state` decides which set applies, and also gates writes independently of quotas.

| State                       | New runs | Config changes | Reads and downloads |
| --------------------------- | -------- | -------------- | ------------------- |
| `trial`, `active`, `warned` | Yes      | Yes            | Yes                 |
| `stopped`                   | No       | Yes            | Yes                 |
| `blocked`                   | No       | No             | Yes                 |

Reads and downloads are never gated. See [Workspaces](/docs/api/workspaces#billing-state).

## Next [#next]

<Cards>
  <Card href="/docs/billing/limits" title="Limits" description="The same numbers, with plan context." />

  <Card href="/docs/api/errors" title="Errors" description="Handling quota_exceeded." />
</Cards>
