---
title: "Billing"
description: "The plan, credit balance, allowances, overage estimate, invoices, credit packs, auto-refill and the Site's credit cap. Payment details and plan changes are never taken here: they are links a person opens."
canonical: "https://writavo.com/docs/api/billing"
last-updated: "2026-09-24"
---

# Billing

The plan, credit balance, allowances, overage estimate, invoices, credit packs, auto-refill
and the Site's credit cap. Payment details and plan changes are never taken here: they are
links a person opens.

Base URL: `https://api.writavo.com/v1`

### GET /billing

Read the billing summary

- **Operation id**: `getBillingSummary`
- **Scope**: `billing:read`
- **Permission**: `billing.manage`
- **Rate limit class**: read
- **Plan feature**: `none`
- **Spends credits**: no
- **Publishable key may call it**: no

The whole billing picture in one read: the plan and any scheduled change, whether a card is on
file, the credit balance, the CMS overage so far this period, auto-refill, this Site's credit cap,
and every allowance with what has been used.

CMS allowances (`is_payg: true`) are pay-as-you-go and identical on every plan: going past one
bills the overage and never blocks. Never call them plan limits and never suggest upgrading to
lift one.

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | BillingSummary | The billing summary. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. It is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. For a key an AI agent holds, a 403 may also be `AGENT_ACCESS_DISABLED` (the organisation turned agent access off) or `APPROVAL_DENIED` (a person denied this action). Neither is about scope, and neither reveals anything about another Site. On the organisation, team and settings operations a 403 may also be `FORBIDDEN`: a change no scope allows, such as an AI agent changing the access of the person it acts for, anything to do with ownership, or a setting only a person may change. The message says what to do instead. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money, as do the paid SEO scans and the other operations that do expensive work. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys, webhooks, the content plan, formats and prompts, settings, delivery, SEO curation, the team and billing \| 120 per minute \| \| upload \| `POST /media/upload-url` and `POST /seo/backlinks/import` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs`, `POST /pipeline/articles/{id}/requeue`, `POST /delivery/cms/push` and the seven paid SEO scans \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

```bash
curl https://api.writavo.com/v1/billing \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000"
```

### GET /billing/invoices

List invoices and payments

- **Operation id**: `listInvoices`
- **Scope**: `billing:read`
- **Permission**: `billing.manage`
- **Rate limit class**: read
- **Plan feature**: `none`
- **Spends credits**: no
- **Publishable key may call it**: no

Invoices, credit pack purchases and refunds, newest first, with links to the hosted invoice and
its PDF. Page back with `before` (the `next_cursor` of the previous page). The list is read from
Writavo's own copy of the billing history; `stale: true` only says that copy has not been
refreshed recently, so the last few changes may not be in it yet.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `before` | query | string | no | The `next_cursor` from the previous page. |
| `limit` | query | integer | no | Rows per page. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | InvoicePage | A page of billing history. |
| 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. It is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. For a key an AI agent holds, a 403 may also be `AGENT_ACCESS_DISABLED` (the organisation turned agent access off) or `APPROVAL_DENIED` (a person denied this action). Neither is about scope, and neither reveals anything about another Site. On the organisation, team and settings operations a 403 may also be `FORBIDDEN`: a change no scope allows, such as an AI agent changing the access of the person it acts for, anything to do with ownership, or a setting only a person may change. The message says what to do instead. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money, as do the paid SEO scans and the other operations that do expensive work. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys, webhooks, the content plan, formats and prompts, settings, delivery, SEO curation, the team and billing \| 120 per minute \| \| upload \| `POST /media/upload-url` and `POST /seo/backlinks/import` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs`, `POST /pipeline/articles/{id}/requeue`, `POST /delivery/cms/push` and the seven paid SEO scans \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

```bash
curl https://api.writavo.com/v1/billing/invoices \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000"
```

### GET /billing/credit-packs

List credit packs

- **Operation id**: `listCreditPacks`
- **Scope**: `billing:read`
- **Permission**: `billing.manage`
- **Rate limit class**: read
- **Plan feature**: `none`
- **Spends credits**: no
- **Publishable key may call it**: no

