# Billing API reference

> HTML version: https://storeshot.art/docs/api/billing/

Buy credit packs (hosted checkout). Contract 1.18.0; base URL `https://api.storeshot.art/v1`. Schemas: https://storeshot.art/docs/api/schemas.md

## GET /billing/packs

**List credit packs** (operation `listBillingPacks`). Auth: API key with scope `billing:read` or `billing:write`, or access token.

Requires `billing:read` or `billing:write` (not default scopes). The one-time credit packs on sale, with list prices in cents; taxes are applied at checkout by the payment provider (merchant of record). Empty while web payments are switched off. Any key environment may list.

**Credits.** Free: no credits.

**Responses:**

| Status | Description | Schema | Headers |
|---|---|---|---|
| `200` | The packs on sale. | `BillingPackList` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` |
| `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` |
| `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |

Example 200 response:

```json
{
  "data": [
    {
      "id": "credits_15",
      "name": "Storeshot API: 15 credits",
      "credits": 15,
      "priceAmount": 399,
      "currency": "usd"
    }
  ]
}
```

## POST /billing/checkout

**Start a checkout** (operation `createBillingCheckout`). Auth: API key with scope `billing:write`, or access token.

Requires `billing:write` (not a default scope) and a live credential. Creates a hosted checkout for one pack and returns its `url`: send the buyer there. Credits are added to `purchased` once the payment succeeds (poll `GET /billing/checkouts/{id}`); a refund takes back what is still unused. Checkouts expire after 24 hours. At most 10 per account per hour. Send an `Idempotency-Key` so a retried request never opens two checkouts.

**When to use.** Needs the non-default `billing:write` scope: to hand your human a payment `url` for a credit pack. Without it, give them the `details.topUpUrl` of the `402`. Never pay yourself.

**Credits.** Free: no credits. Payments happen on the hosted page, never through the API. A paid checkout adds the pack's credits.

**Parameters:**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `idempotency-key` | header | `string` | no | 1–255 printable ASCII characters. Retrying with the same key and body returns the original checkout (`Idempotent-Replayed: true`) instead of creating another. |

**Request body** (`application/json`, required, schema `CreateBillingCheckoutRequest`):

| Field | Type | Required | Description |
|---|---|---|---|
| `pack` | `string` | yes |  |
| `successUrl` | `string` | no | Where the buyer lands after paying. Default: a static confirmation page. `https://` on storeshot.art, a storeshot.art subdomain or this API host; at most 2048 characters. Format: uri |
| `returnUrl` | `string` | no | Where "back" on the checkout page leads. Default: the credits page. `https://` on storeshot.art, a storeshot.art subdomain or this API host; at most 2048 characters. Format: uri |

Example request body:

```json
{
  "pack": "credits_15",
  "successUrl": "https://example.com",
  "returnUrl": "https://example.com"
}
```

**Responses:**

