Conventions

Read this page once and the rest of the reference will be predictable. Every endpoint shares the same envelope, the same error shape, and the same retry rules.

The response envelope

Every endpoint returns the same wrapper. The resource you asked for is always under data.

Response envelope
{
  "success": true,
  "data": { },
  "error": null,
  "context": {
    "affected_entities": ["organization:0e4c…"],
    "suggested_next_actions": ["list_proxies"],
    "warnings": []
  },
  "request_id": null,
  "timestamp": "2026-02-18T09:31:04.512Z"
}
Name Type Description
success boolean Whether the action completed. Check this before reading data.
data object | null The resource. Null on failure.
error object | null Present only when success is false. Carries code, message, and usually suggestion.
context object | null Optional hints: affected_entities, suggested_next_actions, warnings. Safe to ignore.
request_id string | null Trace id. Include it when you contact support about a specific call.
timestamp string ISO 8601 time the response was generated.

Status codes

This is the one place the API will surprise you, so it is worth stating plainly: a request that was well-formed and authenticated returns HTTP 200 even when the action was refused. The refusal is in the body.

HTTP Description
200 The request was understood. Read success to find out whether the action happened.
401 The API key is missing its prefix, unknown, revoked, or expired.
403 No Authorization header at all, or the resource in the path belongs to another organization.
404 A proxy assignment id in the path does not exist in your organization.
422 The body or query string failed schema validation. detail names the field.
429 Rate limit exceeded. Wait for Retry-After.
5xx Something broke on our side. Safe to retry with backoff.

The two error shapes differ, which matters if you parse errors in one place. Business failures put the code in error.code; HTTP-level failures put it in detail.code.

Business failure
HTTP/1.1 200 OK

{
  "success": false,
  "data": null,
  "error": {
    "code": "LAST_ACCOUNT_CANCEL_ONLY",
    "message": "The last live account cannot be removed.",
    "field": null,
    "details": null,
    "suggestion": "Cancel the organization plan in the billing portal."
  },
  "timestamp": "2026-02-18T09:31:04.512Z"
}
Transport failure
HTTP/1.1 401 Unauthorized

{
  "detail": {
    "code": "API_KEY_REVOKED",
    "message": "This API key has been revoked.",
    "suggestion": "Generate a new key in the dashboard."
  }
}

Pagination

Only GET /invoices paginates, because only invoices can grow without bound. It uses cursors rather than page numbers, so a new invoice arriving mid-walk does not shift rows into a page you already read.

limit Page size, 1–100. Defaults to 10.
starting_after An invoice id from the previous page. Results are newest-first, so this walks backwards in time.
has_more Response field. Keep going while it is true.

To walk the whole history, pass the last id of each page as the next starting_after and stop when has_more is false.

Walk every invoice
import os

import requests

API_KEY = os.environ["PROXYCHARM_API_KEY"]
BASE_URL = "https://api.proxycharm.com/api/v1/public"

cursor = None
invoices = []

while True:
    params = {"limit": 100}
    if cursor:
        params["starting_after"] = cursor

    payload = requests.get(
        f"{BASE_URL}/invoices",
        headers={"Authorization": f"Bearer {API_KEY}"},
        params=params,
        timeout=30,
    ).json()

    page = payload["data"]
    invoices.extend(page["items"])

    if not page["has_more"]:
        break
    cursor = page["items"][-1]["id"]

print(len(invoices))

The other list endpoints return every row in one response. GET /workspaces and GET /proxies are bounded by your plan size, so there is nothing to page through.

Filtering

Filtering is per-endpoint rather than a general query language.

Endpoint Name Description
GET /proxies account_id Restrict to one workspace. Equivalent to GET /workspaces/{id}/proxies.
GET /invoices status One of open, paid, uncollectible, void. Draft invoices are never returned.

Sorting

There is no sort parameter. Invoices come back newest-first; other lists come back in creation order. Sort client-side if you need a different order.

Idempotency

POST /workspaces is the only endpoint that creates a billable resource, so it is the only one that takes an idempotency key. Send one whenever a retry could otherwise double-charge you.

Put a unique value — a UUID works — in idempotency_key. If you retry with the same key, the original workspace is returned instead of a second one being created.

POST /workspaces with an idempotency key
curl https://api.proxycharm.com/api/v1/public/workspaces \
  -X POST \
  -H "Authorization: Bearer $PROXYCHARM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "name": "Scraper EU",
       "slots": [{ "country": "DE" }],
       "idempotency_key": "6f9619ff-8b86-d011-b42d-00c04fc964ff"
     }'
Scope The key is remembered against the workspace it created, for the lifetime of that workspace.
Reuse across organizations A key already used by another organization returns ADD_ACCOUNT_FAILED. Generate a fresh key rather than reusing one.
Removal DELETE /workspaces/{id} is naturally idempotent — a second delete returns ACCOUNT_NOT_REMOVABLE.
Reads All GET endpoints are side-effect free. Retry them freely.

Timestamps, ids, and money

Timestamps ISO 8601 strings in UTC. Fields that may be unknown are nullable rather than zero.
Ids UUID strings for our own records. Invoice ids are provider ids and start with in_.
Money Always a minor-unit integer in amount_cents plus a lowercase ISO 4217 currency. amount_display is a pre-formatted convenience string — parse amount_cents for arithmetic.

Versioning

The version lives in the path (/api/v1/public). We treat adding a response field or an optional parameter as backwards-compatible, so build your client to ignore fields it does not recognise. Removing a field or changing its meaning would ship as a new version path, and v1 would keep working.