The credit packs that can be bought on top of the plan's monthly credits, with their price.

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | object | The packs. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. It is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. For a key an AI agent holds, a 403 may also be `AGENT_ACCESS_DISABLED` (the organisation turned agent access off) or `APPROVAL_DENIED` (a person denied this action). Neither is about scope, and neither reveals anything about another Site. On the organisation, team and settings operations a 403 may also be `FORBIDDEN`: a change no scope allows, such as an AI agent changing the access of the person it acts for, anything to do with ownership, or a setting only a person may change. The message says what to do instead. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money, as do the paid SEO scans and the other operations that do expensive work. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys, webhooks, the content plan, formats and prompts, settings, delivery, SEO curation, the team and billing \| 120 per minute \| \| upload \| `POST /media/upload-url` and `POST /seo/backlinks/import` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs`, `POST /pipeline/articles/{id}/requeue`, `POST /delivery/cms/push` and the seven paid SEO scans \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

```bash
curl https://api.writavo.com/v1/billing/credit-packs \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000"
```

### POST /billing/credit-packs/{key}/checkout

Start a credit pack purchase

- **Operation id**: `startCreditPurchase`
- **Scope**: `billing:write`
- **Permission**: `billing.manage`
- **Rate limit class**: write
- **Plan feature**: `none`
- **Spends credits**: no
- **Publishable key may call it**: no

Returns a Stripe Checkout link for one credit pack. Nothing is charged by this call: the person
opens the link in their browser and pays on Stripe's page, and the credits arrive when the
payment completes. That page is where they consent, so no approval is asked. Give them
`checkout_url` exactly as returned; it is shown once. Card details never pass through the API or
an assistant.

An unknown or inactive pack is `404`.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `key` | path | string | yes | The pack's `key`, from `GET /billing/credit-packs`. |
| `Idempotency-Key` | header | string | yes | A unique key you generate per logical operation, 1 to 255 printable ASCII characters. A UUID is the obvious choice. Retry with the **same** key and the same body and you get the original response replayed rather than a second object. This is what makes a network timeout safe: you never know whether the first request landed, so you retry with the same key and find out. Same key with a **different** body is `409 IDEMPOTENCY_KEY_CONFLICT`, because reusing a key for different work is a bug in your client rather than a retry. Same key while the first request is still running is `409 IDEMPOTENCY_KEY_IN_FLIGHT`; wait and retry. Keys are scoped to the Site and the endpoint, and are retained for 24 hours. After that the same key is a new operation. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | CreditCheckout | The checkout link. |
| 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. It is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. For a key an AI agent holds, a 403 may also be `AGENT_ACCESS_DISABLED` (the organisation turned agent access off) or `APPROVAL_DENIED` (a person denied this action). Neither is about scope, and neither reveals anything about another Site. On the organisation, team and settings operations a 403 may also be `FORBIDDEN`: a change no scope allows, such as an AI agent changing the access of the person it acts for, anything to do with ownership, or a setting only a person may change. The message says what to do instead. |
| 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. |
| 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. On an approval-gated operation it may also be `APPROVAL_INVALID`: the `Writavo-Approval` you sent is expired, used, for a different request, unknown, or what the request would do has changed since it was approved. Retry without it to ask for a new approval. On a settings, delivery or SEO operation it may be `PREREQUISITE_MISSING` (something the operation needs is not set up; the message names the operation that sets it up) or `FEATURE_UNAVAILABLE` (Writavo has the capability switched off; the message names the free alternative). Neither did anything or charged anything. |
| 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money, as do the paid SEO scans and the other operations that do expensive work. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys, webhooks, the content plan, formats and prompts, settings, delivery, SEO curation, the team and billing \| 120 per minute \| \| upload \| `POST /media/upload-url` and `POST /seo/backlinks/import` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs`, `POST /pipeline/articles/{id}/requeue`, `POST /delivery/cms/push` and the seven paid SEO scans \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

```bash
curl -X POST https://api.writavo.com/v1/billing/credit-packs/3f1b0c7a-0000-4000-8000-000000000001/checkout \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### GET /billing/links

Get the billing links for a person

- **Operation id**: `getBillingLinks`
- **Scope**: `billing:read`
- **Permission**: `billing.manage`
- **Rate limit class**: read
- **Plan feature**: `none`
- **Spends credits**: no
- **Publishable key may call it**: no

The dashboard pages where a person changes the plan, adds or changes a card, and sees invoices.
The API never changes the plan or takes payment details; give the person the right link.

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | BillingLinks | The links. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. It is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. For a key an AI agent holds, a 403 may also be `AGENT_ACCESS_DISABLED` (the organisation turned agent access off) or `APPROVAL_DENIED` (a person denied this action). Neither is about scope, and neither reveals anything about another Site. On the organisation, team and settings operations a 403 may also be `FORBIDDEN`: a change no scope allows, such as an AI agent changing the access of the person it acts for, anything to do with ownership, or a setting only a person may change. The message says what to do instead. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money, as do the paid SEO scans and the other operations that do expensive work. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys, webhooks, the content plan, formats and prompts, settings, delivery, SEO curation, the team and billing \| 120 per minute \| \| upload \| `POST /media/upload-url` and `POST /seo/backlinks/import` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs`, `POST /pipeline/articles/{id}/requeue`, `POST /delivery/cms/push` and the seven paid SEO scans \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

