Orders
List, quote, and place Proxio orders. GET /orders and /orders/{id} read your order history, POST /orders/quote prices one without buying it, POST /orders places a wallet-paid purchase, and POST /services/{id}/renew tops up or extends a service. Pricing is server-authoritative and both purchase writes require an idempotency key.
Orders are how you buy and renew capacity. Listing and reading orders needs
read; placing and renewing spends from your wallet and
needs the purchase scope plus a required
idempotency key.
Wallet-paid only
The API places wallet-paid orders. Top up your wallet first (card and crypto top-ups stay on the web checkout). Server-side pricing is always authoritative, any prices you send in the body are ignored.
List orders
GET /orders, cursor-paginated, filterable by
status and category.
Query parameters
| Parameter | Values | Notes |
|---|---|---|
status | PENDING, PAID, CANCELED, REFUNDED, EXPIRED | Case-insensitive. One value, not a list. Any other value fails with VALIDATION_ERROR. |
category | RESIDENTIAL, ISP, DC (plus the aliases), and MOBILE / STATIC_RESIDENTIAL | Case-insensitive. Any other value fails with VALIDATION_ERROR. |
created_after, created_before | ISO 8601 timestamps | Both inclusive, on created_at. |
sort | created_at | total | Defaults to created_at. |
order | asc | desc | Defaults to desc. |
limit, cursor | see Pagination |
Every parameter here is checked. An unrecognized status, category, sort,
or order, and an unparseable date, all fail with
VALIDATION_ERROR naming the parameter.
Changing sort or order partway through a paginated walk invalidates the
cursor you're holding, see
Pagination.
Unknown filter values are rejected, not ignored
A status or category outside the sets above returns
VALIDATION_ERROR (400) with
details: [{ "field": "status", "issue": "invalid", "allowed": [...] }].
?status=paidd is an error, not every order you've ever placed. Because the
filter you sent is always the filter that ran, an empty page means you have no
matching orders.
This filter reaches more categories than POST /orders
sells. An account can hold MOBILE or STATIC_RESIDENTIAL orders bought from
the dashboard, and those appear in the unfiltered list, so you can narrow to
them here even though you cannot buy them through the API.
curl "https://dashboard.proxio.net/api/v1/orders?status=PAID&limit=20" \
-H "Authorization: Bearer pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x"import requests
BASE = "https://dashboard.proxio.net/api/v1"
API_KEY = "pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x"
resp = requests.get(
f"{BASE}/orders",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"status": "PAID", "limit": 20},
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}/orders?status=PAID&limit=20`, {
headers: { Authorization: `Bearer ${API_KEY}` },
})
console.log((await res.json()).data)200 response:
{
"data": [
{
"id": "clord_9f2a",
"status": "PAID",
"category": "RESIDENTIAL",
"is_unlimited": false,
"quantity_gb": 50,
"ip_quantity": null,
"subtotal": "125.00",
"discount": "12.50",
"total": "112.50",
"paid_via": "WALLET",
"service_id": "clpkg_2a9x",
"created_at": "2026-07-16T10:00:00.000Z"
}
],
"meta": { "next_cursor": null, "has_more": false, "request_id": "req_8Ke2jP4mQ" }
}GET /orders/{id} returns a single order in the same shape, plus a
pricing_snapshot with the resolved line items.
Order status
status is one of five values:
| Status | Meaning |
|---|---|
PENDING | Created but not yet paid. |
PAID | Charged successfully. Provisioning follows. |
CANCELED | Cancelled before payment. |
REFUNDED | Paid, then refunded. |
EXPIRED | Left unpaid past its validity. |
Quantity fields
An order carries its size in one of two fields, never both:
- Metered (residential):
quantity_gbis the GB purchased, andip_quantityisnull. - Unlimited (ISP, datacenter):
quantity_gbisnullandip_quantityholds the number of IP slots. Readingquantity_gbon an unlimited order and treatingnullas zero will silently under-report the order.
Check is_unlimited to know which field to read.
Quote an order
POST /orders/quote prices an order without placing it, same body, same
pricing resolvers and coupon evaluation as placing one, so
a quote and the purchase that follows it can never disagree. Nothing is
written: no order row, no coupon redemption, no wallet movement.
This is a read, not a purchase
Despite the POST verb, this endpoint only requires the read scope, not
purchase. It's priced so a pipeline can budget and decide whether to buy
using a read-only key, long before anything holding purchase gets
involved. No Idempotency-Key needed either, quoting twice is free and has
no side effect to deduplicate.
Body is identical to POST /orders:
category, quantity_gb, days, ip_quantity, coupon, and the same
category values and validation.
curl -X POST https://dashboard.proxio.net/api/v1/orders/quote \
-H "Authorization: Bearer pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x" \
-H "Content-Type: application/json" \
-d '{ "category": "RESIDENTIAL", "quantity_gb": 50, "coupon": "SUMMER10" }'import requests
BASE = "https://dashboard.proxio.net/api/v1"
API_KEY = "pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x"
resp = requests.post(
f"{BASE}/orders/quote",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"category": "RESIDENTIAL", "quantity_gb": 50, "coupon": "SUMMER10"},
timeout=15,
)
resp.raise_for_status()
quote = resp.json()["data"]
print(quote["total"], quote["wallet"]["covers_total"])const BASE = "https://dashboard.proxio.net/api/v1"
const API_KEY = "pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x"
const res = await fetch(`${BASE}/orders/quote`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ category: "RESIDENTIAL", quantity_gb: 50, coupon: "SUMMER10" }),
})
const quote = (await res.json()).data
console.log(quote.total, quote.wallet.covers_total)200 response:
{
"data": {
"category": "residential",
"currency": "USD",
"subtotal": "125.00",
"discount": "12.50",
"total": "112.50",
"pricing": {
"kind": "metered",
"quantity_gb": 50,
"unit_price_gb": 2.5,
"tier_percent_applied": 0,
"ips_per_gb": 0
},
"coupon": {
"code": "SUMMER10",
"applied": true,
"name": "Summer promo",
"percent_off": 10,
"amount_off": null
},
"wallet": { "currency": "USD", "balance": "30.00", "covers_total": false }
},
"meta": { "request_id": "req_8Ke2jP4mQ" }
}categoryon a quote is lowercase and spelled out ("residential","isp","datacenter"), unlike everywhere else in this API, including the ordercategorythis quote otherwise mirrors, which is always the uppercase canonical form (RESIDENTIAL,ISP,DC). Compare case-insensitively, or normalize it yourself, if you're matching a quote'scategoryagainst a purchase's.pricingis one of two shapes:kind: "metered"(residential, shown above) orkind: "unlimited"(ISP/datacenter), the same two shapes the purchase itself resolves against. It's context for the price, not something to re-derivetotalfrom yourself.couponisnullwhen you didn't send one. When you did,applied: falsemeans the code didn't qualify, no reason is given: a per-reason answer would let you probe which codes exist and how close they are to their redemption caps.wallet.covers_totalis exactly the check the purchase itself makes (it fails withINSUFFICIENT_BALANCEwhenbalance < total), sofalsehere means placing this exact order right now would be refused for want of funds. Top up before you retry it as a purchase.
A quote is a snapshot, not a lock
Nothing about calling this endpoint reserves the price, a coupon's remaining redemptions, or your balance. If you quote and then wait, the purchase that follows re-resolves pricing and re-evaluates the coupon from scratch and can land on a different number.
Place an order
POST /orders. Requires the purchase scope and an Idempotency-Key
(without it, the request fails with
MISSING_PARAMETER).
Body
| Field | Type | Notes |
|---|---|---|
category | string | RESIDENTIAL, ISP, or DC. Case-insensitive, aliases accepted, see Category values. |
quantity_gb | number | Required for residential (per-GB). |
days | number | Required for ISP/DC. One of the durations advertised by GET /products (allowed_days). |
ip_quantity | number | ISP/DC only. Default 1. |
coupon | string | Optional discount code. |
Category values
Input is normalized onto a canonical enum, so what you send back is not
necessarily what you get. Responses always use RESIDENTIAL, ISP, or DC.
| Canonical value | Also accepted on input |
|---|---|
RESIDENTIAL | residential |
ISP | isp, isp_rotating |
DC | dc, dc_rotating, datacenter |
Matching is case-insensitive and surrounding whitespace is trimmed, so
" Datacenter " resolves to DC. Compare against the canonical value when you
read a response, never against the string you sent. Anything outside the table
fails with VALIDATION_ERROR, both in a
POST /orders body and as a GET /orders filter.
The allowed days values are config-driven, not a fixed list: read them from
allowed_days on the matching product. A days value
outside that set fails with
VALIDATION_ERROR (400) and
details: [{ "field": "days", "allowed": [...] }].
curl -X POST https://dashboard.proxio.net/api/v1/orders \
-H "Authorization: Bearer pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "category": "RESIDENTIAL", "quantity_gb": 50, "coupon": "SUMMER10" }'import uuid
import requests
BASE = "https://dashboard.proxio.net/api/v1"
API_KEY = "pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x"
resp = requests.post(
f"{BASE}/orders",
headers={
"Authorization": f"Bearer {API_KEY}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={"category": "RESIDENTIAL", "quantity_gb": 50, "coupon": "SUMMER10"},
timeout=30,
)
resp.raise_for_status()
print(resp.status_code, resp.json()["data"]) # 201import { randomUUID } from "node:crypto"
const BASE = "https://dashboard.proxio.net/api/v1"
const API_KEY = "pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x"
const res = await fetch(`${BASE}/orders`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": randomUUID(),
},
body: JSON.stringify({ category: "RESIDENTIAL", quantity_gb: 50, coupon: "SUMMER10" }),
})
console.log(res.status, (await res.json()).data) // 201201 response:
{
"data": {
"order": { "id": "clord_9f2a", "status": "PAID", "subtotal": "125.00", "discount": "12.50", "total": "112.50", "paid_via": "WALLET" },
"service": { "id": "clpkg_2a9x", "category": "RESIDENTIAL", "status": "active" },
"wallet": { "balance": "30.00" }
},
"meta": { "request_id": "req_8Ke2jP4mQ" }
}Provisioning can trail the charge
On success the order is PAID and provisioning runs. If provisioning is still
finishing, service may be null and meta.provisioning is "pending". Poll
GET /orders/{id} until its service_id is populated. The
charge is never applied twice.
If your balance can't cover the total, the order fails with
INSUFFICIENT_BALANCE (402), top up and
retry with the same idempotency key.
A failed order leaves a PENDING row behind
The order row is written before the wallet is charged, so an order that
fails at the charge, most commonly on
INSUFFICIENT_BALANCE, stays in your
history with status: "PENDING". Nothing was charged and nothing was
provisioned, but the row is real: it appears in GET /orders and in
?status=PENDING.
Each failed attempt adds another one, so a retry loop against an underfunded
wallet accumulates orphan pending orders. Filter them out when you reconcile,
treat only PAID as a real purchase, and top up before retrying instead of
hammering the endpoint.
Renew or top up a service
POST /services/{id}/renew tops up or extends an existing service. Same
requirements as placing an order: purchase scope and a required
Idempotency-Key. Send one of two body shapes, depending on what you're doing
and what kind of package it is:
- Top up a metered package:
{ "type": "topup", "quantity_gb": 25 }. Adds traffic to a metered package (residential and other metered categories) right away. Topping up an unlimited package returnsUNSUPPORTED_OPERATION. - Extend an unlimited ISP/DC package:
{ "type": "extend", "days": 30 }. Priced at the per-day rate times the number of active IP slots, so a 5-IP package is billed for all 5. The expiry moves forward by exactlydays, immediately.
Any other package kind, including Residential, returns
UNSUPPORTED_OPERATION for
type: "extend". Residential packages aren't manually extendable: they renew
via auto-renewal instead, which charges
your wallet the current price for another cycle automatically. Use topup to
add data to a residential package right now.
The days value for an unlimited ISP/DC extend is config-driven: it must be
one of the extension durations the service's category advertises. An unknown
value fails with VALIDATION_ERROR (400)
and details: [{ "field": "days", "allowed": [...] }].
curl -X POST https://dashboard.proxio.net/api/v1/services/clpkg_2a9x/renew \
-H "Authorization: Bearer pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "type": "topup", "quantity_gb": 25 }'import uuid
import requests
BASE = "https://dashboard.proxio.net/api/v1"
API_KEY = "pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x"
resp = requests.post(
f"{BASE}/services/clpkg_2a9x/renew",
headers={
"Authorization": f"Bearer {API_KEY}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={"type": "topup", "quantity_gb": 25},
timeout=30,
)
resp.raise_for_status()
print(resp.json()["data"])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/renew`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": randomUUID(),
},
body: JSON.stringify({ type: "topup", quantity_gb: 25 }),
})
console.log((await res.json()).data)201 response (topup on a metered package):
{
"data": {
"order": { "id": "clord_7c1d", "status": "PAID", "total": "62.50" },
"service": {
"id": "clpkg_2a9x",
"remaining": { "bytes": "75000000000", "bytes_num": 75000000000, "gigabytes": 75 },
"expires_at": "2026-09-01T00:00:00.000Z"
}
},
"meta": { "request_id": "req_8Ke2jP4mQ" }
}201 response (extend on an unlimited ISP/DC package). The shape is identical,
but remaining is null, because an unlimited package has no bandwidth counter
to report. Only expires_at moves:
{
"data": {
"order": { "id": "clord_7c1d", "status": "PAID", "total": "86.40" },
"service": {
"id": "clpkg_5d3m",
"remaining": null,
"expires_at": "2026-10-01T00:00:00.000Z"
}
},
"meta": { "request_id": "req_8Ke2jP4mQ" }
}Don't assume remaining is an object: check for null before reading
remaining.bytes.
Related pages
Wallet
Read your Proxio wallet balance with GET /wallet, fund it with POST /wallet/topups (a 201 is a payment link, not money), and page through the transaction ledger with GET /wallet/transactions, filterable by type and date range, sortable by created_at or amount.
Webhooks
Receive events from Proxio instead of polling, as a signed JSON envelope or delivered to Discord or Slack. Full event catalog, payload shape, an X-Proxio-Signature verification walkthrough in Python and Node.js (t=,v1= scheme with 300s tolerance, JSON format only), retry and backoff, auto-disable, secret rotation, and a test endpoint.

