# Billing API reference

> Buy credits from the API. When a request fails with `402 insufficient_credits`:
`GET /credit_packs` → `POST /checkouts` → send the user the `checkout_url` → poll
`GET /checkouts/{id}` until `status` is `completed` → retry the original request with the same
`Idempotency-Key`. See [Buying credits](#section/Buying-credits).

Source: https://signwith.co/docs/api/billing · Updated 2026-10-02

## List credit packs

`GET https://app.signwith.co/api/v1/credit_packs`

Read-only keys can call this.

Lists the credit packs that can be bought, cheapest first. Call it after a
`402 insufficient_credits` (or when `GET /credits` shows `can_send: false`) to show the user
their options before starting a checkout. The lifetime deal (`unlimited: true`) is left out
for users who already have it. Returns every pack in one page (`has_more` is always `false`).

### Request sample

cURL:

```bash
curl https://app.signwith.co/api/v1/credit_packs \
  -H "Authorization: Bearer $SIGNWITH_API_KEY"
```

Node:

```javascript
const response = await fetch('https://app.signwith.co/api/v1/credit_packs', {
  headers: {
    Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`,
  },
})

console.log(response.status, await response.json())
```

Python:

```python
import os

import requests

response = requests.get(
    "https://app.signwith.co/api/v1/credit_packs",
    headers={
        "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}",
    },
)