```bash
curl https://api.writavo.com/v1/billing/links \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000"
```

### POST /billing/scheduled-change/cancel

Keep the current plan

- **Operation id**: `keepCurrentPlan`
- **Scope**: `billing:write`
- **Permission**: `billing.manage`
- **Rate limit class**: write
- **Plan feature**: `none`
- **Spends credits**: no
- **Costs money**: yes
- **AI agent approval**: `billing.keep_plan`, always asked. Asked only when a plan change is actually scheduled.
- **Publishable key may call it**: no

Cancel a scheduled plan change (a downgrade or cancellation set for the end of the period), so
the organisation stays on its current plan and keeps paying for it. With nothing scheduled it
answers `released: false` and asks no approval.

**When an AI agent asks.** A key from a device or OAuth sign-in, or any request from an MCP
client, always gets `428 APPROVAL_REQUIRED` on the first try, whatever the organisation's
approvals switch says, because it spends money or credits. Asked only when a plan change is
actually scheduled. Give the person `error.approval.url`; once they approve, repeat the
identical request with `Writavo-Approval: <error.approval.id>`. The approval is void if what the
request would do changes before the retry. A key made in the dashboard is asked only when the
request comes from an MCP client.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `Idempotency-Key` | header | string | yes | A unique key you generate per logical operation, 1 to 255 printable ASCII characters. A UUID is the obvious choice. Retry with the **same** key and the same body and you get the original response replayed rather than a second object. This is what makes a network timeout safe: you never know whether the first request landed, so you retry with the same key and find out. Same key with a **different** body is `409 IDEMPOTENCY_KEY_CONFLICT`, because reusing a key for different work is a bug in your client rather than a retry. Same key while the first request is still running is `409 IDEMPOTENCY_KEY_IN_FLIGHT`; wait and retry. Keys are scoped to the Site and the endpoint, and are retained for 24 hours. After that the same key is a new operation. |
| `Writavo-Approval` | header | uuid | no | The `error.approval.id` from a `428 APPROVAL_REQUIRED`, sent on the retry once a person has approved it. Only meaningful for a key approved for an AI agent; every other caller can leave it out. It is single use and bound to the key, to the exact request that asked for it (method, path and body) and to what that request would do: anything else, including a request whose effect changed since it was approved, is `409 APPROVAL_INVALID`. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | ScheduledChangeReleased | Whether a change was cancelled. |
| 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. It is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. For a key an AI agent holds, a 403 may also be `AGENT_ACCESS_DISABLED` (the organisation turned agent access off) or `APPROVAL_DENIED` (a person denied this action). Neither is about scope, and neither reveals anything about another Site. On the organisation, team and settings operations a 403 may also be `FORBIDDEN`: a change no scope allows, such as an AI agent changing the access of the person it acts for, anything to do with ownership, or a setting only a person may change. The message says what to do instead. |
| 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. On an approval-gated operation it may also be `APPROVAL_INVALID`: the `Writavo-Approval` you sent is expired, used, for a different request, unknown, or what the request would do has changed since it was approved. Retry without it to ask for a new approval. On a settings, delivery or SEO operation it may be `PREREQUISITE_MISSING` (something the operation needs is not set up; the message names the operation that sets it up) or `FEATURE_UNAVAILABLE` (Writavo has the capability switched off; the message names the free alternative). Neither did anything or charged anything. |
| 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. |
| 428 | - | `APPROVAL_REQUIRED` or `APPROVAL_PENDING`. Only ever returned to an AI agent: a key from a device or OAuth sign-in, or any request an MCP client makes (it carries `Writavo-Mcp-Tool`) whatever key it holds. For an operation marked `x-writavo-approval-always` it is returned whatever the organisation's approvals switch says, and for the six content actions while approvals are on. Nothing has happened yet. Show `error.approval.url` to a person; once they approve, repeat the identical request with `Writavo-Approval: <error.approval.id>`. Do not change the body or the path: the approval is bound to the exact request, and a different one is `409 APPROVAL_INVALID`. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money, as do the paid SEO scans and the other operations that do expensive work. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys, webhooks, the content plan, formats and prompts, settings, delivery, SEO curation, the team and billing \| 120 per minute \| \| upload \| `POST /media/upload-url` and `POST /seo/backlinks/import` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs`, `POST /pipeline/articles/{id}/requeue`, `POST /delivery/cms/push` and the seven paid SEO scans \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