| Status | Description | Schema | Headers |
|---|---|---|---|
| `201` | Created (or, with `Idempotent-Replayed: true`, the checkout created earlier with this key and body). | `BillingCheckout` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Idempotent-Replayed` |
| `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` |
| `403` | `FORBIDDEN`: `insufficient_scope`, `live_key_required` or `billing_account_flagged` (`details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `409` | `IDEMPOTENCY_CONFLICT`: the key was used for a different body, or that checkout is still being created (`details.checkoutId`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `413` | `PAYLOAD_TOO_LARGE`. | `ErrorEnvelope` | `X-Request-Id` |
| `429` | `RATE_LIMITED`: request rate (see `details.scope`), or 10 checkouts in the last hour (`details.policy` `billing_checkouts`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` |
| `503` | `BILLING_UNAVAILABLE`: `disabled`, `not_configured` or `provider_error` (`details.reason`). Nothing was charged. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` |
| `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |

Example 201 response:

```json
{
  "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d",
  "status": "open",
  "pack": "credits_15",
  "credits": 0,
  "priceAmount": 399,
  "currency": "usd",
  "url": "https://buy.polar.sh/polar_c_abc123",
  "expiresAt": "2026-10-29T12:00:00.000Z",
  "createdAt": "2026-10-29T12:00:00.000Z",
  "paidAt": "2026-10-29T12:00:00.000Z",
  "creditsGranted": 0,
  "creditsReversed": 0,
  "kind": "subscription",
  "plan": "pro",
  "planName": "Storeshot API Pro",
  "subscriptionStatus": "active"
}
```

## GET /billing/checkouts/{id}

**Get a checkout** (operation `getBillingCheckout`). Auth: API key with scope `billing:read` or `billing:write`, or access token.

Requires `billing:read` or `billing:write` and a live credential. The checkout status, derived from the payment provider: `open`, `processing`, `paid`, `partially_refunded`, `refunded`, `expired` or `failed`, with the credits granted and reversed so far. Only the account that created it can read it. `{id}` is our checkout id or the payment provider's checkout id (the `{CHECKOUT_ID}` placeholder of `successUrl`), so a success page can look the checkout up straight from its URL. Plan checkouts also return `planName` and `subscriptionStatus`.

**Credits.** Free: no credits.

**Parameters:**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | `string` | yes | Our checkout id (`id`) or the payment provider's checkout id (what `{CHECKOUT_ID}` in your `successUrl` becomes); our id wins if both match. An unknown, foreign or malformed id is 404. |

**Responses:**

| Status | Description | Schema | Headers |
|---|---|---|---|
| `200` | The checkout. | `BillingCheckout` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` |
| `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` |
| `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |

Example 200 response:

```json
{
  "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d",
  "status": "open",
  "pack": "credits_15",
  "credits": 0,
  "priceAmount": 399,
  "currency": "usd",
  "url": "https://buy.polar.sh/polar_c_abc123",
  "expiresAt": "2026-10-29T12:00:00.000Z",
  "createdAt": "2026-10-29T12:00:00.000Z",
  "paidAt": "2026-10-29T12:00:00.000Z",
  "creditsGranted": 0,
  "creditsReversed": 0,
  "kind": "subscription",
  "plan": "pro",
  "planName": "Storeshot API Pro",
  "subscriptionStatus": "active"
}
```

## GET /billing/plans

**List plans** (operation `listBillingPlans`). Auth: API key with scope `billing:read` or `billing:write`, or access token.

Requires `billing:read` or `billing:write` (not default scopes). The monthly plans on sale, with list prices in cents (taxes are applied at checkout) and the limits each plan raises. Each paid month resets the `subscription` balance to the plan credits; subscription credits are used before starter and purchased credits and expire at renewal. Empty while plans are switched off. Any key environment may list.

**Credits.** Free: no credits.

**Responses:**

| Status | Description | Schema | Headers |
|---|---|---|---|
| `200` | The plans on sale. | `BillingPlanList` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` |
| `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` |
| `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |

Example 200 response:

```json
{
  "data": [
    {
      "id": "pro",
      "name": "Storeshot API Pro",
      "credits": 60,
      "priceAmount": 1200,
      "currency": "usd",
      "interval": "month",
      "limits": {}
    }
  ]
}
```

## GET /billing/subscription

**Get the plan of this account** (operation `getBillingSubscription`). Auth: API key with scope `billing:read` or `billing:write`, or access token.

Requires `billing:read` or `billing:write`. The tier whose limits apply now (`free` without a plan), those limits, and the latest subscription with its `subscription` balance. Any key environment may read.

**Credits.** Free: no credits.

**Responses:**

| Status | Description | Schema | Headers |
|---|---|---|---|
| `200` | The plan state. | `BillingSubscriptionState` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` |
| `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` |
| `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |

Example 200 response:

```json
{
  "plan": "free",
  "limits": {
    "requestsPerMinutePerKey": 0,
    "requestsPerMinutePerUser": 0,
    "concurrentGenerations": 0,
    "dailyGenerations": 0,
    "maxStoredUploads": 0,
    "maxStoredUploadMegabytes": 0
  },
  "subscription": {
    "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d",
    "plan": "pro",
    "status": "active",
    "cancelAtPeriodEnd": true,
    "currentPeriodStart": "2026-10-29T12:00:00.000Z",
    "currentPeriodEnd": "2026-10-29T12:00:00.000Z",
    "pendingPlan": "pro",
    "creditsPerPeriod": 0,
    "subscriptionCredits": 0
  }
}
```

## POST /billing/subscription/checkout

**Start a plan checkout** (operation `createBillingSubscriptionCheckout`). Auth: API key with scope `billing:write`, or access token.

Requires `billing:write` and a live credential. Creates a hosted checkout for a monthly plan and returns its `url`. Once the first payment succeeds the plan applies and the `subscription` balance is set to the plan credits (poll `GET /billing/subscription`). One plan per account: an account with a plan changes it instead. Asking again for the same plan within 15 minutes returns the open checkout; another plan replaces it. At most 10 checkouts per account per hour (packs included). Send an `Idempotency-Key` so a retried request never opens two checkouts.

**Credits.** Free: no credits. Payments happen on the hosted page, never through the API. Each paid month resets the plan credits.

**Parameters:**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `idempotency-key` | header | `string` | no | 1–255 printable ASCII characters. Retrying with the same key and body returns the original checkout (`Idempotent-Replayed: true`) instead of creating another. |

**Request body** (`application/json`, required, schema `CreateSubscriptionCheckoutRequest`):

| Field | Type | Required | Description |
|---|---|---|---|
| `plan` | `string` | yes | One of: "pro", "agency", "scale" |
| `successUrl` | `string` | no | Where the buyer lands after paying. Default: a static confirmation page. `https://` on storeshot.art, a storeshot.art subdomain or this API host; at most 2048 characters. Format: uri |
| `returnUrl` | `string` | no | Where "back" on the checkout page leads. Default: the credits page. `https://` on storeshot.art, a storeshot.art subdomain or this API host; at most 2048 characters. Format: uri |

Example request body:

```json
{
  "plan": "pro",
  "successUrl": "https://example.com",
  "returnUrl": "https://example.com"
}
```

**Responses:**

| Status | Description | Schema | Headers |
|---|---|---|---|
| `201` | Created (or, with `Idempotent-Replayed: true`, the open checkout created earlier for this plan or this key). | `BillingCheckout` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Idempotent-Replayed` |
| `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` |
| `403` | `FORBIDDEN`: `insufficient_scope`, `live_key_required` or `billing_account_flagged` (`details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `409` | `BILLING_STATE_CONFLICT` (`subscription_exists`, `checkout_pending`) or `IDEMPOTENCY_CONFLICT`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `413` | `PAYLOAD_TOO_LARGE`. | `ErrorEnvelope` | `X-Request-Id` |
| `429` | `RATE_LIMITED`: request rate (see `details.scope`), or 10 checkouts in the last hour (`details.policy` `billing_checkouts`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` |
| `503` | `BILLING_UNAVAILABLE`: `disabled`, `not_configured` or `provider_error` (`details.reason`). Nothing changed. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` |
| `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |

Example 201 response:

```json
{
  "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d",
  "status": "open",
  "pack": "credits_15",
  "credits": 0,
  "priceAmount": 399,
  "currency": "usd",
  "url": "https://buy.polar.sh/polar_c_abc123",
  "expiresAt": "2026-10-29T12:00:00.000Z",
  "createdAt": "2026-10-29T12:00:00.000Z",
  "paidAt": "2026-10-29T12:00:00.000Z",
  "creditsGranted": 0,
  "creditsReversed": 0,
  "kind": "subscription",
  "plan": "pro",
  "planName": "Storeshot API Pro",
  "subscriptionStatus": "active"
}
```

## POST /billing/subscription/change

**Change the plan** (operation `changeBillingSubscription`). Auth: API key with scope `billing:write`, or access token.

Requires `billing:write` and a live credential. A higher plan applies at once: the prorated difference is charged now and, once paid, the `subscription` balance grows by the extra credits for the rest of the period. A lower plan starts at the next renewal (`pendingPlan`). Asking for the current plan while a downgrade is pending cancels the downgrade.

**Credits.** Free: no credits. Payments happen on the hosted page, never through the API.

**Request body** (`application/json`, required, schema `ChangeSubscriptionRequest`):

| Field | Type | Required | Description |
|---|---|---|---|
| `plan` | `string` | yes | One of: "pro", "agency", "scale" |

Example request body:

```json
{
  "plan": "agency"
}
```

**Responses:**

| Status | Description | Schema | Headers |
|---|---|---|---|
| `200` | The plan state after the change (credits move only when the provider reports payment). | `BillingSubscriptionState` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` |
| `403` | `FORBIDDEN`: `insufficient_scope` or `live_key_required` (`details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `409` | `BILLING_STATE_CONFLICT`: `no_subscription`, `same_plan`, `canceling` or `past_due` (`details.reason`). Nothing was charged. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `413` | `PAYLOAD_TOO_LARGE`. | `ErrorEnvelope` | `X-Request-Id` |
| `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` |
| `503` | `BILLING_UNAVAILABLE`: `disabled`, `not_configured` or `provider_error` (`details.reason`). Nothing changed. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` |
| `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |

Example 200 response:

```json
{
  "plan": "free",
  "limits": {
    "requestsPerMinutePerKey": 0,
    "requestsPerMinutePerUser": 0,
    "concurrentGenerations": 0,
    "dailyGenerations": 0,
    "maxStoredUploads": 0,
    "maxStoredUploadMegabytes": 0
  },
  "subscription": {
    "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d",
    "plan": "pro",
    "status": "active",
    "cancelAtPeriodEnd": true,
    "currentPeriodStart": "2026-10-29T12:00:00.000Z",
    "currentPeriodEnd": "2026-10-29T12:00:00.000Z",
    "pendingPlan": "pro",
    "creditsPerPeriod": 0,
    "subscriptionCredits": 0
  }
}
```

## POST /billing/subscription/cancel

**Cancel the plan at the period end** (operation `cancelBillingSubscription`). Auth: API key with scope `billing:write`, or access token.

Requires `billing:write` and a live credential. The plan, its limits and its credits stay until the period ends; nothing renews after that. Works while plans are switched off, so a subscriber can always stop paying.

**Credits.** Free: no credits. Payments happen on the hosted page, never through the API.

**Responses:**

| Status | Description | Schema | Headers |
|---|---|---|---|
| `200` | The plan state after the change (credits move only when the provider reports payment). | `BillingSubscriptionState` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` |
| `403` | `FORBIDDEN`: `insufficient_scope` or `live_key_required` (`details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `409` | `BILLING_STATE_CONFLICT`: `no_subscription` (`details.reason`). Nothing was charged. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` |
| `503` | `BILLING_UNAVAILABLE`: `disabled`, `not_configured` or `provider_error` (`details.reason`). Nothing changed. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` |
| `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |

Example 200 response:

```json
{
  "plan": "free",
  "limits": {
    "requestsPerMinutePerKey": 0,
    "requestsPerMinutePerUser": 0,
    "concurrentGenerations": 0,
    "dailyGenerations": 0,
    "maxStoredUploads": 0,
    "maxStoredUploadMegabytes": 0
  },
  "subscription": {
    "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d",
    "plan": "pro",
    "status": "active",
    "cancelAtPeriodEnd": true,
    "currentPeriodStart": "2026-10-29T12:00:00.000Z",
    "currentPeriodEnd": "2026-10-29T12:00:00.000Z",
    "pendingPlan": "pro",
    "creditsPerPeriod": 0,
    "subscriptionCredits": 0
  }
}
```

## POST /billing/subscription/uncancel

**Keep the plan (undo a cancel)** (operation `uncancelBillingSubscription`). Auth: API key with scope `billing:write`, or access token.

Requires `billing:write` and a live credential. Undoes a cancel before the period ends; the plan renews as before.

**Credits.** Free: no credits. Payments happen on the hosted page, never through the API.

**Responses:**

| Status | Description | Schema | Headers |
|---|---|---|---|
| `200` | The plan state after the change (credits move only when the provider reports payment). | `BillingSubscriptionState` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` |
| `403` | `FORBIDDEN`: `insufficient_scope` or `live_key_required` (`details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `409` | `BILLING_STATE_CONFLICT`: `no_subscription` (`details.reason`). Nothing was charged. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` |
| `503` | `BILLING_UNAVAILABLE`: `disabled`, `not_configured` or `provider_error` (`details.reason`). Nothing changed. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` |
| `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |

Example 200 response:

```json
{
  "plan": "free",
  "limits": {
    "requestsPerMinutePerKey": 0,
    "requestsPerMinutePerUser": 0,
    "concurrentGenerations": 0,
    "dailyGenerations": 0,
    "maxStoredUploads": 0,
    "maxStoredUploadMegabytes": 0
  },
  "subscription": {
    "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d",
    "plan": "pro",
    "status": "active",
    "cancelAtPeriodEnd": true,
    "currentPeriodStart": "2026-10-29T12:00:00.000Z",
    "currentPeriodEnd": "2026-10-29T12:00:00.000Z",
    "pendingPlan": "pro",
    "creditsPerPeriod": 0,
    "subscriptionCredits": 0
  }
}
```

## POST /billing/portal

**Open the billing portal** (operation `createBillingPortalSession`). Auth: API key with scope `billing:write`, or access token.

Requires `billing:write` and a live credential. Returns a link to the payment provider portal (invoices, payment method, cancel). `mode: session` is signed in as this account; it is offered only when the payment account belongs to this account email. Otherwise `mode: email_login` asks for the billing email and sends a one-time code.

**Credits.** Free: no credits. Payments happen on the hosted page, never through the API.

**Request body** (`application/json`, optional, schema `CreatePortalSessionRequest`):

| Field | Type | Required | Description |
|---|---|---|---|
| `returnUrl` | `string` | no | Where "back" in the portal leads. Default: the credits page. `https://` on storeshot.art, a storeshot.art subdomain or this API host; at most 2048 characters. Format: uri |

Example request body:

```json
{
  "returnUrl": "https://example.com"
}
```

**Responses:**

| Status | Description | Schema | Headers |
|---|---|---|---|
| `200` | The portal link. | `BillingPortalSession` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` |
| `403` | `FORBIDDEN`: `insufficient_scope` or `live_key_required` (`details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `409` | `BILLING_STATE_CONFLICT`: `no_customer` (`details.reason`). Nothing was charged. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `413` | `PAYLOAD_TOO_LARGE`. | `ErrorEnvelope` | `X-Request-Id` |
| `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` |
| `503` | `BILLING_UNAVAILABLE`: `disabled`, `not_configured` or `provider_error` (`details.reason`). Nothing changed. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` |
| `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |

Example 200 response:

```json
{
  "url": "https://example.com",
  "expiresAt": "2026-10-29T12:00:00.000Z",
  "mode": "session"
}
```
