API Reference
Complete REST API documentation for managing projects, credentials, connectors, and proxies.
Base URL
All API endpoints are prefixed with /api/v1. For example:
http://localhost:8000/api/v1/projects
Authentication
All API endpoints except /api/v1/auth/* and /health require a bearer token: either a session token from a login, or a personal access token. Mutation endpoints require at least Editor role; user management requires Admin role.
Login
POST /api/v1/auth/login
Content-Type: application/json
{
"username": "admin",
"password": "your-password"
}
Response:
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "bearer",
"expires_in": 86400
}
Using the Token
Include the token in the Authorization header:
curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \
http://localhost:8000/api/v1/projects
Check Auth Status
GET /api/v1/auth/status
Personal Access Tokens
A token (octo_...) is sent exactly like a session token and acts as its user with the user’s current role; revoking it, or disabling the user, takes effect on the next call. Only a hash is stored, so the secret appears in the create response and nowhere else. See Single Sign-On and API Tokens.
| Method | Path | Description |
|---|---|---|
| GET | /users/me/tokens |
The caller’s tokens: id, name, token_prefix, expires_at, last_used_at, expired |
| POST | /users/me/tokens |
Create one from {name, expires_in_days} (expires_in_days null for no expiry). Returns {token, api_token}. Refused (403) when the caller is itself a token |
| DELETE | /users/me/tokens/{id} |
Revoke |
| GET | /users/{user_id}/tokens |
A user’s tokens (admin) |
| POST | /users/{user_id}/tokens |
Issue a token on a user’s behalf (admin); how a service account gets one |
| DELETE | /users/{user_id}/tokens/{id} |
Revoke (admin) |
curl -X POST -H "Authorization: Bearer <session token>" -H "Content-Type: application/json" \
-d '{"name": "ci", "expires_in_days": 90}' http://localhost:8000/api/v1/users/me/tokens
# {"token": "octo_...", "api_token": {"id": "...", "name": "ci", "token_prefix": "octo_abcdefg", ...}}
curl -H "Authorization: Bearer octo_..." http://localhost:8000/api/v1/projects
Sign in with a Provider
The round trip through Google, Microsoft or GitHub is driven by the browser, not by API clients; scripts use a personal access token. These endpoints are public. See Single Sign-On and API Tokens.
| Method | Path | Description |
|---|---|---|
| GET | /auth/sso/providers |
The enabled providers, [{kind, label}], for the login page |
| GET | /auth/sso/{kind}/start |
Redirects the browser to the provider (kind: google, microsoft, github) |
| GET | /auth/sso/{kind}/callback |
Where the provider sends the browser back. Ends at /sso/complete?code=... on success, or at /?sso_error=... with a reason |
| POST | /auth/sso/exchange |
Trade the one-time {code} from the callback for {access_token, token_type, expires_in}. Single use, valid for a minute |
The providers themselves are set up by an admin:
| Method | Path | Description |
|---|---|---|
| GET | /sso/providers |
Every provider with enabled, client_id, has_client_secret, tenant_id, configured and the redirect_uri to register |
| PUT | /sso/providers/{kind} |
Save {enabled, client_id, client_secret, tenant_id}; a null client_secret keeps the stored one. Microsoft requires a single tenant ID |
Linked provider accounts are listed and removed under /users/me/identities and /users/{user_id}/identities (admin), each with provider, email, created_at and last_login_at. User responses carry sign_in_providers, the kinds linked to the user.
Request IDs
Every response carries an X-Request-ID header. Send your own value in the same request header (up to 128 characters of letters, digits, ., _, : and -) to have it used and echoed back; otherwise one is generated. Quote the ID when reporting a failed call, as it appears on every server log line written while handling that request.
curl -i -H "X-Request-ID: deploy-42-step-3" http://localhost:8000/api/v1/projects
Projects
Projects provide multi-tenancy support. Each project has its own credentials, connectors, and proxy pools.
List Projects
GET /api/v1/projects
Create Project
POST /api/v1/projects
Content-Type: application/json
{
"name": "My Project",
"description": "Production proxy pool",
"username": "proxy-user",
"password": "proxy-password",
"routing_strategy": "round_robin"
}
Get Project
GET /api/v1/projects/{project_id}
Update Project
PATCH /api/v1/projects/{project_id}
Content-Type: application/json
{
"name": "Updated Name",
"routing_strategy": "least_used"
}
Delete Project
DELETE /api/v1/projects/{project_id}
Connect
What a client sends to use the project’s proxy for a place and session, and where it would go. This is what the Connect page shows. Every query parameter is optional:
GET /api/v1/projects/{project_id}/connect?country=us&state=ny&city=New%20York&sessid=job-1
The username is composed from the project’s and the parameters, then read by the parser the proxy listener uses, so the normalised form and any refusal are exactly what a client would get:
{
"endpoint": {"scheme": "https", "host": "proxy.example.com", "port": null},
"username": "myuser-cc-us-st-ny-city-new_york-sessid-job-1",
"sessid": "job-1",
"location": {"country": "US", "state": "NY", "city": "new_york"},
"error": null,
"routes": [
{"connector_id": "...", "connector_name": "ISP static", "dynamic": false, "on_demand": false, "proxies": 2, "share": 0.75},
{"connector_id": "...", "connector_name": "Residential", "dynamic": true, "on_demand": false, "proxies": 1, "share": 0.25}
],
"countries": ["DE", "US"],
"any_country": false,
"places": [{"country": "US", "state": "NY", "city": "new_york", "proxies": 2}]
}
| Field | Meaning |
|---|---|
endpoint |
Where clients reach the proxy, from OCTOPROX_PROXY_PUBLIC_URL: scheme (http or https), host, and port, which is null when the URL names none (clients use the scheme’s default: 443 for https, which URLs then leave out, and 80 for http, which they should name, since curl reads a bare http:// proxy as port 1080). Without the setting: http, host null (the host the web UI is reached at) and server.proxy_port |
username |
The username to send, normalised; as composed when it cannot be read |
error |
The proxy’s 400 message for the username, or null. A value containing a reserved delimiter (or forming one with the dash before or after it), or a session ID with a colon, is refused here too |
routes |
The enabled connectors a request with the username would reach now, with the routable proxies of each and the expected share of requests by weight. dynamic marks a dynamic-sessions gateway, on_demand an “All countries” pool that would create sessions for the country on the first request. Empty means the proxy would refuse the request: 502, or 429 while the proxies that could serve it are rate limited, or the traffic-limit status (509 by default) while their connectors are over their limit. No destination is known, so domain filters are not applied |
countries |
Countries the project serves: every connector’s declared list and the known country of every healthy exit |
any_country |
A country outside countries is served too: a dynamic-sessions connector or residential pool lists no countries |
places |
States and cities healthy fixed exits are known to be in, busiest first, at most 200 |
Credentials
Credentials store cloud provider authentication details.
List Credentials
GET /api/v1/projects/{project_id}/credentials
Create Credential
type is any provider id from the catalog. The config is validated against the provider’s field schema; descriptor providers with a validation call also verify the credential with the vendor and may store captured values (for example Bright Data’s customer id). Names are unique per project, ignoring case: a duplicate returns 400.
POST /api/v1/projects/{project_id}/credentials
Content-Type: application/json
{
"name": "AWS Production",
"type": "aws",
"config": {
"auth_mode": "access_keys",
"access_key": "AKIAIOSFODNN7EXAMPLE",
"secret_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
}
}
An AWS credential can also carry no secrets at all. With "auth_mode": "host_role" Octoprox uses the identity of the host it runs on (EC2 instance profile, ECS task role, EKS pod identity or AWS_* environment variables). Either mode may add role_arn and an optional external_id to assume a role with sts:AssumeRole before touching EC2. See the AWS setup guide for the IAM side.
{
"name": "AWS Customer Account",
"type": "aws",
"config": {
"auth_mode": "host_role",
"role_arn": "arn:aws:iam::123456789012:role/octoprox-proxies",
"external_id": "acme-7f3b9c"
}
}
GCP and Azure credentials have the same shape. "auth_mode": "host_identity" uses the service account, managed identity or workload identity of the host and stores nothing; the defaults service_account_key and client_secret keep the stored-secret behaviour. GCP may add impersonate_service_account in either mode; Azure host mode may add target_tenant_id and target_client_id for an app registration federated to the host identity. See the GCP and Azure setup guides.
Credential Types: aws, gcp, azure, static
Get Credential
GET /api/v1/projects/{project_id}/credentials/{credential_id}
Update Credential
PATCH /api/v1/projects/{project_id}/credentials/{credential_id}
Delete Credential
DELETE /api/v1/projects/{project_id}/credentials/{credential_id}
Connectors
Connectors define how proxies are provisioned (cloud instances or static).
List Connectors
GET /api/v1/projects/{project_id}/connectors
Create Connector
Connector names are unique per project, ignoring case: a duplicate returns 400.
POST /api/v1/projects/{project_id}/connectors
Content-Type: application/json
{
"name": "AWS US-East Proxies",
"credential_id": "credential-uuid",
"config": {
"instance_name": "octoprox-proxy",
"region": "us-east-1",
"instance_type": "t3.micro",
"security_group": "sg-0123456789abcdef0",
"min_proxies": 1,
"max_proxies": 10
},
"routing_config": {
"domain_whitelist": ["example.com", "api.example.com"]
},
"rate_limit_config": {
"max_requests": 100,
"window_seconds": 60,
"quarantine_seconds_min": 120,
"quarantine_seconds_max": 300
}
}
Routing weight (routing_config.weight)
routing_config.weight is the connector’s relative share of the project’s traffic against the other connectors that can serve a request: an integer from 1 to 100, default 1. Weights 1 and 3 split requests 25/75. The share does not depend on how many proxies the connector holds. The default is not stored, so a connector at weight 1 shows no weight key. See Connector weights.
{
"routing_config": {
"weight": 3,
"domain_blacklist": ["blocked.com"]
}
}
Domain Filtering (routing_config)
Connectors support optional domain-based filtering to control which target domains their proxies serve. The routing_config field accepts:
domain_whitelist- Only route requests for these domains through this connector’s proxies.domain_blacklist- Route all requests except these domains through this connector’s proxies.
Whitelist and blacklist are mutually exclusive - you can set one or the other, but not both.
Domain matching is hierarchical: entering bing.com matches bing.com and all subdomains (www.bing.com, images.bing.com, etc.).
Examples:
Whitelist - only allow specific domains:
{
"routing_config": {
"domain_whitelist": ["example.com", "api.example.com"]
}
}
Blacklist - block specific domains:
{
"routing_config": {
"domain_blacklist": ["blocked.com", "ads.tracker.net"]
}
}
No restrictions (default):
{
"routing_config": {}
}
Countries (config)
Connectors declare the countries their proxies exit from inside config, so clients can pick them with a -cc-<code> suffix on the proxy username (see Country Routing). Codes are ISO 3166-1 alpha-2 and are normalised to upper case, with UK stored as GB; an empty list is dropped.
- Static and cloud connectors:
config.countries, a list of codes. - Provider connectors: the provider’s country field (
config.country_codefor the built-in descriptors). It accepts one code, a list, or a comma-separated string and is always returned as a list. Listing several countries provisionsnum_proxiesslots per country.
{
"name": "EU residential",
"credential_id": "…",
"config": { "num_proxies": 5, "country_code": ["DE", "FR", "NL"] }
}
Proxy responses include country, the exit country when the vendor reported it or the slot was provisioned for one, and state_code and city, each with a state_source or city_source (manual when pinned by hand, else the attribution source that resolved it), the place -st- and -city- requests match the proxy on (see State and City Routing). Connector responses include target: the intended pool size (total, or null when the connector has none) and how it is derived, per_country times the countries that have a slot group, with the ones created on demand listed in on_demand.
Manually added proxies (static connectors) get their exit location looked up right after they are created: one request goes through the proxy to the proxy.geo_lookup endpoint and the discovered IP becomes display_host while the country lands in country. Pass country on POST .../proxies to set it yourself and skip the lookup, set country to "" on PATCH .../proxies/{id} to clear it, or call POST .../proxies/{id}/locate to re-run the lookup and get the updated proxy back (502 when the request through the proxy fails). state (an ISO 3166-2 subdivision part such as NY, ON or ENG, or the full US-NY) and city (any spelling; stored as a slug) pin the state and city by hand on create and update; an empty string clears each. Pinned values win over the databases for routing and are what attribution verifies the exit against.
Rate Limiting (rate_limit_config)
Connectors support optional per-proxy rate limiting. When a proxy exceeds max_requests within window_seconds, it is quarantined (excluded from selection) for a random duration between quarantine_seconds_min and quarantine_seconds_max. See the Rate Limiting guide for details.
| Field | Type | Description |
|---|---|---|
max_requests |
integer | Max requests per proxy in the window |
window_seconds |
integer | Sliding window duration (1-86400) |
quarantine_seconds_min |
integer | Min quarantine duration (1-86400) |
quarantine_seconds_max |
integer | Max quarantine duration (1-86400) |
sticky_quarantine |
boolean | Block sticky session fallback (default: false) |
Example:
{
"rate_limit_config": {
"max_requests": 100,
"window_seconds": 60,
"quarantine_seconds_min": 120,
"quarantine_seconds_max": 300,
"sticky_quarantine": false
}
}
Disabled (default):
{
"rate_limit_config": {}
}
Traffic Limits (traffic_config)
Connectors meter the bytes through their proxies per billing period and can cap them. traffic_config holds the period, the limit, what happens at the limit and the price per GB; see the Traffic Limits & Billing guide. Only explicit choices are stored, a key at its default is dropped.
| Field | Type | Default | Description |
|---|---|---|---|
limit_bytes |
integer | none | Bytes per period before action applies; omit to meter only |
period |
day, week, month |
month |
How often usage starts over (UTC) |
reset_day |
integer | 1 |
Day of month (1-28) for month, weekday (1 = Monday) for week |
action |
alert, block, interrupt |
alert |
Flag only, stop new requests, or also cut running transfers |
warn_percent |
integer | 80 |
Share of the limit at which the connector is flagged |
limit_status |
509 or 429 |
509 |
Status when every eligible connector is over its limit |
price_per_gb |
number | none | Price per decimal GB (1 GB = 1,000,000,000 bytes) |
currency |
string | USD |
ISO 4217 code |
Example:
{
"traffic_config": {
"limit_bytes": 500000000000,
"period": "month",
"reset_day": 15,
"action": "block",
"price_per_gb": 8
}
}
Every connector response carries traffic_usage, the usage in the current period as the serving instance sees it:
{
"traffic_usage": {
"period": "month",
"period_start": "2026-09-15T00:00:00",
"period_end": "2026-10-15T00:00:00",
"bytes_sent": 12000000000,
"bytes_received": 230000000000,
"total_bytes": 242000000000,
"limit_bytes": 500000000000,
"percent": 48.4,
"action": "block",
"status": "ok",
"blocked": false,
"price_per_gb": 8.0,
"currency": "USD",
"cost": 1936.0,
"reset_at": null
}
}
status is ok, warning (past warn_percent) or exceeded. blocked is true while the connector takes no new requests. percent and cost are null without a limit or a price. reset_at is set when usage was reset by hand inside the period; traffic_reset_at on the connector holds the same timestamp.
Clients whose request finds every eligible connector over its limit get the configured status (509 by default) with a Proxy-Status: octoprox; error=bandwidth_limit_exceeded header.
Reset Traffic Usage
Start the connector’s usage over from now (editor role). History is kept; the count against the limit restarts and a block is lifted:
POST /api/v1/projects/{project_id}/connectors/{connector_id}/traffic/reset
Returns the updated connector.
Connector Metrics History
The connector’s own metrics history, which survives its proxies being rotated or re-synced:
GET /api/v1/projects/{project_id}/connectors/{connector_id}/metrics/history?range=24h
Same range values and response shape as project metrics history.
Unquarantine Proxy
Forcefully remove a proxy from quarantine:
POST /api/v1/projects/{project_id}/proxies/{proxy_id}/unquarantine
Returns 200 with {"status": "ok", "proxy_id": "..."} on success, 400 if the proxy is not quarantined.
Get Connector Options
Get available regions, instance types, the country list and other static options:
GET /api/v1/connector-options
Provider-specific dynamic options (zones, entry nodes, sub-users) are served by the provider options endpoint.
Get Connector
GET /api/v1/projects/{project_id}/connectors/{connector_id}
Update Connector
PATCH /api/v1/projects/{project_id}/connectors/{connector_id}
Content-Type: application/json
{
"name": "Updated Name",
"routing_config": {
"domain_whitelist": ["new-domain.com"]
}
}
Delete Connector
DELETE /api/v1/projects/{project_id}/connectors/{connector_id}
Proxies
Proxies are the actual proxy servers managed by Octoprox.
List Proxies
GET /api/v1/projects/{project_id}/proxies
Create Proxy (Static)
POST /api/v1/projects/{project_id}/proxies
Content-Type: application/json
{
"connector_id": "connector-uuid",
"host": "192.168.1.100",
"port": 3128,
"protocol": "http",
"username": "proxy-user",
"password": "proxy-pass"
}
Protocols: http, https, socks4, socks5
Upload Proxies (Bulk)
Upload multiple proxies from a CSV file:
POST /api/v1/projects/{project_id}/proxies/upload
Content-Type: multipart/form-data
file: proxies.csv
connector_id: connector-uuid
CSV format (one proxy per line):
http://192.168.1.1:8080
socks5://user:pass@10.0.0.1:1080
Get Proxy
GET /api/v1/projects/{project_id}/proxies/{proxy_id}
Delete Proxy
DELETE /api/v1/projects/{project_id}/proxies/{proxy_id}
Proxy location fields
Every proxy response carries the outcome of IP attribution: country (what routing uses), country_source (database, vendor, endpoint or manual), vendor_country (what the vendor claimed), location_conflict (the claim is contradicted), location_verdict (confirmed, contradicted or open; null when nothing was claimed or the exit was not judged yet), location (region, state_code, city, coordinates, ASN and anonymity flags from the databases, when any covers the IP), state_code, state_source, city and city_source (the state and city routing matches on: pinned by hand, or resolved from the databases, then the echo endpoint, then the vendor’s word, like the country), and state_verdict and city_verdict (how a claimed state or city fared, with the same values as location_verdict). Setting country by hand pins it as manual: attribution then verifies the exit against it instead of overwriting it.
Metrics
Get Project Metrics
GET /api/v1/projects/{project_id}/metrics
Response:
{
"total_proxies": 10,
"healthy_proxies": 8,
"total_requests": 15420,
"success_rate": 0.98,
"avg_latency_ms": 145.2
}
Traffic Split
How the project’s traffic divides between its connectors under the current weights, expected and observed.
GET /api/v1/projects/{project_id}/metrics/traffic-split?range=1h
range is one of 1h (default), 6h, 24h, 7d, 30d and bounds the observed counts.
Response:
{
"strategy": "round_robin",
"range": "1h",
"total_weight": 4,
"observed_requests": 1200,
"connectors": [
{
"connector_id": "…",
"name": "Oxylabs residential",
"credential_type": "oxylabs",
"enabled": true,
"weight": 3,
"dynamic": true,
"total_proxies": 1,
"eligible_proxies": 1,
"expected_share": 75.0,
"excluded_reason": null,
"observed_requests": 880,
"observed_share": 73.33,
"observed_bytes": 18400000000,
"cost": 147.2,
"currency": "USD"
},
{
"connector_id": "…",
"name": "DC pool",
"credential_type": "static_proxy_provider",
"enabled": true,
"weight": 1,
"dynamic": false,
"total_proxies": 20,
"eligible_proxies": 18,
"expected_share": 25.0,
"excluded_reason": null,
"observed_requests": 320,
"observed_share": 26.67,
"observed_bytes": 6100000000,
"cost": null,
"currency": null
}
]
}
expected_share (0-100) is what an untargeted request sees right now: every enabled connector with at least one eligible proxy takes weight / total_weight. A connector taking nothing has excluded_reason set to disabled, traffic_limit (blocked by its traffic limit) or no_eligible_proxies. observed_share is the connector’s part of the requests in the window, null when there were none; observed_bytes is its traffic in the window and cost what that traffic costs at the connector’s price per GB (null when unpriced). The observed figures come from the connector’s own metrics history, so they survive proxy rotation. The response also carries observed_bytes for the whole project.
Hosts
Where the project’s traffic went in a window, by destination host and by the connector that carried it. The numbers come from the flushed host history (see Metrics), so they lag live traffic by up to one flush interval.
GET /api/v1/projects/{project_id}/metrics/hosts?range=24h&connector_id=…&search=example&limit=100
range is one of 1h, 6h, 24h (default), 7d, 30d. connector_id keeps only the traffic that connector carried, search only hosts containing the text (case-insensitive), and limit (1-500, default 100) caps how many hosts come back, busiest first; totals.host_count says how many matched in all.
Response:
{
"range": "24h",
"since": "2026-10-09T10:00:00",
"overflow_host": "(other)",
"enabled": true,
"limit": 100,
"totals": {
"host_count": 42,
"request_count": 18200,
"success_count": 17900,
"failure_count": 300,
"avg_latency_ms": 212.4,
"bytes_sent": 91000000,
"bytes_received": 4830000000
},
"connectors": [
{ "connector_id": "…", "name": "Oxylabs residential", "credential_type": "oxylabs", "enabled": true }
],
"hosts": [
{
"host": "www.example.com",
"request_count": 9100,
"success_count": 9050,
"failure_count": 50,
"avg_latency_ms": 180.2,
"bytes_sent": 40000000,
"bytes_received": 2900000000,
"request_share": 50.0,
"bytes_share": 59.73,
"connectors": [
{
"connector_id": "…", "name": "Oxylabs residential", "credential_type": "oxylabs",
"request_count": 9100, "success_count": 9050, "failure_count": 50,
"avg_latency_ms": 180.2, "bytes_sent": 40000000, "bytes_received": 2900000000
}
]
}
]
}
request_share and bytes_share (0-100) are the host’s part of the window’s requests and of its bytes both ways. A host named overflow_host is where requests to hosts beyond a configured ceiling were folded (see metrics.hosts.max_hosts in Configuration; it never appears with the default of no ceiling); enabled is false when per-host counting is switched off, in which case nothing new is recorded. A connector entry whose name is null was deleted after its rows were written; its rows cascade with it on the next flush.
The busiest hosts over time, for a chart:
GET /api/v1/projects/{project_id}/metrics/hosts/history?range=24h&top=8&connector_id=…
GET /api/v1/projects/{project_id}/metrics/hosts/history?range=24h&hosts=www.example.com,api.example.com
The top hosts by requests (1-12, default 8), or the hosts named (comma-separated, up to 12), get a series each; every other host is folded into one series under overflow_host. Buckets are sized to the range: 1 minute for 1h, 5 minutes for 6h, 15 minutes for 24h, 1 hour for 7d, 6 hours for 30d. Every series carries every bucket of the window, with zeros where the host was quiet, so they stack.
Response:
{
"range": "24h",
"since": "2026-10-09T10:00:00",
"bucket_seconds": 900,
"overflow_host": "(other)",
"series": [
{
"host": "www.example.com",
"points": [
{ "timestamp": "2026-10-09T10:00:00", "request_count": 120, "success_count": 119, "failure_count": 1,
"avg_latency_ms": 171.0, "bytes_sent": 500000, "bytes_received": 38000000 }
]
},
{ "host": "(other)", "points": [ { "timestamp": "2026-10-09T10:00:00", "request_count": 31, "…": "…" } ] }
]
}
Failure log
Why the project’s requests failed or were refused (see Metrics). The summary counts every failure from the metrics history; the examples are a sample, kept per kind, that appear within about 5 seconds of the failure.
Summary
GET /api/v1/projects/{project_id}/failures/summary?range=24h
range is one of 1h, 6h, 24h (default), 7d, 30d. The numbers come from the flushed history, so they trail live traffic by up to one flush interval.
Response:
{
"range": "24h",
"since": "2026-10-14T11:00:00",
"bucket_seconds": 900,
"total": 3750,
"kinds": [
{ "kind": "upstream_rejected", "count": 3097,
"upstream_statuses": [ { "status": 407, "count": 2055 }, { "status": 502, "count": 917 } ] },
{ "kind": "upstream_timeout", "count": 463, "upstream_statuses": [] },
{ "kind": "no_proxy_available", "count": 190, "upstream_statuses": [] }
],
"connectors": [
{ "connector_id": "a2b9…", "name": "Bright Data residential", "count": 3560 },
{ "connector_id": null, "name": null, "count": 190 }
],
"series": [
{ "kind": "upstream_rejected", "points": [ { "timestamp": "2026-10-14T11:00:00", "count": 12 } ] }
],
"enabled": true
}
kinds and connectors are ordered most first; a connector of null stands for requests refused before a proxy was chosen. series has one entry per kind in the same order, each with every bucket of the window (zeros where it was quiet), so they stack.
Examples
GET /api/v1/projects/{project_id}/failures?kind=upstream_rejected&count=200&cursor=…
Newest first. kind keeps one kind (leave it out for all of them), count is 1-1000 (default 200), and next_cursor is set when older examples remain; pass it as cursor for the next page.
Response:
{
"records": [
{
"id": "1760526000123-0:upstream_rejected",
"kind": "upstream_rejected",
"status": 502,
"message": "407 Proxy Authentication Required",
"error_type": "ProxyError",
"method": "CONNECT",
"target": "www.example.com:443",
"session_id": "order-1",
"location": "US/NY",
"peer_id": null,
"peer_name": null,
"proxy_id": "6f1c…",
"connector_id": "a2b9…",
"connector_name": "Bright Data residential",
"proxy": "brd.superproxy.io:33335",
"upstream_status": 407,
"latency_ms": 212.4,
"count": 37,
"first_at": "2026-10-15T10:59:58.120000",
"last_at": "2026-10-15T11:00:02.870000"
}
],
"next_cursor": null,
"total": 1,
"appended": 1,
"enabled": true,
"max_entries": 1000,
"retention_hours": 168
}
kind is one of upstream_timeout, upstream_unreachable, upstream_rejected, upstream_error, client_closed, interrupted (the failures the request metrics count), no_proxy_available, all_proxies_quarantined, traffic_limit_exceeded, preflight_mismatch, invalid_location (refused before a proxy was used), or intercepted_request_failed (one request inside a TLS-intercepted connection). status is what the client was answered; upstream_status is the vendor’s HTTP status when an HTTP upstream refused the request. count is how many identical failures the example stands for, between first_at and last_at; session_id is the first one’s. total is how many examples are kept and max_entries how many are kept per kind.
appended counts the examples ever added (of the kind asked for, or all kinds). To tell how many are newer than a page you loaded, poll the head and subtract:
GET /api/v1/projects/{project_id}/failures/head?kind=upstream_rejected
{ "appended": 1042, "total": 1000 }
Clearing
DELETE /api/v1/projects/{project_id}/failures
Discards the examples (editors and admins). The counts are part of the metrics history and stay, and new failures keep being recorded.
Prometheus Metrics
Export metrics in Prometheus format:
GET /api/v1/projects/{project_id}/metrics/prometheus
Besides the project gauges and counters, each connector’s traffic this period is exported with project, connector and name labels:
| Metric | Type | Description |
|---|---|---|
octoprox_connector_traffic_bytes |
gauge | Bytes through the connector in its current traffic period |
octoprox_connector_traffic_limit_bytes |
gauge | The period’s limit, for connectors that have one |
octoprox_connector_traffic_cost |
gauge | Spend at the connector’s price per GB, with a currency label |
octoprox_connector_traffic_blocked |
gauge | 1 while the connector takes no requests because of its limit |
Backup & Migration
Admin-only endpoints for exporting the entire Octoprox setup to a single encrypted file and restoring it on the same or another instance. Useful for disaster recovery and for migrating between deployments.
Export Backup
POST /api/v1/backup/export
Request:
{
"passphrase": "correct horse battery staple",
"include_metrics": false,
"include_database_files": false
}
| Field | Type | Notes |
|---|---|---|
passphrase |
string | Minimum 8 characters. Required again to import - it cannot be recovered. |
include_metrics |
boolean | Include history: proxy/project metrics, exit IP observations and per-connector exit IPs. Default false (smaller file). |
include_database_files |
boolean | Include the bytes of uploaded and downloaded IP databases. Default false; a city database is tens of megabytes. Their rows are always included, so without the files the restored instance lists them without a file, ready to re-upload or refresh. |
Response: the backup file as application/octet-stream with a
Content-Disposition: attachment; filename="octoprox-backup-YYYY-MM-DD.opbak"
header.
The file covers users (including password hashes), projects, credentials, connectors, proxies, custom provider descriptors and their audit log, and the IP attribution settings and database records. Optionally it also carries the history (metrics and attribution observations) and the IP database files.
Import Backup
POST /api/v1/backup/import
Content-Type: multipart/form-data
| Form field | Type | Notes |
|---|---|---|
file |
file | The .opbak file produced by export. |
passphrase |
string | The passphrase used when exporting. |
mode |
string | Only replace is supported (default). |
keep_current_user |
boolean | Default false. See below. |
Import replaces all existing data on the instance. The wipe and restore run in a single transaction, so a failure leaves the existing data untouched. Afterwards the live proxy cache is rebuilt, stale Redis state for the replaced projects and proxies is purged, and IP attribution reloads its settings and reopens whatever database files the backup carried. Other instances pick the changes up on their periodic reload.
With keep_current_user=true the calling admin’s own account survives the
wipe, so an admin importing a backup taken from another instance is not locked
out. Imported users that would collide with the kept account are adjusted:
- same
id→ the imported user receives a fresh id - same
username→ the imported user is renamed to<username>-imported(then-imported-2,-imported-3, … if needed) - same non-empty
email→ the imported user’s email is cleared
With keep_current_user=false (the default) users are restored exactly as they
are in the backup; the current session’s user may no longer exist, so log in
again with credentials that are valid in the backup.
Response:
{
"users": 3,
"projects": 2,
"credentials": 1,
"connectors": 2,
"proxies": 14,
"proxy_metrics": 0,
"project_metrics": 0,
"provider_descriptors": 0,
"provider_audit_log": 0,
"geo_settings": 1,
"geo_databases": 2,
"geo_database_blobs": 0,
"ip_observations": 0,
"connector_exit_ips": 0,
"kept_current_user": true,
"user_conflicts": [
{
"original_username": "admin",
"new_username": "admin-imported",
"new_id": true,
"email_cleared": false
}
]
}
users counts imported rows only; the kept account is not included.
Errors (HTTP 400):
- Incorrect passphrase or corrupt file.
- File is not an Octoprox backup.
- Backup was created by a newer Octoprox (
format_versiontoo high). - Schema mismatch: the backup’s Alembic revision differs from this instance’s. Upgrade both instances to the same Octoprox version, then retry. This check runs before decryption, so it does not require the passphrase.
keep_current_user=truebut the caller’s account has no database row.
Backup File Format
An .opbak file is a small JSON envelope. The metadata is plain text so an
importer can check compatibility before asking for the passphrase; the data is
encrypted:
{
"format": "octoprox-backup",
"format_version": 1,
"created_at": "2026-09-04T10:15:00+00:00",
"app_version": "1.0.1",
"schema_version": "<alembic revision>",
"includes_metrics": false,
"kdf": { "algo": "pbkdf2-sha256", "iterations": 600000, "salt": "<base64>" },
"ciphertext": "<base64 Fernet token>"
}
The ciphertext is gzipped JSON encrypted with Fernet (AES-128-CBC + HMAC-SHA256). The key is derived from the passphrase with PBKDF2-HMAC-SHA256 and a random per-file salt. Treat the file as sensitive: it contains password hashes and provider credentials, protected only by the passphrase.
System Statistics
Admin-only. One endpoint returning what exists in the install, what it costs in Postgres and Redis, and which background workers are alive. This is what the Settings → System page renders.
GET /api/v1/system/stats
Editors and viewers receive 403.
Response sections:
| Section | Scope | Contents |
|---|---|---|
runtime |
This instance | Version, instance id, role, uptime, ports, worker intervals, and event_loop_lag_max_ms / event_loop_lag_mean_ms: how late the event loop ran timers over the last minute (null before the first minute). A worst case over a second is also logged as Event loop running behind. |
inventory |
Whole install | Exact counts of projects, credentials, connectors, proxies, users and providers, plus the live pool’s health breakdown. |
projects |
Whole install | Per-project credential, connector and proxy counts, busiest first. |
database |
Whole install | Database size, per-table size (indexes included), server connections and this instance’s pool usage. |
redis |
Whole install | Memory, throughput, hit rate and a breakdown of the keyspace by purpose. |
cache |
This instance | Entry counts of every in-memory cache the process holds. |
workers |
Mixed | tasks are this instance’s background loops and their run counters; leases and instances are cluster-wide, and each entry in instances carries that instance’s own snapshot. |
Scope matters behind a load balancer. runtime, cache and
workers.tasks describe only the instance that answered the request. With
several replicas, repeated calls may land on different instances and report
different numbers - that is accurate, not a bug. Everything Postgres- or
Redis-derived is the same from every instance.
Every instance, from any instance. Because a load balancer gives no way to
address a particular replica, each instance publishes those same three sections
about itself on its heartbeat, and they come back under
workers.instances[].snapshot:
| Field | Notes |
|---|---|
snapshot.runtime / snapshot.cache / snapshot.tasks |
The same shape as the top-level runtime, cache and workers.tasks, for that instance. |
snapshot.proxy_server_listening / proxy_server_connections |
That instance’s proxy listener, as in the top-level workers. |
snapshot.geo_lookup_enabled / geo_lookups_in_flight |
That instance’s exit-location lookups. |
age_seconds |
How long ago the snapshot was published - up to the 10s key TTL. Derived from the remaining TTL, so it does not depend on hosts agreeing on the time. null when there is no snapshot. |
A snapshot is republished every 5 seconds, so it is a recent reading rather
than a live one; the top-level sections remain live for the answering
instance, which is also listed (with its own, slightly older, snapshot).
snapshot is null for an instance running a version that predates
snapshots - during a rolling upgrade, for example. The Settings → System
page uses this to switch its per-instance cards between replicas.
Cost. Entity counts are exact. Table row counts are planner estimates
(pg_class.reltuples), so they stay cheap on metric tables with millions of
rows, drift until the next ANALYZE, and are null on a table autovacuum has
not reached yet. The Redis keyspace breakdown is a bounded SCAN; past 50,000
keys it reports a sample and sets "truncated": true.
Example (abridged):
{
"generated_at": "2026-09-19T21:52:35.740343",
"runtime": { "version": "2.2.2", "role": "all", "uptime_seconds": 3841.2 },
"inventory": {
"projects": 2, "credentials": 8, "connectors": 9, "connectors_enabled": 2,
"proxies": 29, "users": 1, "users_by_role": { "admin": 1 },
"proxies_by_status": { "healthy": 19, "unhealthy": 10 }
},
"database": {
"name": "octoprox",
"size_bytes": 8985623,
"tables": [
{ "name": "proxies", "row_estimate": 29, "total_bytes": 294912,
"table_bytes": 65536, "index_bytes": 229376,
"dead_rows": 4, "last_vacuum_at": "2026-10-10T06:12:40Z" }
],
"backends": 2, "pool_size": 5, "pool_checked_out": 1
},
"redis": {
"used_memory_bytes": 1558312, "total_keys": 33, "ops_per_sec": 4,
"groups": [ { "label": "Proxy health", "keys": 29 } ],
"truncated": false
},
"workers": {
"tasks": [
{ "name": "metrics_flusher", "scope": "singleton", "lease": "metrics_flusher",
"state": "running", "error": null, "interval_seconds": 60,
"runs": 412, "idle_runs": 0, "failures": 1, "consecutive_failures": 0,
"overruns": 3, "consecutive_overruns": 0,
"last_overrun_at": "2026-09-19T19:31:55.417002",
"last_run_at": "2026-09-19T21:52:30.114221", "last_duration_ms": 84.2,
"avg_duration_ms": 61.9, "max_duration_ms": 512.7,
"last_error": "TimeoutError: ", "last_error_at": "2026-09-19T18:04:11.002913" }
],
"leases": [
{ "name": "metrics_flusher", "kind": "Metrics flush to Postgres",
"worker": "metrics_flusher", "target": null,
"holder": "66eb615b-…", "held_by_self": true, "ttl_ms": 4027 }
],
"instances": [ { "instance_id": "66eb615b-…", "role": "all", "is_self": true, "ttl_seconds": 9 } ]
}
}
Two kinds of worker health. state and error describe the asyncio task:
not running means the loop on that instance has stopped, and error carries
the exception that ended it. The run counters describe the cycles inside the
loop - one health-check sweep, one metrics flush, one peer message applied - so
a loop that raises every cycle and recovers is still running, and only
consecutive_failures and last_error say it is broken. Counters are
per-process and reset with it; a cycle cancelled at shutdown is not counted.
Granularity is the cycle, not the item: the auto-scaler handles each connector
under its own try, so a cycle counts as a success even when one connector
failed.
Runs that had nothing to do. idle_runs is the subset of runs where the
loop ticked and returned early - the metric-delta publisher over an empty
buffer, a health-check sweep whose shard landed entirely on peers, a system
snapshot a peer already wrote, an auto-scaler cycle on an install with no cloud
connectors. Subtract it from runs for the cycles that did something. The
split matters because the two numbers answer different questions: an install
taking no traffic ticks the metric-delta publisher 17k times a day without a
single flush, and without idle_runs that reads as a busy worker. The
durations describe the working cycles only - an early return is not a
measurement of the work it skipped, and averaging it in would report a
publisher that spends 20ms on every real flush as taking 0.1ms. A worker with
no idle path (the heartbeat, the pub/sub subscribers) reports 0.
Cadence and overruns. interval_seconds is the interval the loop was
started with, declared by the loop itself so it is the value actually slept on
rather than a config setting read elsewhere - worth knowing, because a worker
whose module was configured differently from the rest of the process will say
so here. It is null for the two pub/sub subscribers, which run when a peer
message arrives and so have no cadence to miss. overruns counts cycles that
took longer than that interval: a loop cannot start its next cycle until the
current one returns, so those cycles pushed the worker off its cadence - the
system snapshotter shows this as gaps in the trend charts, and the others as
work simply happening less often than configured.
overruns is a lifetime count, so read it with consecutive_overruns, which
the first cycle back inside the cadence resets: that pair distinguishes “slow
right now” from “hit one slow cycle during a restart and has been fine since”,
and last_overrun_at says when the most recent one was. failures /
consecutive_failures work the same way. A climbing consecutive_overruns
with failures at zero is the signature of a worker that needs a longer
interval or less to do, rather than one that is broken.
Which worker runs where. scope is singleton for leader-elected loops
and instance for ones every instance runs. A singleton’s lease matches a
leases[].name (for per-connector leases, the part before the :), and each
lease names its worker in return - so “which instance is doing this job right
now” (leases) and “how is it going on this instance” (tasks) line up. A
standby’s singleton worker is legitimately at "runs": 0. Leader-elected work
is metrics flushing, compaction and system snapshots globally, auto-scaling and
provider sync (discovery and IP refresh) per connector.
An absent lease means two different things, which is what
lease_per_resource is for. A global singleton (false) holds its lease
continuously, so finding no holder in leases is a failover gap that closes
within seconds. A per-resource worker (true - the auto-scaler and provider
syncer) takes one lease per connector at the top of the work and releases it in
a finally a moment later, so between ticks no instance holds one, and
several instances can hold different ones at the same time. For those, an
absent lease is the resting state, not a standby; whether the loop is actually
working is answered by its run counters, not by leases.
System Trends
GET /api/v1/system/stats/history?range=24h
Admin-only. Returns the gauge history behind the Trends charts on the
System page. Ranges: 1h, 24h (raw snapshots) and 7d, 30d, 90d
(averaged into buckets).
Snapshots are written by the system_snapshotter worker on whichever instance
holds its lease, so unlike the per-instance sections of /system/stats, this
series reads the same from every instance.
Gauges, not counters. These are readings like “how big is the database”,
so downsampling a range averages them. Summing, as the proxy and project
metrics pipelines do for request counts, would invent a number that was never
true at any instant. bucket_seconds is null for raw ranges and set when
points are averages, so a chart can say which it is showing.
| Field | Notes |
|---|---|
snapshots |
Points oldest-first: database and Redis size, Redis keys, entity counts, proxy totals by health. |
table_growth |
Per-table size at both edges of the window plus the delta, biggest mover first. A difference between two points, so no bucketing is involved. |
bucket_seconds |
Bucket width, or null when points are raw. |
interval_seconds |
The configured snapshot cadence, so a client can tell a real gap from an expected one. |
Configuration (config/*.yaml, or OCTOPROX_SYSTEM_METRICS_* env vars):
system:
metrics_interval: 300 # seconds between snapshots; 0 disables the worker
metrics_retention_days: 90 # 0 keeps snapshots forever
The interval is floored at 60 seconds. At the default cadence this is one row
every five minutes for the whole install - about 26k rows over the 90-day
window - so retention alone keeps it bounded and there are no compaction tiers
like the ones proxy_metrics and project_metrics use.
Example:
{
"range": "7d",
"bucket_seconds": 3600,
"interval_seconds": 300,
"snapshots": [
{
"timestamp": "2026-09-19T21:00:00",
"database_size_bytes": 8985623, "redis_memory_bytes": 1558312, "redis_keys": 33,
"projects": 2, "credentials": 8, "connectors": 9, "connectors_enabled": 2, "users": 1,
"proxies_total": 29, "proxies_healthy": 19, "proxies_unhealthy": 10
}
],
"table_growth": [
{ "name": "project_metrics", "first_bytes": 131072, "last_bytes": 212992, "delta_bytes": 81920 }
]
}
IP Attribution
All endpoints live under /api/v1/geo. Reads need any authenticated user; writes need an admin. See IP Attribution for the concepts.
| Method | Path | Purpose |
|---|---|---|
| GET | /geo/settings |
Install-wide settings (default conflict rule, default_city_radius_km, echo endpoint, preflight, retention), whether an admin saved them, databases loaded on this instance |
| PUT | /geo/settings |
Replace the settings (body: the settings object). Applies to every instance |
| GET | /geo/databases |
Every database: stored rows plus config-file entries, with loaded_here, load_error and, for a scheduled download, last_update_at (the last check, whether or not the file changed) and next_update_at (when the schedule is next due; null without a schedule, while update_paused, or while the database is disabled) |
| POST | /geo/databases |
Upload a file (multipart/form-data: file, optional name, priority, enabled): an mmdb, an IP2Location BIN, or a GeoNames table (zipped or .txt, stored with vendor: geonames, kind: places, format: geonames). Validated before it is stored; proxies are re-attributed |
| POST | /geo/databases/from-url |
Register a vendor download URL (name, update_url which may carry {YYYY} and {MM} placeholders filled per run, update_interval_hours, update_auth, priority) and download it now. update_auth takes {username, password} for basic auth or {token} / {header, token} |
| POST | /geo/databases/inspect |
Open an uploaded file and report its metadata without storing it |
| PATCH | /geo/databases/{id} |
Change name, enabled, priority, update_url, update_interval_hours, update_auth, update_paused (true stops the schedule and keeps the file loaded and the URL, credentials and interval; a refresh still downloads) |
| POST | /geo/databases/{id}/refresh |
Download a scheduled database now |
| DELETE | /geo/databases/{id} |
Remove a stored database everywhere |
| POST | /geo/lookup |
{ip, claimed_country?, claimed_state?, claimed_city?, project_id?}: each database’s answer and how the IP resolves at every level, judging the optional claim as a vendor’s would be under the default conflict rule and city radius, or the project’s. resolution.city_distance_km is how far the observed city is from the claimed one when both could be placed |
| POST | /geo/reattribute |
{connector_id?}: re-run attribution offline for every proxy with a known exit IP; returns {updated} |
| GET | /geo/observations |
One page of observations, newest first, with total for paging. Filters apply on the server and mirror the table’s columns: connector_id, proxy_id, project_id, source, ip (exact), claimed_country, resolved_country, verdict (contradicted, open, confirmed, no_claim: the country verdict; the state and city verdicts come back per row and do not move the label), until (ISO time: start the log at that moment; without an offset it is UTC); paging with limit (max 1000) and offset. total is counted no further than 10,000 rows past the page: total_exact false makes it a lower bound. Each row also carries the place below the country: claimed_state, claimed_city, resolved_state, resolved_city, and one verdict per level, country_conflict, state_conflict, city_conflict (true contradicted, false confirmed, null no verdict) |
| GET | /geo/accuracy |
Per connector, over the distinct exit IPs last seen in the window: exits, then country, state and city, each {claimed, confirmed, contradicted, open, accuracy}. open is a claim without a verdict: for the country one that independent sources disagreed about, counted against accuracy (confirmed over claimed); for the state and city one no city-level database could judge, counted in neither (accuracy is confirmed over confirmed plus contradicted). breakdown lists the contradicted pairs as {level, claimed, observed, exits} (project_id, connector_id, days) |
| GET | /geo/exits |
Per connector: distinct exit IPs first seen in the window and ever, sightings, reuse (project_id, connector_id, days). Dynamic-sessions connectors carry coverage (preflight_on, sampled_percent): below 100 the figures for session-less traffic are undercounts |
| GET | /geo/observations/count |
{"total"}: every observation under the same filters, counted outright. Quick under project_id, connector_id, proxy_id or ip; under the other filters alone it reads the table, and past 30 s it answers 504 |
| GET | /geo/exits/ips |
One page of distinct exit IPs, most recently seen first, each with first and last sighting, hand-out count and the latest observation’s state (proxy, source, vendor claim, resolved country and source, verdict flags, and the same place fields as an observation). Filters: project_id, connector_id, ip, proxy_id, country, claimed_country, verdict (the country verdict, as for observations); paging with limit (max 1000) and offset; total and total_exact in the response, counted as for observations |
| GET | /geo/exits/ips/count |
{"total"}: every exit IP under the same filters, counted outright, as for observations |
| GET | /geo/status |
Pipeline health: databases, buffered, queued (queued_observations: published to Redis, not written yet) and stored observations (stored_observations: the database’s row estimate, within a few percent), preflight counters |
Projects carry location_contradicted (evidence, vendor, refuse: what to do when the independent sources agree against the vendor’s claim), location_unknown (vendor, refuse: what to do when nothing independent places the exit), location_preflight (boolean, whether a session’s exit is echoed before its first request), and the optional overrides location_conflict_rule (consensus, first, any) and location_city_radius_km (0 to 500 km; 0 compares city names only) on create, update and in responses. On update an empty string clears an override so the project inherits the install default again. There is no source order: the routing value is always the databases, then the echo endpoint, then the vendor.
Echo
GET /echo?nonce=<anything>
Public. Returns the caller’s IP and, with a database loaded, its attribution:
{"ip": "203.0.113.7", "country": "GB", "region": "England", "city": "London", "asn": 12345, "organization": "Example Ltd", "databases": ["<database id>"], "nonce": "…", "timestamp": "2026-09-21T12:00:00Z"}
Requested through a proxy it reports the proxy’s exit. Behind a load balancer, list the balancer in geo.echo.trusted_proxies so the address comes from X-Forwarded-For.
WireGuard
Devices that cannot use a proxy join a project through a WireGuard tunnel (see WireGuard Devices). Server settings are install-wide and need an admin to change; devices (peers) are project entities editors manage.
Server settings
GET /api/v1/wireguard/settings
Returns the public key, endpoint, subnet, gateway and the answering instance’s tunnel status:
{
"public_key": "HIgo9xNzJMWLKASShiTqIybxZ0U3wGLiUeJ1PKf8ykw=",
"endpoint_host": "vpn.example.com",
"endpoint_port": 51820,
"subnet": "10.66.0.0/16",
"gateway": "10.66.0.1",
"persistent_keepalive": 25,
"client_mtu": null,
"configured": true,
"updated_at": "2026-10-03T09:00:00",
"status": {
"enabled": true,
"state": "running",
"error": null,
"instance_id": "7d6b...",
"interface": "wg0",
"backend": "kernel",
"listen_port": 51820,
"transparent_port": 8081,
"dns_port": 5353,
"fake_ip_range": "198.18.0.0/15",
"active_connections": 3,
"peers_total": 4,
"peers_enabled": 4,
"peers_online": 2,
"connections_by_address": 0,
"encrypted_dns_blocked": 1,
"block_encrypted_dns": true
}
}
PUT /api/v1/wireguard/settings
Content-Type: application/json
{
"endpoint_host": "vpn.example.com",
"endpoint_port": 51820,
"subnet": "10.66.0.0/16",
"persistent_keepalive": 25,
"client_mtu": null
}
Admin only. The subnet must still contain every device’s address. Changing the subnet re-addresses the gateway on the next restart of the terminating instance.
POST /api/v1/wireguard/settings/rotate-key
Admin only. Issues a new server key pair; every device config becomes invalid.
Devices (peers)
GET /api/v1/projects/{project_id}/wireguard/peers
{
"total": 1,
"server_configured": true,
"server_public_key": "HIgo...",
"peers": [
{
"id": "…",
"project_id": "…",
"name": "Living room TV",
"public_key": "…",
"has_private_key": true,
"has_preshared_key": true,
"address": "10.66.0.2",
"enabled": true,
"session_id": "sofa",
"country": "US",
"state": "NY",
"city": "new_york",
"created_at": "…",
"updated_at": "…",
"status": {
"online": true,
"last_handshake_at": "2026-10-03T09:04:12",
"endpoint": "203.0.113.9:40123",
"live": true,
"rx_bytes": 1048576,
"tx_bytes": 23068672
},
"metrics": {
"request_count": 318,
"success_count": 316,
"failure_count": 2,
"avg_latency_ms": 212.4,
"bytes_sent": 904211,
"bytes_received": 21874322,
"connections_by_address": 0,
"encrypted_dns_blocked": 1
}
}
]
}
status is where the device stands. With live true it is what an instance
carrying the tunnel published in the last half minute (every ten seconds
while it runs), including the interface’s own byte counters since that
instance’s interface came up; with live false it is the last sighting on
record, which the carrying instance writes to the device’s row whenever a
newer handshake appears, and the byte counters are zero. metrics is what
the pool relayed for the device: the same counters a connector has, cluster-wide
and across restarts, plus the two name-resolution signals (see
WireGuard Devices).
POST /api/v1/projects/{project_id}/wireguard/peers
Content-Type: application/json
{
"name": "Living room TV",
"public_key": null,
"preshared": true,
"enabled": true,
"session_id": "sofa",
"country": "us",
"state": "ny",
"city": "New York"
}
Editor or admin. With public_key null Octoprox generates the key pair and
keeps the private key so the config can be shown again; with a device’s own
public key only the public half is stored. The device gets the next free
address of the subnet. A state or city needs a country. Names are unique per
project, ignoring case.
GET /api/v1/projects/{project_id}/wireguard/peers/{peer_id}
PATCH /api/v1/projects/{project_id}/wireguard/peers/{peer_id}
DELETE /api/v1/projects/{project_id}/wireguard/peers/{peer_id}
POST /api/v1/projects/{project_id}/wireguard/peers/{peer_id}/rotate-keys
PATCH takes any of name, enabled, session_id, country, state,
city; an empty string clears the last four. rotate-keys issues a new key
pair (and preshared key, if the device had one).
GET /api/v1/projects/{project_id}/wireguard/peers/{peer_id}/metrics/history?range=24h
The device’s own history, in the shape of metrics above plus a
timestamp per snapshot, wrapped as {"snapshots": [...]}. Same range
values and bucketing as the project and connector history endpoints
(1h, 6h, 24h raw minutes; 7d hourly; 30d 6-hourly).
GET /api/v1/projects/{project_id}/wireguard/peers/{peer_id}/config
{
"filename": "Living-room-TV.conf",
"config": "[Interface]\nPrivateKey = …\nAddress = 10.66.0.2/32\nDNS = 10.66.0.1\n\n[Peer]\nPublicKey = …\nPresharedKey = …\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = vpn.example.com:51820\nPersistentKeepalive = 25\n",
"complete": true,
"server_configured": true
}
complete is false when the device holds its own private key (the file
carries a placeholder) or the endpoint host is not set yet.
OpenVPN
The second tunnel protocol (see OpenVPN Devices). The same shape as the WireGuard endpoints with the credential swapped: the install has a CA instead of a key pair, a device a certificate instead of keys.
Server settings
GET /api/v1/openvpn/settings
Returns the CA fingerprint and expiry, endpoint, transport, subnet, gateway and the answering instance’s daemon status. Private material (the CA key, the server key, the tls-crypt key) is never returned.
{
"ca_fingerprint": "A1:B2:…",
"ca_expires_at": "2036-10-05T09:00:00",
"endpoint_host": "vpn.example.com",
"endpoint_port": 1194,
"protocol": "udp",
"subnet": "10.67.0.0/16",
"gateway": "10.67.0.1",
"keepalive_interval": 10,
"keepalive_timeout": 60,
"client_mtu": null,
"configured": true,
"updated_at": "2026-10-05T09:00:00",
"status": {
"enabled": true,
"state": "running",
"error": null,
"instance_id": "7d6b...",
"interface": "ovpn0",
"protocol": "udp",
"listen_port": 1194,
"daemon_version": "OpenVPN 2.6.3 x86_64-pc-linux-gnu",
"restarts": 0,
"denied": 1,
"transparent_port": 8081,
"dns_port": 5353,
"fake_ip_range": "198.18.0.0/15",
"active_connections": 3,
"peers_total": 2,
"peers_enabled": 2,
"peers_online": 1,
"connections_by_address": 0,
"encrypted_dns_blocked": 0,
"block_encrypted_dns": true
}
}
PUT /api/v1/openvpn/settings
Content-Type: application/json
{
"endpoint_host": "vpn.example.com",
"endpoint_port": 1194,
"protocol": "udp",
"subnet": "10.67.0.0/16",
"keepalive_interval": 10,
"keepalive_timeout": 60,
"client_mtu": null
}
Admin only. protocol is udp or tcp; keepalive_timeout must exceed
keepalive_interval; the subnet must still contain every device’s address.
A change to the port, transport, subnet or keepalive restarts the daemon on
every instance running it.
POST /api/v1/openvpn/settings/rotate-identity
Admin only. Issues a new CA, server certificate and tls-crypt key, and reissues every device’s certificate under the new CA in one transaction; every device profile becomes invalid.
Devices (peers)
GET /api/v1/projects/{project_id}/openvpn/peers
{
"total": 1,
"server_configured": true,
"ca_fingerprint": "A1:B2:…",
"peers": [
{
"id": "…",
"project_id": "…",
"name": "Living room router",
"serial": "3891…",
"certificate_expires_at": "2036-10-05T09:00:00",
"address": "10.67.0.2",
"enabled": true,
"session_id": null,
"country": "US",
"state": null,
"city": null,
"created_at": "…",
"updated_at": "…",
"status": {
"online": true,
"connected_since": "2026-10-05T09:04:12",
"last_seen_at": "2026-10-05T09:04:12",
"endpoint": "203.0.113.9:40123",
"live": true,
"rx_bytes": 1048576,
"tx_bytes": 23068672
},
"metrics": { "request_count": 318, "…": "as for WireGuard devices" }
}
]
}
status.online means an instance running the daemon currently carries a
session for the device; last_seen_at is that session’s start, or the
persisted start of the last one. metrics is the same TunnelPeerMetrics
shape WireGuard devices carry.
POST /api/v1/projects/{project_id}/openvpn/peers
Content-Type: application/json
{ "name": "Living room router", "session_id": null, "country": "US", "state": null, "city": null, "enabled": true }
Editor. Issues a certificate from the install’s CA (the device id as common name) and takes the next free address. There is no bring-your-own key.
GET /api/v1/projects/{project_id}/openvpn/peers/{peer_id}
PATCH /api/v1/projects/{project_id}/openvpn/peers/{peer_id}
DELETE /api/v1/projects/{project_id}/openvpn/peers/{peer_id}
POST /api/v1/projects/{project_id}/openvpn/peers/{peer_id}/rotate-certificate
GET /api/v1/projects/{project_id}/openvpn/peers/{peer_id}/metrics/history?range=24h
GET /api/v1/projects/{project_id}/openvpn/peers/{peer_id}/config
PATCH takes the same partial document as a WireGuard device (name,
enabled, session_id, country, state, city; an empty string
clears). Disabling, rotating or deleting a device ends any session it has.
rotate-certificate issues a new certificate and key; the serial changes
and the old profile stops working. config returns the .ovpn profile:
{
"filename": "Living-room-router.ovpn",
"config": "client\ndev tun\nproto udp\nremote vpn.example.com 1194\n…<ca>…</ca>\n<cert>…</cert>\n<key>…</key>\n<tls-crypt>…</tls-crypt>\n",
"complete": true,
"server_configured": true
}
Health Check
Public endpoint for load balancer health checks:
GET /health
Response:
{
"status": "healthy"
}