```bash
curl -X POST https://api.writavo.com/v1/billing/scheduled-change/cancel \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### PATCH /billing/autorefill

Change auto-refill

- **Operation id**: `updateAutorefill`
- **Scope**: `billing:write`
- **Permission**: `billing.manage`
- **Rate limit class**: write
- **Plan feature**: `none`
- **Spends credits**: no
- **Costs money**: yes
- **AI agent approval**: `billing.autorefill`, always asked. Asked only when auto-refill is on after the change; turning it off is never asked.
- **Publishable key may call it**: no

Turn auto-refill on or off, or change it. While on, the saved card is charged for `pack_key`
whenever the credit balance falls to `threshold_credits`, never more than `monthly_ceiling_usd`
a month. Turning it on needs `pack_key` and `monthly_ceiling_usd` (`422` otherwise; a ceiling
below the pack's price is also `422`) and a card on file: without one it is
`409 PREREQUISITE_MISSING`, and the person adds a card at
https://app.writavo.com/billing?action=add-card. Turning it off is never asked and never charges.

**When an AI agent asks.** A key from a device or OAuth sign-in, or any request from an MCP
client, always gets `428 APPROVAL_REQUIRED` on the first try, whatever the organisation's
approvals switch says, because it spends money or credits. Asked only when auto-refill is on
after the change; turning it off is never asked. Give the person `error.approval.url`; once they
approve, repeat the identical request with `Writavo-Approval: <error.approval.id>`. The approval
is void if what the request would do changes before the retry. A key made in the dashboard is
asked only when the request comes from an MCP client.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `Writavo-Approval` | header | uuid | no | The `error.approval.id` from a `428 APPROVAL_REQUIRED`, sent on the retry once a person has approved it. Only meaningful for a key approved for an AI agent; every other caller can leave it out. It is single use and bound to the key, to the exact request that asked for it (method, path and body) and to what that request would do: anything else, including a request whose effect changed since it was approved, is `409 APPROVAL_INVALID`. |

**Request body** (required)

| Field | Type | Required | Description |
|---|---|---|---|
| `enabled` | boolean | yes |  |
| `threshold_credits` | number | no |  |
| `pack_key` | string | no | From `GET /billing/credit-packs`. |
| `monthly_ceiling_usd` | number | no |  |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | Autorefill | Auto-refill after the change. |
| 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. It is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. For a key an AI agent holds, a 403 may also be `AGENT_ACCESS_DISABLED` (the organisation turned agent access off) or `APPROVAL_DENIED` (a person denied this action). Neither is about scope, and neither reveals anything about another Site. On the organisation, team and settings operations a 403 may also be `FORBIDDEN`: a change no scope allows, such as an AI agent changing the access of the person it acts for, anything to do with ownership, or a setting only a person may change. The message says what to do instead. |
| 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. On an approval-gated operation it may also be `APPROVAL_INVALID`: the `Writavo-Approval` you sent is expired, used, for a different request, unknown, or what the request would do has changed since it was approved. Retry without it to ask for a new approval. On a settings, delivery or SEO operation it may be `PREREQUISITE_MISSING` (something the operation needs is not set up; the message names the operation that sets it up) or `FEATURE_UNAVAILABLE` (Writavo has the capability switched off; the message names the free alternative). Neither did anything or charged anything. |
| 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. |
| 428 | - | `APPROVAL_REQUIRED` or `APPROVAL_PENDING`. Only ever returned to an AI agent: a key from a device or OAuth sign-in, or any request an MCP client makes (it carries `Writavo-Mcp-Tool`) whatever key it holds. For an operation marked `x-writavo-approval-always` it is returned whatever the organisation's approvals switch says, and for the six content actions while approvals are on. Nothing has happened yet. Show `error.approval.url` to a person; once they approve, repeat the identical request with `Writavo-Approval: <error.approval.id>`. Do not change the body or the path: the approval is bound to the exact request, and a different one is `409 APPROVAL_INVALID`. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money, as do the paid SEO scans and the other operations that do expensive work. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys, webhooks, the content plan, formats and prompts, settings, delivery, SEO curation, the team and billing \| 120 per minute \| \| upload \| `POST /media/upload-url` and `POST /seo/backlinks/import` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs`, `POST /pipeline/articles/{id}/requeue`, `POST /delivery/cms/push` and the seven paid SEO scans \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