print(response.status_code, response.json())
```

### Response 200

The credit packs on sale.

```json
{
  "object": "list",
  "data": [
    {
      "object": "credit_pack",
      "id": 3,
      "name": "Basic",
      "description": "10 documents",
      "credits": 10,
      "unlimited": false,
      "price": "9.00",
      "currency": "USD",
      "price_per_credit": "0.90"
    },
    {
      "object": "credit_pack",
      "id": 4,
      "name": "Pro",
      "description": "25 documents",
      "credits": 25,
      "unlimited": false,
      "price": "19.00",
      "currency": "USD",
      "price_per_credit": "0.76"
    },
    {
      "object": "credit_pack",
      "id": 5,
      "name": "Business",
      "description": "50 documents",
      "credits": 50,
      "unlimited": false,
      "price": "29.00",
      "currency": "USD",
      "price_per_credit": "0.58"
    },
    {
      "object": "credit_pack",
      "id": 9,
      "name": "Lifetime deal",
      "description": "Unlimited documents, forever",
      "credits": null,
      "unlimited": true,
      "price": "149.00",
      "currency": "USD",
      "price_per_credit": null
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

### Errors

| Status | Meaning |
| --- | --- |
| 401 | The API key is missing, invalid or expired, or its account has been archived. |
| 429 | Rate limit exceeded (`rate_limited`, with `Retry-After`); more than 20 `POST /feedback` messages in an hour (`rate_limited`, with `Retry-After`: seconds until the next clock hour); or — for `POST /signature_requests/{id}/remind` — signers were reminded less than an hour ago (`remind_too_soon`, with `Retry-After`).  |
| 500 | Something went wrong on SignWith's side. It's safe to retry with the same `Idempotency-Key`. |

## Start a credit purchase

`POST https://app.signwith.co/api/v1/checkouts`

Needs a full-access key. Accepts `Idempotency-Key`.

Starts buying a credit pack and returns a hosted `checkout_url` for the user to pay on.
Payment happens entirely on the payment provider's page; credits are added once the provider
confirms the payment. Poll [`GET /checkouts/{id}`](#operation/getCheckout) to find out when
that happens, then retry the request that failed with `402 insufficient_credits`.

An optional `discount_code` applies a discount if it is active, not used up and valid for
the chosen pack (`422 invalid_discount_code` otherwise); the returned `credit_pack` then
describes the discounted offer. Requires a full-access key and a billing country on the
user's SignWith profile (`422 billing_details_required`). Buying the lifetime deal twice
returns `422 already_purchased`. If the payment provider can't start the payment the API
returns `502 payment_provider_error`.

Each call starts a new checkout, so send an `Idempotency-Key` to make retries safe.

### Headers

| Name | Type | Description |
| --- | --- | --- |
| `Idempotency-Key` | string | A unique value (e.g. a UUID) that makes retries of this request safe. A successful response is stored for 24 hours and replayed for retries with the same key.  |

### Request body (`application/json`)

- `credit_pack_id` (integer, required). ID of a pack from `GET /credit_packs`.
- `discount_code` (string). Optional discount code for this pack.

### Request sample

cURL:

```bash
curl -X POST https://app.signwith.co/api/v1/checkouts \
  -H "Authorization: Bearer $SIGNWITH_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "credit_pack_id": 4
}'
```

Node:

```javascript
const response = await fetch('https://app.signwith.co/api/v1/checkouts', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID(),
  },
  body: JSON.stringify({
    credit_pack_id: 4,
  }),
})

console.log(response.status, await response.json())
```

Python:

```python
import os
import uuid

import requests

response = requests.post(
    "https://app.signwith.co/api/v1/checkouts",
    headers={
        "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "credit_pack_id": 4,
    },
)

print(response.status_code, response.json())
```

### Response 201

The checkout was started. Send the user to `checkout_url`.

```json
{
  "object": "checkout",
  "id": "3f6b2c1a-8d4e-4b7a-9c20-5e1f7a9d3b84",
  "status": "pending",
  "credit_pack": {
    "object": "credit_pack",
    "id": 4,
    "name": "Business",
    "description": "50 documents",
    "credits": 50,
    "unlimited": false,
    "price": "29.00",
    "currency": "USD",
    "price_per_credit": "0.58"
  },
  "amount": "29.00",
  "currency": "USD",
  "discount_code": null,
  "checkout_url": "https://checkout.dodopayments.com/buy/pl_2x7Kq9mTbR4vLw8N",
  "completed_at": null,
  "created_at": "2026-09-27T11:20:31Z"
}
```

### Errors

| Status | Meaning |
| --- | --- |
| 400 | The request body is not valid JSON. |
| 401 | The API key is missing, invalid or expired, or its account has been archived. |
| 403 | The key is read-only, or its user can't access this resource. |
| 404 | The resource doesn't exist or belongs to another account. |
| 409 | A request with the same `Idempotency-Key` is still being processed. Retry shortly. |
| 422 | The request is valid JSON but can't be processed. See `error.code`; the codes each endpoint can return are described in the endpoint's description and in the error table above.  |
| 429 | Rate limit exceeded (`rate_limited`, with `Retry-After`); more than 20 `POST /feedback` messages in an hour (`rate_limited`, with `Retry-After`: seconds until the next clock hour); or — for `POST /signature_requests/{id}/remind` — signers were reminded less than an hour ago (`remind_too_soon`, with `Retry-After`).  |
| 500 | Something went wrong on SignWith's side. It's safe to retry with the same `Idempotency-Key`. |
| 502 | The payment provider couldn't start the checkout. Nothing was charged; try again later. |

## Get a checkout

`GET https://app.signwith.co/api/v1/checkouts/{id}`

Read-only keys can call this.

Returns a checkout's status. Poll it after sending the user the `checkout_url`:
`pending` means the user hasn't paid yet (or the payment is still being confirmed),
`completed` means the credits have been added, and `failed` means the payment failed or was
canceled — start a new checkout to try again. `checkout_url` is only returned while the
checkout is `pending`. Only checkouts started by the key's user can be read.

### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | string (uuid) | Checkout ID (the `id` returned by `POST /checkouts`). |

### Request sample

cURL:

```bash
curl https://app.signwith.co/api/v1/checkouts/3f6b2c1a-8d4e-4b7a-9c20-5e1f7a9d3b84 \
  -H "Authorization: Bearer $SIGNWITH_API_KEY"
```

Node:

```javascript
const response = await fetch('https://app.signwith.co/api/v1/checkouts/3f6b2c1a-8d4e-4b7a-9c20-5e1f7a9d3b84', {
  headers: {
    Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`,
  },
})

console.log(response.status, await response.json())
```

Python:

```python
import os

import requests

response = requests.get(
    "https://app.signwith.co/api/v1/checkouts/3f6b2c1a-8d4e-4b7a-9c20-5e1f7a9d3b84",
    headers={
        "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}",
    },
)

print(response.status_code, response.json())
```

### Response 200

The checkout.

```json
{
  "object": "checkout",
  "id": "3f6b2c1a-8d4e-4b7a-9c20-5e1f7a9d3b84",
  "status": "pending",
  "credit_pack": {
    "object": "credit_pack",
    "id": 4,
    "name": "Business",
    "description": "50 documents",
    "credits": 50,
    "unlimited": false,
    "price": "29.00",
    "currency": "USD",
    "price_per_credit": "0.58"
  },
  "amount": "29.00",
  "currency": "USD",
  "discount_code": null,
  "checkout_url": "https://checkout.dodopayments.com/buy/pl_2x7Kq9mTbR4vLw8N",
  "completed_at": null,
  "created_at": "2026-09-27T11:20:31Z"
}
```

```json
{
  "object": "checkout",
  "id": "3f6b2c1a-8d4e-4b7a-9c20-5e1f7a9d3b84",
  "status": "completed",
  "credit_pack": {
    "object": "credit_pack",
    "id": 4,
    "name": "Business",
    "description": "50 documents",
    "credits": 50,
    "unlimited": false,
    "price": "29.00",
    "currency": "USD",
    "price_per_credit": "0.58"
  },
  "amount": "29.00",
  "currency": "USD",
  "discount_code": null,
  "checkout_url": null,
  "completed_at": "2026-09-27T11:24:09Z",
  "created_at": "2026-09-27T11:20:31Z"
}
```

### Errors

| Status | Meaning |
| --- | --- |
| 401 | The API key is missing, invalid or expired, or its account has been archived. |
| 404 | The resource doesn't exist or belongs to another account. |
| 429 | Rate limit exceeded (`rate_limited`, with `Retry-After`); more than 20 `POST /feedback` messages in an hour (`rate_limited`, with `Retry-After`: seconds until the next clock hour); or — for `POST /signature_requests/{id}/remind` — signers were reminded less than an hour ago (`remind_too_soon`, with `Retry-After`).  |
| 500 | Something went wrong on SignWith's side. It's safe to retry with the same `Idempotency-Key`. |

## The credit pack object

Anchor: https://signwith.co/docs/api/billing#the-credit-pack-object

- `object` (string, required, always `credit_pack`).
- `id` (integer, required).
- `name` (string, required).
- `description` (string or null, required).
- `credits` (integer or null, required). Credits added when bought. `null` for the lifetime deal.
- `unlimited` (boolean, required). `true` for the lifetime deal, which removes the credit limit.
- `price` (string, required). Price as a decimal string with two places.
- `currency` (string, required, always `USD`).
- `price_per_credit` (string or null, required). `price` divided by `credits`, as a decimal string. `null` for the lifetime deal.

## The checkout object

Anchor: https://signwith.co/docs/api/billing#the-checkout-object

- `object` (string, required, always `checkout`).
- `id` (string (uuid), required). Checkout ID. Use it with `GET /checkouts/{id}`.
- `status` (string, required, one of `pending`, `completed`, `failed`). `pending` — waiting for payment or confirmation; `completed` — paid and credits added; `failed` — the payment failed or was canceled.
- `credit_pack` (object, required). The pack being bought (the discounted offer when a `discount_code` was applied).
  - `object` (string, required, always `credit_pack`).
  - `id` (integer, required).
  - `name` (string, required).
  - `description` (string or null, required).
  - `credits` (integer or null, required). Credits added when bought. `null` for the lifetime deal.
  - `unlimited` (boolean, required). `true` for the lifetime deal, which removes the credit limit.
  - `price` (string, required). Price as a decimal string with two places.
  - `currency` (string, required, always `USD`).
  - `price_per_credit` (string or null, required). `price` divided by `credits`, as a decimal string. `null` for the lifetime deal.
- `amount` (string, required). Amount charged, as a decimal string.
- `currency` (string, required, always `USD`).
- `discount_code` (string or null, required).
- `checkout_url` (string or null, required, URL). Hosted payment page to send the user to. Only present while `status` is `pending`.
- `completed_at` (string (date-time) or null, required). When the payment was confirmed.
- `created_at` (string (date-time), required).
