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.
{
"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.
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"
} 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.
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.
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.