---
title: "Start a hosted checkout to upgrade a key"
method: POST
path: "/v1/billing/checkout"
tags: ["Billing"]
---

# Start a hosted checkout to upgrade a key

`POST /v1/billing/checkout`

## Request

- Base URL: `https://api.attestwire.com`
- URL: `https://api.attestwire.com/v1/billing/checkout`
- Auth: one of:
  - none
  - HTTP bearer

## Request body

- object
  - `key` string: The API key to upgrade. May be sent as an `Authorization: Bearer` header instead, which wins if both are present.
  - `plan` 'solo' | 'starter' | 'scale', required
  - `period` 'monthly' | 'annual': How often the subscription bills. Omit for monthly — the field is optional precisely so every integration written before annual prices existed keeps working unchanged. An annual price that this deployment does not sell is refused with `400 bad_request`; it is never silently downgraded to the monthly price.

## Response `200`

Redirect the customer to `url`.

- object
  - `url` string, uri: Paddle-hosted checkout page. Send the customer here. Nothing is charged until they complete it, and an abandoned one is never billed.
  - `transaction_id` string: The Paddle transaction this checkout will settle (`txn_…`). Useful for correlating a support question with a Paddle dashboard row; it is not a credential and grants nothing.
  - `plan` 'solo' | 'starter' | 'scale'
  - `period` 'monthly' | 'annual': The billing period this checkout was created for. Echoed back even when the request omitted it, so a caller never has to infer what it just created.
  - `portal_endpoint` string: The ROUTE that mints a billing-portal link — not a link. Always `/v1/billing/portal`. Call it with the customer's own key and it returns a one-time URL where they change plan, update the card, download invoices and cancel. It is an endpoint rather than a URL because Paddle has no static portal page: each link is a session bound to one customer and it expires, so there is nothing durable to publish here, and minting one on every `/v1/usage` poll would spend a live API call on a link nobody opens. Present on `/v1/usage` only for keys that actually have a subscription — a free key has nothing to manage, and pointing one at a route that will answer 409 is worse than saying nothing. Absent, never null.

## Other responses

- `400`: Unknown plan, or malformed body.
- `401`: Unknown API key.
- `409`: This key already has a subscription. A second checkout would create a second subscription and bill for both plans; change plan in the billing portal instead, where the switch is prorated.
- `410`: The key presented was retired by an earlier rotation. Its replacement is the live credential; this one is permanently gone, so retrying with it cannot work. Every authenticated endpoint answers this, not just /v1/keys/rotate.
- `413`: The request body is over 1 MB. Nothing was read and nothing was charged.
- `500`: Unhandled fault while creating the checkout transaction. Never expected; the body carries no detail on purpose (a fault message can quote the offending value). Retry, and email hello@attestwire.com if it persists.
- `502`: The payment provider refused the request. Nothing was created; retry shortly. A Paddle account still awaiting verification answers this way, and so does a transaction created with no checkout URL behind it.
- `503`: Billing is not enabled on this deployment.

---

[API](https://skmtc.dev/attestwire/apis/attestwire-api.md) · [All operations](https://skmtc.dev/attestwire/apis/attestwire-api/llms.txt) · [OpenAPI document](https://skmtc.dev/attestwire/apis/attestwire-api/revisions/0036f8e043b2?raw)