```bash
curl -X PATCH https://api.writavo.com/v1/billing/autorefill \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -d '{
  "enabled": "<enabled>"
}'
```

### PATCH /billing/site-credit-cap

Change this Site's credit cap

- **Operation id**: `updateSiteCreditCap`
- **Scope**: `billing:write`
- **Permission**: `billing.manage`
- **Rate limit class**: write
- **Plan feature**: `none`
- **Spends credits**: no
- **AI agent approval**: `billing.site_cap`, always asked. Asked only when the cap is raised or removed.
- **Publishable key may call it**: no

Set the most credits this Site may spend in a month, independent of the organisation's balance;
`null` removes the cap. Lowering it, or capping an uncapped Site, only reduces what can be spent
and is never asked. The cap is what answers `402 SPEND_CAP_REACHED`.

**When an AI agent asks.** A key from a device or OAuth sign-in, or any request from an MCP
client, always gets `428 APPROVAL_REQUIRED` on the first try, whatever the organisation's
approvals switch says, because it spends money or credits. Asked only when the cap is raised or
removed. Give the person `error.approval.url`; once they approve, repeat the identical request
with `Writavo-Approval: <error.approval.id>`. The approval is void if what the request would do
changes before the retry. A key made in the dashboard is asked only when the request comes from
an MCP client.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `Writavo-Approval` | header | uuid | no | The `error.approval.id` from a `428 APPROVAL_REQUIRED`, sent on the retry once a person has approved it. Only meaningful for a key approved for an AI agent; every other caller can leave it out. It is single use and bound to the key, to the exact request that asked for it (method, path and body) and to what that request would do: anything else, including a request whose effect changed since it was approved, is `409 APPROVAL_INVALID`. |

**Request body** (required)

| Field | Type | Required | Description |
|---|---|---|---|
| `monthly_credit_cap` | number | null | yes | Null removes the cap. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | SiteCreditCap | The cap after the change. |
| 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. It is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. For a key an AI agent holds, a 403 may also be `AGENT_ACCESS_DISABLED` (the organisation turned agent access off) or `APPROVAL_DENIED` (a person denied this action). Neither is about scope, and neither reveals anything about another Site. On the organisation, team and settings operations a 403 may also be `FORBIDDEN`: a change no scope allows, such as an AI agent changing the access of the person it acts for, anything to do with ownership, or a setting only a person may change. The message says what to do instead. |
| 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. On an approval-gated operation it may also be `APPROVAL_INVALID`: the `Writavo-Approval` you sent is expired, used, for a different request, unknown, or what the request would do has changed since it was approved. Retry without it to ask for a new approval. On a settings, delivery or SEO operation it may be `PREREQUISITE_MISSING` (something the operation needs is not set up; the message names the operation that sets it up) or `FEATURE_UNAVAILABLE` (Writavo has the capability switched off; the message names the free alternative). Neither did anything or charged anything. |
| 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. |
| 428 | - | `APPROVAL_REQUIRED` or `APPROVAL_PENDING`. Only ever returned to an AI agent: a key from a device or OAuth sign-in, or any request an MCP client makes (it carries `Writavo-Mcp-Tool`) whatever key it holds. For an operation marked `x-writavo-approval-always` it is returned whatever the organisation's approvals switch says, and for the six content actions while approvals are on. Nothing has happened yet. Show `error.approval.url` to a person; once they approve, repeat the identical request with `Writavo-Approval: <error.approval.id>`. Do not change the body or the path: the approval is bound to the exact request, and a different one is `409 APPROVAL_INVALID`. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money, as do the paid SEO scans and the other operations that do expensive work. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys, webhooks, the content plan, formats and prompts, settings, delivery, SEO curation, the team and billing \| 120 per minute \| \| upload \| `POST /media/upload-url` and `POST /seo/backlinks/import` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs`, `POST /pipeline/articles/{id}/requeue`, `POST /delivery/cms/push` and the seven paid SEO scans \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

```bash
curl -X PATCH https://api.writavo.com/v1/billing/site-credit-cap \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -d '{
  "monthly_credit_cap": "<monthly_credit_cap>"
}'
```
