ProxioDocs
API Reference

Whitelist (IP Auth)

Bind source IPs to a Proxio credential for passwordless authentication, with optional default geo and session settings. List, add, batch add, and remove bindings. Up to 50 per credential, 30 additions per minute. Covers INVALID_IP, IP_ALREADY_BOUND, and IP_UNAVAILABLE.

IP authentication lets a credential authenticate by source IP instead of a password: connect from a whitelisted IP and the gateway trusts you without credentials in the proxy URL. Each binding can also carry default geo and session settings applied when the request doesn't specify its own.

Bindings live under a credential. Up to 50 per credential.

Scopes: read for GET, write for add and remove.

List bindings

GET /services/{id}/credentials/{credId}/whitelist returns the full list (up to 50), no cursor.

curl https://dashboard.proxio.net/api/v1/services/clpkg_2a9x/credentials/clsub_7h2k/whitelist \
  -H "Authorization: Bearer pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x"
import requests

BASE = "https://dashboard.proxio.net/api/v1"
API_KEY = "pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x"

resp = requests.get(
    f"{BASE}/services/clpkg_2a9x/credentials/clsub_7h2k/whitelist",
    headers={"Authorization": f"Bearer {API_KEY}"},
    timeout=15,
)
resp.raise_for_status()
print(resp.json()["data"])
const BASE = "https://dashboard.proxio.net/api/v1"
const API_KEY = "pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x"

const res = await fetch(
  `${BASE}/services/clpkg_2a9x/credentials/clsub_7h2k/whitelist`,
  { headers: { Authorization: `Bearer ${API_KEY}` } },
)
console.log((await res.json()).data)

200 response:

{
  "data": [
    {
      "id": "clbind_3k9v",
      "ip": "203.0.113.5",
      "default_country": "us",
      "default_state": null,
      "default_city": "newyork",
      "default_sticky": true,
      "default_sesstime": 10,
      "created_at": "2026-07-16T12:00:00.000Z"
    }
  ],
  "meta": { "request_id": "req_8Ke2jP4mQ" }
}

Add a binding

POST /services/{id}/credentials/{credId}/whitelist.

Body

FieldTypeNotes
ipstringPublic, routable IP to whitelist. Required.
default_countrystring | nullISO2 country applied by default.
default_statestring | nullISO 3166-2 state code.
default_citystring | nullCity slug.
default_stickybooleanWhether requests default to a sticky session. Defaults to false.
default_sesstimenumberDefault session window, 1 to 90 minutes. Only stored when default_sticky is true.

default_country, default_state, and default_city each pass through the same normalization the username grammar uses: lowercase, with everything outside a-z0-9 stripped (no dashes preserved). That's a correct mechanical slug for a city name ("New York" -> newyork), but default_state isn't a slug of the state's name, it's the ISO 3166-2 code, and normalization can't derive one from the other ("California" slugs to california, not the actual code ca). Don't hand-type a display name into default_state. Send the exact code value GET /locations gives you for country, state, and city alike: those codes are already lowercase and dash-free, so normalization is a no-op on them and the stored default matches what the gateway expects.

default_sesstime depends on default_sticky

The two are not independent. If default_sticky is false or omitted, default_sesstime is discarded and stored as null, without a warning and without an error: the request still returns 201, and the binding you get back has "default_sesstime": null.

To set a session window, send both fields together: { "default_sticky": true, "default_sesstime": 10 }. Read default_sesstime back from the response rather than assuming the value you sent was kept.

curl -X POST \
  https://dashboard.proxio.net/api/v1/services/clpkg_2a9x/credentials/clsub_7h2k/whitelist \
  -H "Authorization: Bearer pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "ip": "203.0.113.5", "default_country": "us", "default_city": "newyork", "default_sticky": true, "default_sesstime": 10 }'
import uuid
import requests

BASE = "https://dashboard.proxio.net/api/v1"
API_KEY = "pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x"

