The Reseller API is a separate, money-moving surface for partners who resell ISP proxies at volume: it holds a prepaid USD balance, a purchasable catalog of ISP plans, an order/fulfillment pipeline, and signed webhooks — all independent of the ordinary dashboard account it is attached to. It is not the same thing as "reselling" via sub-users (see the reselling guide) — that model splits a residential traffic pool across sub-users on the shared gateway; this one buys and holds discrete, static IP:port ISP proxies against a running balance. Every endpoint below (except applying) returns the standard {success, payload, meta, errors, description} envelope described on the ISP Proxies API page.
Every endpoint on this page requires an active, API-enabled reseller account and a reseller-scoped token. The master switch, the token's reseller ability, the resellers row and its api_enabled flag are checked in that order — the first one that fails short-circuits the rest. All four failure modes return the exact same 403 body on purpose (so a caller cannot probe which reason applies):
{
"success": false,
"payload": null,
"errors": { "reseller": "reseller_disabled" },
"description": "Reseller API is not available for this account"
}
An unauthenticated request (missing/invalid bearer token) is a plain 401 before any of this runs.
Before you integrate, read these — they are not obvious from the endpoint list:
An IP can never be returned or cancelled before its term ends. Neither upstream provider supports it. See No Early Returns.
max_buy_qty is 1 in production. A single order can only ever cover one proxy — buying N means N separate orders with N separate idempotency keys. See Create an Order.
Ordering is asynchronous.POST /orders returns status: "provisioning", never credentials. See Order Lifecycle.
Webhooks are a convenience, not a source of truth. A partner that never configures one still works correctly by polling. See Webhooks.
The Idempotency-Key header has three outcomes on both POST /orders and POST /proxies/{proxy}/renew: same key + same body replays with 200; same key + different body (or a different target proxy, for renewals) is 409; a missing header is 422. See Idempotency.
Clearing notes or external_ref: send "" or null — both clear the field, and it always reads back as null, never "". See Update a Proxy.
There is no city-level targeting anywhere in this API. ISP plans are sold by country and ISP name only — the catalog and order endpoints have no city parameter, and this is not a gap you can work around: the residential gateway's own -city- username token is unreliable in production too. Do not advertise city selection to your customers for ISP proxies.
Applying for Partner Access
Reseller access is not self-service. Any authenticated ProxyHat account may apply; a human reviews the application and flips the account to active with api_enabled: true before any other endpoint on this page will admit it. Calling this endpoint twice does not create a duplicate — it returns the existing application.
POST/v1/reseller/applyRequires Auth
Apply for Partner Access
Apply for Partner Access
Submit a reseller application for the authenticated account. Requires only a valid bearer token — no reseller row, no reseller-scoped ability. A second call from an account that has already applied returns the existing application (200) instead of creating another.
Example request:
Request Body
Name
Type
Required
Description
name
string
Required
Your company or brand name, max 191 characters.
website
string (url)
Optional
Your website, max 500 characters.
expected_volume
string
Optional
Free-text description of expected monthly volume. Recorded only in the internal review alert — there is no column for it, so it is never echoed back by any endpoint.
What happens next. The application starts at status: "pending" with api_enabled: false — every other endpoint on this page still refuses the account at this point. There is no polling endpoint for application status; ProxyHat contacts you once the account is reviewed and activated. Once active, mint a reseller-scoped bearer token with POST /v1/profile/api-keys (see the Profile & API Keys page), passing "abilities": ["reseller"] (or a token with the full ["*"] ability also works) — then use that token as the Authorization: Bearer value on every call below.
Account & Balance
GET/v1/reseller/accountRequires Auth
Get Reseller Account
Get Reseller Account
Return your reseller account status, prepaid balance, discount rate, low-balance threshold and webhook configuration state (never the secret itself). meta carries the two numbers you need to size an integration: max_buy_qty and your per-minute rate limit.
req, _ := http.NewRequest("GET", "https://api.proxyhat.com/v1/reseller/account", nil)
req.Header.Set("Authorization", "Bearer __API_KEY__")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
var out struct {
Payload map[string]interface{} `json:"payload"`
Meta map[string]interface{} `json:"meta"`
}
json.NewDecoder(resp.Body).Decode(&out)
fmt.Println(out.Payload["name"], out.Payload["balance_usd"], out.Payload["status"])
Ledger
GET/v1/reseller/ledgerRequires Auth
List Ledger Entries
List Ledger Entries
Every balance movement on your account — deposits (top-ups), purchases, renewals, refunds and manual adjustments — newest first, paginated. Each row carries balance_after_usd as of that entry, which is why two rows never report the same running balance even when read back-to-back.
We filled these in for you:per_page
Tweak any value if you like — or just press Try.
Query Parameters
Name
Type
Required
Description
type
string
Optional
Filter to one entry type: deposit, purchase, renewal, refund, or adjustment.
req, _ := http.NewRequest("GET", "https://api.proxyhat.com/v1/reseller/ledger?type=purchase&per_page=20", nil)
req.Header.Set("Authorization", "Bearer __API_KEY__")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
var out struct {
Payload struct {
Data []map[string]interface{} `json:"data"`
} `json:"payload"`
}
json.NewDecoder(resp.Body).Decode(&out)
for _, row := range out.Payload.Data {
fmt.Println(row["type"], row["amount_usd"], "->", row["balance_after_usd"])
}
Debits are negative. A purchase or renewal reports a negative amount_usd; a deposit or refund reports a positive one. reference is null for a manual admin adjustment and otherwise points back at the order or top-up that caused the entry, so you can join without guessing.
Catalog
GET/v1/reseller/catalogRequires Auth
Browse the Catalog
Browse the Catalog
Every active, purchasable ISP plan, ordered by country then term length, priced at your reseller rate. Internal cost and provider identifiers are never included — this endpoint is audited specifically to never leak them.
req, _ := http.NewRequest("GET", "https://api.proxyhat.com/v1/reseller/catalog", nil)
req.Header.Set("Authorization", "Bearer __API_KEY__")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
var out struct {
Payload []map[string]interface{} `json:"payload"`
Meta struct {
MaxBuyQty int `json:"max_buy_qty"`
BalanceUsd float64 `json:"balance_usd"`
} `json:"meta"`
}
json.NewDecoder(resp.Body).Decode(&out)
for _, plan := range out.Payload {
fmt.Printf("%v (%v) $%v / %vd\n", plan["isp_name"], plan["country_code"], plan["price"], plan["days"])
}
GET/v1/reseller/catalog/{id}Requires Auth
Get a Catalog Plan
Get a Catalog Plan
Retrieve a single active plan by its UUID, for an order-confirmation screen. 404 if the plan does not exist, is inactive, or the id is not a valid UUID (a malformed id never reaches the database, so this is always a clean 404, never a 500).
compare_at is null, not omitted, when there is nothing to strike through. Always check for null rather than treating a missing key as "no discount" — the key is always present. traffic_limit: 0 means unmetered, the same convention as the public ISP store catalog. There is no city, region or ZIP parameter anywhere in this endpoint — plans are scoped to country and ISP name only.
Orders
POST/v1/reseller/orders/quoteRequires Auth
Quote an Order
Quote an Order
Price an order without creating anything or touching your balance — no Idempotency-Key needed. quantity is silently clamped to meta.max_buy_qty (1 in production) before pricing, exactly as an actual order would be, so the quoted total always matches what a real order costs.
Example request:plan_idquantity
Request Body
Name
Type
Required
Description
plan_id
string (uuid)
Required
A catalog plan id.
quantity
integer
Optional
Defaults to 1. Clamped to max_buy_qty, never rejected for being too high.
Debit your balance and queue a purchase. Returns immediately with status:"provisioning" — never credentials. Requires an Idempotency-Key header (see below). external_ref is your own opaque identifier for this order (e.g. your customer/order id); it is echoed back on every read and included in the order.fulfilled webhook.
Example request:plan_idquantityexternal_ref
Request Body
Name
Type
Required
Description
plan_id
string (uuid)
Required
A catalog plan id.
quantity
integer
Optional
Defaults to 1. Silently clamped to max_buy_qty (1 in production) — requesting more never errors, it just buys fewer than you asked for. See "Buying more than one" below.
external_ref
string
Optional
Your own reference for this order, max 191 characters.
auto_renew
boolean
Optional
When true, the resulting proxy is enrolled in the nightly auto-renew sweep once fulfilled. Debits your balance again at each renewal — never creates a card subscription.
import requests
import uuid
idempotency_key = str(uuid.uuid4()) # generate ONCE per logical purchase, then reuse on retry
response = requests.post(
"https://api.proxyhat.com/v1/reseller/orders",
headers={
"Authorization": "Bearer __API_KEY__",
"Content-Type": "application/json",
"Accept": "application/json",
"Idempotency-Key": idempotency_key,
},
json={
"plan_id": "8d2e1f4a-6b3c-4e9d-a12f-7c5b0e3d9a84",
"quantity": 1,
"external_ref": "cust_10293",
"auto_renew": False,
},
)
order = response.json()["payload"]
print(order["id"], order["status"]) # status is "provisioning" here, not a live proxy yet
import { randomUUID } from "crypto";
const idempotencyKey = randomUUID(); // generate ONCE per logical purchase, then reuse on retry
const response = await fetch("https://api.proxyhat.com/v1/reseller/orders", {
method: "POST",
headers: {
"Authorization": "Bearer __API_KEY__",
"Content-Type": "application/json",
"Accept": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({
plan_id: "8d2e1f4a-6b3c-4e9d-a12f-7c5b0e3d9a84",
quantity: 1,
external_ref: "cust_10293",
auto_renew: false,
}),
});
const { payload } = await response.json();
console.log(payload.id, payload.status); // "provisioning" — poll or wait for the webhook
idempotencyKey := uuid.NewString() // generate ONCE per logical purchase, then reuse on retry
body, _ := json.Marshal(map[string]interface{}{
"plan_id": "8d2e1f4a-6b3c-4e9d-a12f-7c5b0e3d9a84",
"quantity": 1,
"external_ref": "cust_10293",
"auto_renew": false,
})
req, _ := http.NewRequest("POST", "https://api.proxyhat.com/v1/reseller/orders", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer __API_KEY__")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Idempotency-Key", idempotencyKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
var out struct {
Payload map[string]interface{} `json:"payload"`
}
json.NewDecoder(resp.Body).Decode(&out)
fmt.Println(out.Payload["id"], out.Payload["status"])
Buying more than one proxy.max_buy_qty is 1 in production — both upstream ISP providers report stock as a plain in-stock/out-of-stock boolean, not a count, so an order for N > 1 can never be validated against real availability in advance. Sending "quantity": 5 does not error: the order is silently created for 1 unit and payload.quantity reports the clamped value. To buy N proxies, issue N separate POST /orders calls, each with its ownIdempotency-Key.
The Idempotency-Key contract — three outcomes. Generate one key per logical purchase (a UUID is fine) and reuse it only when retrying that exact same purchase:
Same key, same body → 200, replayed. The original order is returned unchanged and nothing is debited a second time. Safe to retry blindly on a timeout.
Same key, different body → 409.{ "success": false, "payload": null, "errors": { "idempotency_key": "idempotency_conflict" }, "description": "This Idempotency-Key was used for a different order" }. Reusing a key for a genuinely different order (different plan, quantity, external_ref, or even just a flipped auto_renew) is refused rather than silently returning the old order or double-charging — pick a new key.
Missing header → 422.{ "success": false, "payload": null, "errors": { "Idempotency-Key": "The Idempotency-Key header is required." }, "description": "Validation failed" }. This check runs after ordinary field validation, so a request that also has an invalid plan_id gets Laravel's native 422 validation shape instead — see Error Handling for the two 422 shapes.
The identical three-outcome contract applies to POST /proxies/{id}/renew, where "different body" means "a key reused against a different proxy."
GET/v1/reseller/ordersRequires Auth
List Orders
List Orders
Your own orders, newest first, paginated. There is no filter by kind (purchase vs renewal) — filter client-side on payload.data[].kind if you need it.
No input needed —runs live with your key
Query Parameters
Name
Type
Required
Description
status
string
Optional
pending, provisioning, fulfilled, failed, or refunded.
req, _ := http.NewRequest("GET", "https://api.proxyhat.com/v1/reseller/orders?external_ref=cust_10293", nil)
req.Header.Set("Authorization", "Bearer __API_KEY__")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
var out struct {
Payload struct {
Data []map[string]interface{} `json:"data"`
} `json:"payload"`
}
json.NewDecoder(resp.Body).Decode(&out)
for _, order := range out.Payload.Data {
fmt.Println(order["id"], order["status"])
}
GET/v1/reseller/orders/{id}Requires Auth
Get an Order
Get an Order
Poll this endpoint after creating an order until status leaves "provisioning" — this is the authoritative fallback if you do not configure a webhook, or if a webhook delivery is delayed or lost. 404 for another reseller's order, or for a malformed (non-UUID) id.
import time
import requests
order_id = "2c5e9a1f-7b34-4d8e-a1c6-9f0b2d3e4a5c"
H = {"Authorization": "Bearer __API_KEY__", "Accept": "application/json"}
# Authoritative polling fallback: works with zero webhook configuration.
while True:
order = requests.get(f"https://api.proxyhat.com/v1/reseller/orders/{order_id}", headers=H).json()["payload"]
if order["status"] != "provisioning":
break
time.sleep(15)
if order["status"] == "fulfilled":
proxy = order["proxies"][0]
print(proxy["ip"], proxy["port"], proxy["login"], proxy["password"])
else:
print("order did not fulfill:", order["status"], order["error"])
const orderId = "2c5e9a1f-7b34-4d8e-a1c6-9f0b2d3e4a5c";
const H = { "Authorization": "Bearer __API_KEY__", "Accept": "application/json" };
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// Authoritative polling fallback: works with zero webhook configuration.
let order;
do {
const res = await fetch(`https://api.proxyhat.com/v1/reseller/orders/${orderId}`, { headers: H });
order = (await res.json()).payload;
if (order.status === "provisioning") await sleep(15000);
} while (order.status === "provisioning");
if (order.status === "fulfilled") {
const [proxy] = order.proxies;
console.log(proxy.ip, proxy.port, proxy.login, proxy.password);
} else {
console.log("order did not fulfill:", order.status, order.error);
}
orderID := "2c5e9a1f-7b34-4d8e-a1c6-9f0b2d3e4a5c"
var order map[string]interface{}
for {
req, _ := http.NewRequest("GET", "https://api.proxyhat.com/v1/reseller/orders/"+orderID, nil)
req.Header.Set("Authorization", "Bearer __API_KEY__")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
var out struct {
Payload map[string]interface{} `json:"payload"`
}
json.NewDecoder(resp.Body).Decode(&out)
resp.Body.Close()
order = out.Payload
if order["status"] != "provisioning" {
break
}
time.Sleep(15 * time.Second)
}
fmt.Println(order["status"])
Order Lifecycle & Refunds
An order moves through pending → provisioning → (fulfilled | failed) → optionally refunded. Your balance is debited the instant the order is created — before anything is provisioned — so fulfilled_at, not the debit, is the signal that credentials exist.
fulfilled — enough IspProxy rows now carry this order's payment reference; payload.proxies is populated and an order.fulfilled webhook fires.
failed — the provider could not deliver (renewals only reach this from an armed-mode provider's async refusal, or the proxy having already expired before the renewal confirmed). An order.failed or proxy.renewal_failed webhook carries payload.error / a reason field with the detail.
refunded — an hourly sweep automatically refunds any order that has sat unfulfilled for more than ~48 hours (and could not still be mid-flight at the provider), crediting your ledger and emitting order.refunded. You do not need to request this yourself.
A renewal order's proxies array is always empty — even once fulfilled. Renewing extends the term of an existingIspProxy row in place; it never creates a new one, so nothing ever attaches to reseller_order_id on a renewal order. To read the renewed proxy's new expires_at, fetch it directly with GET /proxies/{id} using the id you renewed — do not expect to find it on the order.
Proxies
An ISP proxy can never be cancelled or returned before its term ends. There is no DELETE endpoint and none is planned — neither upstream provider (CyberYozh or NodeMaven ISP) exposes an early-termination operation, so ProxyHat cannot offer one either. Setting auto_renew: false only stops the next renewal; the proxy remains yours, billed and usable, until expires_at. There is no partial refund for unused days on a proxy that was actually delivered — the only refund path (see Order Lifecycle) is for an order that was never fulfilled at all.
GET/v1/reseller/proxiesRequires Auth
List My Proxies
List My Proxies
Every ISP proxy currently assigned to you, with full live credentials, filterable by external reference, country, active/expired status, or an expiring-soon horizon.
We filled these in for you:expiring_within_days
Tweak any value if you like — or just press Try.
Query Parameters
Name
Type
Required
Description
external_ref
string
Optional
Exact match on the reference you set at order time or via PATCH.
country
string
Optional
2-letter ISO country code.
status
string
Optional
active or expired.
expiring_within_days
integer
Optional
1-365. Only proxies still active but expiring inside this window.
Write your own notes, your own external_ref, or flip auto_renew on an already-owned proxy. Never creates a Creem card subscription — a prepaid renewal is always debited from your balance, either via this proxy's own nightly auto-renew pass or an explicit POST /renew.
Example request:id
Path Parameters
Name
Type
Required
Description
id
string (uuid)
Required
The proxy id.
Request Body
Name
Type
Required
Description
notes
string
Optional
Max 2000 characters. Send "" or null to clear it — both do the same thing, and it always reads back null, never "".
external_ref
string
Optional
Max 191 characters. Same clear-with-"" behavior as notes.
auto_renew
boolean
Optional
Enroll or unenroll this proxy in the nightly auto-renew sweep.
Why "" and null behave identically. A global Laravel middleware rewrites every incoming empty string to null before this controller (or any other in the app) ever sees the request body — by the time validation runs, "the client sent an empty string to clear this" and "the client sent an explicit null" are already indistinguishable. Both clear the column. Omitting the key entirely, by contrast, leaves the existing value untouched — the controller only writes a field that was actually present in the request body.
POST/v1/reseller/proxies/{id}/testRequires Auth
Test a Proxy
Test a Proxy
Live connectivity probe of one proxy on every protocol it serves (HTTP and/or SOCKS5), run server-side through the proxy itself. Shares its prober with the dashboard's own ISP proxy test, so the two surfaces can never disagree about the same IP.
A failed protocol never fails the whole call.success is true as long as at least one protocol answered (protocols_ok > 0); a protocol that failed reports "ok": false with an error_code of either proxy_auth_failed or proxy_unreachable instead of the connectivity fields above. Calling this on an already-expired proxy skips the network probe entirely and returns 422 with errors.proxy: "proxy_expired" — no HTTP request is ever made to a dead IP.
POST/v1/reseller/proxies/{id}/renewRequires Auth
Renew a Proxy
Renew a Proxy
Extend an existing proxy's term from your balance. Requires an Idempotency-Key header with the same three-outcome contract as Create an Order, EXCEPT that "different body" here means "the same key was already used for a different proxy" — reusing a key against a second proxy is a 409, never a silent renewal of the first one instead.
A renewal can be refused before anything is charged.409 with errors.proxy: "not_renewable" means the proxy failed the same eligibility gate the dashboard's own Renew button enforces — renewals disabled, no bound provider id, past the provider's cutoff window, a renewal already armed, or the plan no longer sellable — checked before the balance is touched, so a refused renewal never costs you anything. 402 with errors.balance: "insufficient_balance" (payload carries required and balance) means the same for an underfunded account. Poll GET /proxies/{id} or wait for a proxy.renewed / proxy.renewal_failed webhook to learn the outcome — a renewal order's own proxies array never populates (see Order Lifecycle).
Top-Ups
Your balance is prepaid and funded only by crypto deposit via Setype — there is no card rail on this surface. A deposit never creates a Payment row; it is a pure ledger credit once Setype confirms it.
POST/v1/reseller/topupsRequires Auth
Create a Top-Up
Create a Top-Up
Open a crypto deposit invoice for amount_usd. Returns pay_address / payment_url / pay_amount_crypto for your customer (or your own treasury) to pay. The minimum deposit is $10 — below it, 422.
Example request:amount_usdcryptocurrency_code
Request Body
Name
Type
Required
Description
amount_usd
number
Required
Minimum 0.01 by field validation, but rejected below the $10 program minimum by a separate 422 (see below).
cryptocurrency_code
string
Optional
One of: btc, eth, usdt, usdt_tron, usdt_bsc, sol, ltc, usdc, usdc_bsc, usdc_sol, usdt_sol, bnb. Omit to let Setype pick.
Webhooks are best-effort. Polling is authoritative. A partner that never configures a webhook URL is fully supported — every state change is equally readable from GET /orders/{id}, GET /proxies/{id} and GET /topups/{id}. Treat a delivered webhook as a hint to check sooner, never as the only place a piece of state is observable — deliveries retry up to 6 times over roughly a day (30s, 2m, 10m, 1h, 6h, then daily) and are then marked failed for good, with no further attempt.
GET/v1/reseller/webhookRequires Auth
Get Webhook Settings
Get Webhook Settings
Your current webhook URL and whether a secret is configured. The secret itself is never returned here — only by Rotate Secret, and only once.
Generate a new signing secret. Returned in the response body exactly once — no endpoint on this API ever repeats it. Store it immediately; if you lose it, rotate again.
import requests
response = requests.post(
"https://api.proxyhat.com/v1/reseller/webhook/rotate-secret",
headers={"Authorization": "Bearer __API_KEY__", "Accept": "application/json"},
)
secret = response.json()["payload"]["secret"]
print("store this now, it will not be shown again:", secret)
const response = await fetch("https://api.proxyhat.com/v1/reseller/webhook/rotate-secret", {
method: "POST",
headers: { "Authorization": "Bearer __API_KEY__", "Accept": "application/json" },
});
const { payload } = await response.json();
console.log("store this now, it will not be shown again:", payload.secret);
req, _ := http.NewRequest("POST", "https://api.proxyhat.com/v1/reseller/webhook/rotate-secret", nil)
req.Header.Set("Authorization", "Bearer __API_KEY__")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
var out struct {
Payload map[string]interface{} `json:"payload"`
}
json.NewDecoder(resp.Body).Decode(&out)
fmt.Println("store this now, it will not be shown again:", out.Payload["secret"])
GET/v1/reseller/webhook/deliveriesRequires Auth
List Webhook Deliveries
List Webhook Deliveries
A debugging log of past delivery attempts — id, event, status, attempts, HTTP response status, and timestamps. Never includes the delivery payload itself: a fulfilled-order delivery carries live proxy credentials, and this listing is for diagnosing delivery health, not for reading data you can already get from the order.
req, _ := http.NewRequest("GET", "https://api.proxyhat.com/v1/reseller/webhook/deliveries", nil)
req.Header.Set("Authorization", "Bearer __API_KEY__")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
var out struct {
Payload struct {
Data []map[string]interface{} `json:"data"`
} `json:"payload"`
}
json.NewDecoder(resp.Body).Decode(&out)
for _, d := range out.Payload.Data {
fmt.Println(d["event"], d["status"], d["attempts"], d["response_status"])
}
Verifying Webhook Signatures
Every delivery carries three headers alongside the JSON body:
Header
Meaning
X-ProxyHat-Event
The event name, e.g. order.fulfilled.
X-ProxyHat-Delivery
This delivery's id (matches GET /webhook/deliveries).
X-ProxyHat-Timestamp
Unix seconds at send time.
X-ProxyHat-Signature
sha256=<hex hmac> — see below.
The signature is computed over "{timestamp}.{raw body}" — not the body alone. Concatenate the exact string from X-ProxyHat-Timestamp, a literal ., and the exact raw request body bytes (before any JSON parsing), then HMAC-SHA256 that with your webhook secret. Hex-encode the result and compare it to X-ProxyHat-Signature with a constant-time comparison. Binding the timestamp into the signed string is what stops a captured, valid delivery from being replayed later with a fresh timestamp: also reject any delivery whose timestamp is more than 5 minutes old (or from the future), even if the signature itself checks out.
import hashlib
import hmac
import time
def verify_webhook(secret: str, timestamp_header: str, raw_body: bytes, signature_header: str) -> bool:
# Reject stale or future-dated deliveries before touching the signature.
if abs(time.time() - int(timestamp_header)) > 300:
return False
signed_string = f"{timestamp_header}.".encode() + raw_body
expected = "sha256=" + hmac.new(secret.encode(), signed_string, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header)
# Flask example:
# ts = request.headers["X-ProxyHat-Timestamp"]
# sig = request.headers["X-ProxyHat-Signature"]
# if not verify_webhook(WEBHOOK_SECRET, ts, request.get_data(), sig):
# abort(401)
import crypto from "crypto";
function verifyWebhook(secret, timestampHeader, rawBody, signatureHeader) {
// Reject stale or future-dated deliveries before touching the signature.
if (Math.abs(Date.now() / 1000 - Number(timestampHeader)) > 300) return false;
const signedString = Buffer.concat([Buffer.from(`${timestampHeader}.`), rawBody]);
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(signedString).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express example (mount with express.raw({ type: "application/json" }) so req.body is a Buffer):
// const ts = req.header("X-ProxyHat-Timestamp");
// const sig = req.header("X-ProxyHat-Signature");
// if (!verifyWebhook(WEBHOOK_SECRET, ts, req.body, sig)) return res.sendStatus(401);
A renewal did not land. Fired from two different places with two different payload shapes — see the warning below.
Either the proxy object + reason (an immediate, synchronous failure, e.g. insufficient balance) or the order object + reason (an armed-mode provider's later, asynchronous refusal correcting an earlier optimistic proxy.renewed).
proxy.expiring
Once daily, for every proxy 7 or 1 calendar days from expires_at (whether or not it has auto-renew).
The proxy object plus days_left (7 or 1).
proxy.expired
Once daily, the first time a proxy is observed past expires_at.
The proxy object.
balance.low
At most once per day, when your balance is under either your low_balance_threshold_usd or the forecast cost of renewing every auto-renew proxy due in the next 7 days.
{ "id": ..., "amount_usd": ..., "balance_usd": ... } (the balance after credit).
proxy.renewal_failed does not have one fixed schema. An immediate rejection (insufficient balance, or any exception thrown directly from your POST /renew call or the nightly auto-renew pass) reports the proxy resource. A later correction — an armed-mode provider like CyberYozh confirming failure only after we had already reported proxy.renewed optimistically — reports the order resource instead. Both carry a top-level reason string either way, so branch on whether the payload has an ip field (proxy) or a kind field (order) rather than assuming one shape, or simply treat the payload as a hint and re-fetch current state from GET /proxies/{id}.
Deliveries retry on failure at 30s, 2 minutes, 10 minutes, 1 hour, 6 hours, then daily — 6 attempts total before a delivery is marked failed for good. proxy.expiring / proxy.expired run once daily; proxy.renewed / proxy.renewal_failed from the same daily pass run at 04:40 UTC; the unfulfilled-order sweep behind order.refunded runs hourly.