resp = requests.post(
    f"{BASE}/services/clpkg_2a9x/credentials/clsub_7h2k/whitelist",
    headers={
        "Authorization": f"Bearer {API_KEY}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "ip": "203.0.113.5",
        "default_country": "us",
        "default_city": "newyork",
        "default_sticky": True,
        "default_sesstime": 10,
    },
    timeout=15,
)
resp.raise_for_status()
print(resp.status_code, resp.json()["data"])  # 201
import { randomUUID } from "node:crypto"

const BASE = "https://dashboard.proxio.net/api/v1"
const API_KEY = "pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x"

const res = await fetch(
  `${BASE}/services/clpkg_2a9x/credentials/clsub_7h2k/whitelist`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": randomUUID(),
    },
    body: JSON.stringify({
      ip: "203.0.113.5",
      default_country: "us",
      default_city: "newyork",
      default_sticky: true,
      default_sesstime: 10,
    }),
  },
)
console.log(res.status, (await res.json()).data) // 201

201 response returns the created binding in the list-item shape above.

IP validation and conflicts

Only a public, routable address can be whitelisted. Anything else is rejected with INVALID_IP (400); details[0].reason is INVALID_IP when the address is malformed, or PRIVATE_OR_RESERVED for any address that isn't eligible, which covers private, reserved, loopback, link-local, and CGNAT ranges. If the IP is already whitelisted on one of your own credentials, the call returns IP_ALREADY_BOUND (409) with details: [{ "credential_id": ... }] naming that credential. An IP that is unavailable for whitelisting is rejected with IP_UNAVAILABLE (409), with no reason given. Hitting the per-credential cap returns LIMIT_REACHED (409) with details: [{ "limit": 50 }].

Additions are rate-limited

Whitelist additions are limited to 30 per 60 seconds per account (on top of your per-key rate limit). Exceeding it returns RATE_LIMITED (429) with a Retry-After header.

Batch add bindings

POST /services/{id}/credentials/{credId}/whitelist/batch adds up to 50 IPs to one credential in a single request, instead of 50 round trips against the 30-per-minute add budget above. Same scope (write), same field-level validation as adding one.

Body

FieldTypeNotes
ipsarrayRequired, 1 to 50 items. Each item is either a bare IP string, or the same object POST /whitelist takes (ip plus the default_* fields). Mix and match freely in one array.
curl -X POST \
  https://dashboard.proxio.net/api/v1/services/clpkg_2a9x/credentials/clsub_7h2k/whitelist/batch \
  -H "Authorization: Bearer pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "ips": ["203.0.113.5", { "ip": "203.0.113.6", "default_country": "us" }] }'
import uuid
import requests

BASE = "https://dashboard.proxio.net/api/v1"
API_KEY = "pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x"

resp = requests.post(
    f"{BASE}/services/clpkg_2a9x/credentials/clsub_7h2k/whitelist/batch",
    headers={
        "Authorization": f"Bearer {API_KEY}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={"ips": ["203.0.113.5", {"ip": "203.0.113.6", "default_country": "us"}]},
    timeout=15,
)
resp.raise_for_status()
body = resp.json()
print(body["meta"])  # {'requested': 2, 'created': 2, 'failed': 0}
import { randomUUID } from "node:crypto"

const BASE = "https://dashboard.proxio.net/api/v1"
const API_KEY = "pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x"

const res = await fetch(
  `${BASE}/services/clpkg_2a9x/credentials/clsub_7h2k/whitelist/batch`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": randomUUID(),
    },
    body: JSON.stringify({ ips: ["203.0.113.5", { ip: "203.0.113.6", default_country: "us" }] }),
  },
)
const body = await res.json()
console.log(body.meta) // { requested: 2, created: 2, failed: 0 }

200 response (not 201, see the callout below):

{
  "data": [
    {
      "index": 0,
      "ip": "203.0.113.5",
      "status": "created",
      "binding": {
        "id": "clbind_3k9v", "ip": "203.0.113.5", "default_country": null,
        "default_state": null, "default_city": null, "default_sticky": false,
        "default_sesstime": null, "created_at": "2026-07-16T12:00:00.000Z"
      },
      "error": null
    },
    {
      "index": 1,
      "ip": "203.0.113.6",
      "status": "error",
      "binding": null,
      "error": {
        "code": "IP_ALREADY_BOUND",
        "message": "This IP is already whitelisted on one of your own credentials.",
        "doc_url": "https://docs.proxio.net/docs/api/errors#ip_already_bound",
        "details": [{ "credential_id": "clsub_2b8n" }]
      }
    }
  ],
  "meta": { "requested": 2, "created": 1, "failed": 1, "request_id": "req_8Ke2jP4mQ" }
}

Status semantics: 200 either way, per-item is where success or failure lives

A request-level failure, a malformed body, an unknown credential, or the batch throttle below, is an ordinary error envelope (400, 404, or 429), nothing in the batch ran. Once the request is accepted, every item gets an answer and the response is always 200, even when every single item failed: meta.failed equal to meta.requested is how you detect that, not the HTTP status. data[] is in request order, one entry per input, each carrying exactly one of binding or error (the other is null), so a generated client has one fixed shape to type. An item's error is field-for-field what POST /whitelist would have returned for that IP on its own, INVALID_IP, IP_ALREADY_BOUND, IP_UNAVAILABLE, or LIMIT_REACHED once the 50-per-credential cap is hit partway through the array (every item after that point is a LIMIT_REACHED error without a validation attempt).

Its own throttle, on top of the shared one

A batch call costs one token from the same 30-per-minute add budget every single add shares, not one token per IP, plus one token from a separate budget of 3 batch calls per 60 seconds per account. Both must have room or the whole request is refused with RATE_LIMITED (429) and a Retry-After header, before anything in the batch runs. The combination bounds how many IPs one account can probe per minute to a fixed multiple of the single-add budget, a flat one-token cost per batch would have let one call submit far more IPs per minute than adding them one at a time ever could.

Retrying without a key re-runs every item

Idempotency-Key is accepted here, not required. Send one and a retry with the same key replays the exact stored result, no items are re-run. Retry the same body without a key, after a timeout where you don't know whether the first attempt landed, for example, and every item runs again from scratch: an IP that already succeeded comes back as an IP_ALREADY_BOUND item error (harmless, but it is a real per-item error, not a skip), while an IP that hadn't been reached yet (say, because the per-credential cap was hit partway through the first attempt) gets a fresh try. Send an Idempotency-Key with every batch call and reuse it on retry if you want the original result back untouched instead of re-running the whole array.

Remove a binding

DELETE /services/{id}/credentials/{credId}/whitelist/{bindingId} returns 204 No Content.

curl -X DELETE \
  https://dashboard.proxio.net/api/v1/services/clpkg_2a9x/credentials/clsub_7h2k/whitelist/clbind_3k9v \
  -H "Authorization: Bearer pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x"
import requests

BASE = "https://dashboard.proxio.net/api/v1"
API_KEY = "pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x"

resp = requests.delete(
    f"{BASE}/services/clpkg_2a9x/credentials/clsub_7h2k/whitelist/clbind_3k9v",
    headers={"Authorization": f"Bearer {API_KEY}"},
    timeout=15,
)
print(resp.status_code)  # 204
const BASE = "https://dashboard.proxio.net/api/v1"
const API_KEY = "pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x"

const res = await fetch(
  `${BASE}/services/clpkg_2a9x/credentials/clsub_7h2k/whitelist/clbind_3k9v`,
  { method: "DELETE", headers: { Authorization: `Bearer ${API_KEY}` } },
)
console.log(res.status) // 204

Update means delete and re-add

There's no PATCH for a binding in v1. To change an IP or its defaults, delete the binding and add a new one.

On this page