Account proxies
The account proxies surface lets you register your own proxies
against your Driftstack account and route an
agent session’s traffic through one — so a
session browses from your proxy’s IP address. Every agent session goes
out through one of these: creating one needs the proxy_id of a saved
proxy, or a profile bound to one, and Driftstack has no connection of its
own to run it on. A direct
session takes the same proxy_id
(POST /v1/sessions
and POST /v1/profiles/:id/launch), and hosted Driftstack needs one there too.
Four schemes can be registered: socks5, http, openvpn, and
wireguard.
Three of them can currently route a browser session: socks5,
openvpn, and wireguard. http proxies can be stored and managed
here, but cannot carry a session on this deployment — passing an
http proxy to a session create is refused with 400, not silently
ignored.
Proxy secrets are write-only: passwords (SOCKS5/HTTP), the OpenVPN
config blob (which embeds your certs/keys), and the WireGuard private
key and pre-shared key are accepted on create/update, encrypted at rest under your
account’s key, and never returned in any response. Responses expose
has_password (a password is stored) and has_secret (a VPN secret is
stored) instead. Every endpoint here reads X-Driftstack-Account: without
it you act on your own proxies; with it, on the team owner’s. A team admin
reads and writes the owner’s proxies, and a member may only list them, as
narrow rows (see On your account, on every computer).
Leave the header off a script that should only ever touch your own
proxies.
What kind of proxy works
Profiles run on Driftstack’s servers, not on your computer. A proxy that works from your desk can still fail from there, and the desktop app’s Test button — which runs from your own machine — cannot tell the difference. What you need:
- A public address. Not
localhost,127.0.0.1, or a private-network address such as10.x.x.x,172.16.x.x–172.31.x.x, or192.168.x.x. A proxy on your own machine or office network tests fine from the app and is unreachable from Driftstack’s servers. - Username and password authentication. IP-allowlist access does not work: profiles run from Driftstack’s servers, not from your IP, so an allowlist that names your address never matches. Ask your provider for user/pass credentials.
- SOCKS5, OpenVPN, or WireGuard. These are the schemes that can carry a
session.
httpproxies can be saved but not used for a session.
Resource shape
{
"id": "a1b2c3d4-...",
"label": "amsterdam residential",
"scheme": "socks5",
"host": "proxy.example.com",
"port": 1080,
"username": "user",
"has_password": true,
"has_secret": false,
"quic_measured": null,
"quic_measured_at": null,
"quic_probe": null,
"quic_probe_at": null,
"udp_probe": null,
"udp_probe_at": null,
"exit_observed": null,
"exit_superseded_at": null,
"full_check_ok": null,
"full_check_at": null,
"os_fingerprint": null,
"os_fingerprint_at": null,
"created_at": "2026-06-16T09:15:00Z",
"updated_at": "2026-06-16T09:15:00Z",
"revision": 1,
"config": null,
"has_preshared_key": false
}
scheme is one of socks5 | http | openvpn | wireguard. host,
port, and username are not secret. has_password / has_secret
are the only signals about the stored credentials; the plaintext is
never readable back. For VPN schemes, host/port are the display
endpoint (parsed from your .ovpn / wg0.conf).
quic_probe is whether QUIC traffic got through this proxy the last time it
was tested — true, false, or null when no test has checked it yet — and
quic_probe_at is when that test ran (ISO 8601), or null. udp_probe and
udp_probe_at say the same about UDP traffic. false is a result, not a gap: it
means a test of a working proxy checked and the traffic did not get through.
null means never tested, never “no” — it is the only value that tells you the
answer is still missing, so do not treat it as false. Each answer keeps its own
date, and one test can fill in one without the other. Age them by their _at
stamps rather than by the time of your request: they are stored results and can
be any age. Changing the proxy’s address, scheme, or credentials resets all four
to null, because the stored answer described the proxy as it was before. They
are filled in by the full test described under Test a proxy —
the quick test does not check them. For an openvpn or wireguard proxy, a
QUIC test that only timed out is not stored: Driftstack may try once more, and
stores false only when that try times out too. A server that
predates them omits the four fields — read an absent field as null.
quic_measured is a different fact: what a live session actually used through
this proxy — h3 (QUIC worked) or h2-only (it fell back) — or null when no
session has reported one, with quic_measured_at saying when. A test result in
quic_probe never changes it, and the two are dated separately, so they can
disagree for a while after a provider changes what it carries.
exit_observed is the last exit identity seen through the proxy —
{ ip, country, timezone, observed_via, observed_at } — or null when
nothing has observed one yet. observed_via is session (a live session
reported it) or probe (a Driftstack proxy test measured it); country and
timezone are null when they could not be determined. For an OpenVPN or
WireGuard proxy this is the only source of its location and timezone short
of running a test, since only a session or a Driftstack test can see through
the tunnel. null means not observed, never “no location”.
exit_superseded_at is when a later Driftstack test found the tunnel
down after exit_observed was recorded (ISO 8601), or null if that
never happened. The stored exit is kept as the last one seen, at its own
observed_at; clients should ignore an exit_observed dated at or before
exit_superseded_at (the desktop app does). The next successful observation
— a session report or a test that saw an exit through a working tunnel —
clears it; a test that finds the tunnel down sets it. A test that could not
run (not_run) measured nothing and never sets it, and while it is set a
not_run reply carries no exit_observed at all (see
Test a proxy). Driftstack also re-checks saved socks5 and
http proxies in the background from its own servers, and repeated misses there
set it too — so it says the stored exit is in doubt, not that the proxy fails
where your sessions run. full_check_ok says that.
full_check_ok is the result of the last ?check=full test measured where your
sessions run (measured_by: phone, see Test a proxy) — true when
the proxy was usable, false when the test reached a result and it was not —
and full_check_at is when it was measured (ISO 8601). Both are null when no
such test has reached a result since the proxy’s address, scheme, or
credentials last changed; changing any of them resets both. A later full test
replaces them. Nothing else sets them: not a quick check, not a full check that
fell back to Driftstack’s own servers, not a test that could not run
(not_run), not a live session, and not the background re-check. A server that
predates them omits both fields — read an absent field as null.
os_fingerprint is the last passive OS reading Driftstack took of the proxy’s
own TCP stack — the same object Test a proxy returns
(os, confidence, reason, observed_ip, observed_via,
single_host_vantage, web_port_vantage, direct_reading,
website_like_reading) — or null when the proxy has never been
fingerprinted. Only Driftstack can take this reading: it comes from the
connection the proxy’s own kernel opens on our side, which the machine you are
calling from cannot see. null means not measured, never “no OS”.
single_host_vantage and web_port_vantage are the original field names;
direct_reading and website_like_reading are the same two facts under their
customer-facing names, added alongside them — read whichever pair you like,
they always agree. direct_reading (single_host_vantage) is true only when
the address you gave, the address that answered, and the address your traffic
exits from are one machine, so nothing sat between what was read and what a
website would see. website_like_reading (web_port_vantage) is true when
the reading was taken the way a real website connection is — a literal
address on the standard secure-web port, not a name that could route to
shared infrastructure. Read a missing or false value on either pair as “this
reading does not describe that path,” never as an assurance that it does.
os_fingerprint_at is when that reading was taken (ISO 8601), or null. Age the
reading by this stamp rather than by the time of your request: it is a stored
measurement, it can be any age, and a reading you cannot date should be treated
as stale rather than current. The desktop app hides one older than 30 minutes.
List
GET /v1/account/me/proxies → { "data": [ ...proxy ], "cap": …, "count": … }
(cap is the workspace’s proxy cap, null when it has none, and count how
many are saved). Send
a row’s revision back as expected_revision on an update; see Revisions
below.
Required scope: account_owner.
Create
POST /v1/account/me/proxies
{
"label": "amsterdam residential",
"scheme": "socks5",
"host": "proxy.example.com",
"port": 1080,
"username": "user",
"password": "••••••"
}
scheme defaults to socks5. username/password are optional (some
SOCKS5 servers accept unauthenticated or username-only access). Returns
the created proxy metadata (no password) with 201.
Limits: label 1-80 characters, host 1-255, username up to 255,
password up to 1,024. On an update, a new password must be at least 1
character. Bodies are strict: an unknown field is refused with 400.
Host safety: the host must be a public address. Private, loopback,
link-local, and cloud-metadata addresses (e.g. 127.0.0.1,
10.0.0.0/8, 169.254.169.254) are rejected — a proxy that
pointed at an internal address could be used to reach networks you
shouldn’t. For a socks5 or http proxy the refusal is a 422 whose
code is proxy_host_not_public, whose field is proxy.host, and whose
detail is: This proxy address points inside our network. Use your proxy provider's public address. The value you sent is never repeated. It covers:
- every spelling of such an address: decimal (
2130706433), octal (0177.0.0.1), hex (0x7f.1), short (127.1), full-width digits, bracketed IPv6 ([::1]), IPv4 inside IPv6 (::ffff:10.0.0.1,64:ff9b::10.0.0.1), a trailing dot, upper case; - any IPv6 address outside global unicast (
2000::/3), such as site-localfec0::1; - names that only exist on a private network:
localhostand*.localhost(in any spelling),*.local,*.internal,*.localdomain,*.home.arpa,*.lan,*.home,*.corp,*.intranet,*.private, and a name with no dot; - names under DNS services that answer with whatever address is written in
the name, or always with a loopback address (
*.nip.io,*.sslip.io,*.xip.io,*.traefik.me,*.1u.ms,localho.st,vcap.me,*.localhost.direct,lvh.me,localtest.me); - a name whose DNS answers (A or AAAA) include such an address, judged by the same rules as the address typed directly. If the name cannot be looked up when you save, the proxy is still saved.
The address is checked when you save the proxy and again before each session starts through it. A name is looked up from Driftstack’s servers at those times, and its answers are reused for up to a minute (a failed lookup for up to ten seconds); the address the session connects to is not checked again at connect time yet.
A host that is not a host name or an IP address at all (white space, a
scheme such as socks5://, a user@ prefix, a port, an IPv6 zone id such as
%en0, a numeric spelling that is not a dotted quad) is a 422 with code
proxy_host_invalid. Both refusals use the validation-failed problem type
(they used to be 400 bad-request), so the SDKs raise ValidationError
rather than BadRequestError. A proxy saved before these checks that holds such a
host is kept, but starting a session through it is refused with the same
sentence (a 422 problem of type proxy-validation-failed, with field
proxy.host), and its Test answers the same sentence. Edit its address to
use it again. The display address of an openvpn or wireguard proxy is
still refused with a 400 when it is a private address.
How many you can save: each plan caps the number of saved proxies on
the account. Crossing it on POST /v1/account/me/proxies returns 400
with Proxy limit reached (<cap>). Delete an existing proxy to add another. — note this is a 400, not the 429 Tier limit the profile
cap uses. The caps are below (the public @driftstack/api-types package
also exports them as PROXIES_PER_TIER). Every paid plan gets as many
saved proxies as saved profiles, so each profile can have its own; the
free trial keeps 5 saved proxies
and 1 saved profile:
| Plan | Tier id | Saved proxies |
|---|---|---|
| Trial | trial |
5 |
| Starter | starter_v3 |
25 |
| Pro | pro_v3 |
100 |
| Team | team_v3 |
300 |
| Scale | scale_v3 |
1,000 |
| Enterprise | enterprise |
Custom |
The enterprise allowance is set by contract rather than a number this page can print. On the free trial, sessions use SOCKS5 proxies. OpenVPN and WireGuard need a paid plan.
An account holding more saved proxies than its plan allows (after a
downgrade, or at the end of a trial) loses none of them, but only the most
recently used, up to the plan’s number, can launch a session — on every launch
path: POST /v1/agent-sessions, POST /v1/sessions with a proxy_id, and
POST /v1/profiles/:id/launch. Any other is refused with 429 tier-limit
(code: "over_plan_limit", resource: "proxy").
The earlier plans, no longer offered, keep their caps for the accounts that hold them:
| Plan | Tier id | Saved proxies |
|---|---|---|
| Personal | solo_manual |
10 |
| Team | team_manual |
50 |
| Agency | agency_manual |
200 |
| API Starter | api_starter |
25 |
| API Builder | api_builder |
100 |
| API Scale | api_scale |
500 |
VPN proxies (OpenVPN / WireGuard)
For a VPN scheme, the secret config rides a nested block. host/port
are the display endpoint (most clients fill them from the parsed config).
OpenVPN — paste the full .ovpn as config_blob (it must be a client
configuration: it needs a client line and a remote line with the server
address; up to 256 KiB). Some providers ship profiles without the client
line — paste one into the desktop app and it offers to add that line for you,
leaving the rest of the file unchanged. username/password are the VPN
account’s credentials. They are required — both — when the file has a bare
auth-user-pass line (OpenVPN then asks for them); a save without both is
refused with 400: This VPN file asks for a username and password (auth-user-pass); enter both. A file that does not ask for them needs neither.
The server names in remote, http-proxy and socks-proxy lines must be
public: a name that resolves to a private, loopback or link-local address is
refused like the address itself.
{
"label": "frankfurt ovpn",
"scheme": "openvpn",
"host": "vpn.example.com",
"port": 1194,
"openvpn": {
"config_blob": "client\nremote vpn.example.com 1194\n...",
"username": "user",
"password": "••••••"
}
}
WireGuard — the private_key and peer_public_key are WireGuard
keys (44 characters, base64); endpoint is host:port, with an IPv6 address
in brackets ([2001:db8::1]:51820 — an unbracketed one is refused, because
its port cannot be told from the address), and a host name must resolve to a
public address; address is the interface address (e.g. 10.7.0.2/32) and is
required, and must include an IPv4 address (an IPv6-only address is refused,
because the tunnel runs over IPv4); allowed_ips defaults to 0.0.0.0/0 and must include the whole
IPv4 internet (0.0.0.0/0, or 0.0.0.0/1, 128.0.0.0/1; extra entries such as
::/0 are fine), because Driftstack sends all of the session’s traffic through
the VPN — a split tunnel is refused; dns is optional, and a private resolver
inside the tunnel (such as 10.64.0.1) is fine (see
WireGuard DNS below); preshared_key (also a
44-character base64 key) is optional and only needed when the peer requires a
pre-shared key; mtu (1280–1500) and persistent_keepalive (seconds,
0–65535) are optional, stored and sent to the session’s machine, which runs
the tunnel at that mtu. Any other key in the block is refused with a 400 that
names it:
{
"label": "frankfurt wg",
"scheme": "wireguard",
"host": "vpn.example.com",
"port": 51820,
"wireguard": {
"private_key": "<44-char base64>",
"peer_public_key": "<44-char base64>",
"endpoint": "vpn.example.com:51820",
"address": "10.7.0.2/32",
"allowed_ips": "0.0.0.0/0",
"dns": "10.64.0.1"
}
}
WireGuard DNS
Your VPN provider’s own DNS is kept. A session looks names up at the dns
addresses of the config, through the VPN tunnel. What a save does with each
address (create, update and import follow the same rule):
-
Kept: any address
allowed_ipssend through the tunnel, whatever its range: public, private (10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7), documentation or other reserved ranges (192.0.2.1,203.0.113.53,2001:db8::53). Every saved WireGuard proxy has a full-tunnelallowed_ips, so every IPv4 address is covered; an IPv6 address is covered whenallowed_ipsinclude it (for example::/0), though the tunnel itself carries IPv4 only today. An addressallowed_ipsdo not cover is kept when it is public or a private tunnel address (10.0.0.0/8,100.64.0.0/10,172.16.0.0/12,192.168.0.0/16,fc00::/7, which includes the IPv6 cloud-metadata addressfd00:ec2::254). -
Removed, with a note: an address that never leaves the session’s machine through a tunnel, wherever
allowed_ipspoint — loopback (127.0.0.0/8,::1), link-local or cloud-metadata (169.254.x.x,fe80::), unspecified (0.0.0.0/8,::), multicast (224.0.0.0/4,ff00::/8), class E (240.0.0.0/4, which includes the broadcast address255.255.255.255), an IPv4 address written as::ffff:a.b.c.dor::a.b.c.d(write10.64.0.1, not::ffff:10.64.0.1); an addressallowed_ipsdo not cover that is in a reserved or special-purpose range; or a numeric spelling of an address (such as0177.0.0.1). Only plain IP addresses are kept: an entry written any other way, such as a name (dead.cafe) or an address with a port (198.18.0.1:53), is left out too (not_plain_ip), because a session would look a name up outside the tunnel. The address is left out of the saved config while at least one other address is kept, and the answer says so inchanges:{ "id": "a1b2c3d4-...", "scheme": "wireguard", "config": { "dns": "10.64.0.1", "...": "..." }, "changes": [ { "field": "wireguard.dns", "action": "removed", "count": 1, "reason_code": "loopback", "note": "1 DNS address was removed from this WireGuard config: a loopback address points at the device itself, not at a DNS server. The remaining DNS server is kept." } ] }One entry per reason.
reason_codeisloopback,link_local,multicast,outside_allowed_ips,not_plain_iporreserved; the removed address is never repeated.changesis absent when the config was stored as sent, and the list never carries it. A create retried with the sameIdempotency-Keyanswers the changes of the proxy’s last save. -
Refused: when no address in
dnscan be used, the save is a400whosedetailgives the reason and asks for the DNS address your VPN provider gives for the config; nothing is saved. A config with nodnsat all is not judged by this rule;vpn_dnsbelow says what a session through it does. That400also carriesfield: "wireguard.dns"andreason_codes(the same codes as above). -
Which value was refused: on
POST /v1/account/me/proxiesandPUT /v1/account/me/proxies/{id}, a400about one WireGuard value carriesfield, naming it:wireguard.private_key,wireguard.peer_public_key,wireguard.preshared_key,wireguard.endpoint,wireguard.allowed_ips,wireguard.address,wireguard.dns,wireguard.mtuorwireguard.persistent_keepalive. A refusal that is not about one value (an unknown key, a missing block) has nofield. Thedetailis unchanged.
The config_blob / private_key / preshared_key are write-only — the response returns
has_secret: true, never the secret. On an installation where VPN
proxies are not available, create returns 503 with the message
VPN proxies are not available on this installation.
Required scope: account_owner — a broad write key is not sufficient.
Update
PUT /v1/account/me/proxies/{id}
Every field is optional. For the password on a SOCKS5 or HTTP proxy:
- omit
password→ keep the existing one (unless the update changes the address — see below) "password": null→ clear it"password": "..."→ set/replace it
Changing the address needs the password again. If the update changes
host, port or scheme (socks5 ↔ http) of a proxy that stores a
password, send password in the same body — the password, or null to
clear it. Without it the update is rejected with 400 and nothing
changes: To change this proxy’s address, send its password again (or null to remove it). A saved password is never sent to a new server on its own.
A saved password is never readable back, so this is what keeps the ability
to edit a proxy from becoming the ability to read its password by
pointing it at another server. The refusal is recorded in your account’s
audit log as proxy.move_refused, with the address that was tried and
the API key that sent it, and it appears in the notifications in the
Driftstack dashboard. A host that is not a
public internet address is refused first, with its own 422 (see Host
safety above). Changing
only label or username keeps the stored password as before.
VPN proxies are different. On a saved openvpn or wireguard proxy,
sending password at all — a new value or null — without also
sending scheme and the matching config block is rejected with 400:
To change a VPN password, submit the full VPN configuration again. The
credential is stored together with the config, so there is no way to
change one without the other. To change a VPN password, resubmit the
full VPN body as you would on create. The same holds for its address: a
VPN’s server lives in its configuration (the remote line, or the
WireGuard Endpoint), so changing host or port without scheme and
the config block is rejected with 400: To change a VPN’s server address, submit the full VPN configuration again. A resubmitted block
replaces the stored key and password; nothing from the previous
configuration carries over to the new server. This refusal is recorded as
proxy.move_refused too.
404 if the id isn’t one of your proxies.
409 — This proxy changed since you last loaded it. Refresh and try again. Something else — a concurrent API call, such as a second script
or integration using your key, or the Driftstack desktop app, which
re-sends each saved proxy before every launch — changed this proxy’s
scheme between your read and your write, so the write was refused
instead of being applied to a proxy that is no longer the one you edited.
The same refusal covers the password: an update that moves a proxy
storing no password is refused if a password was saved in between; one
that sets a new password without host/port is refused if the proxy
was moved in between; and one that re-sends the address you read,
without password, is refused if the proxy was moved and given a new
password in between — a password is never written to an address you did
not read, and a saved one is never paired with an address it was not
saved for. Every such 409 carries code: "stale_revision" and
current_revision. Send expected_revision with the update to have any other
change since your read refused the same way (see Revisions below). Re-read the
proxy and reissue the update.
A 409 here means the row still exists; a 404 means it is gone.
Required scope: account_owner — a broad write key is not sufficient.
Delete
DELETE /v1/account/me/proxies/{id} → 204. A second delete of the same id,
or an unknown id, returns 404; on a retry, read that 404 as already
deleted.
Required scope: account_owner — a broad write key is not sufficient.
Test a proxy
POST /v1/account/me/proxies/{id}/test
Answers one question: would a session launched through this proxy work right
now? For a socks5 proxy that is the same check Driftstack runs before
launching a session — it connects, authenticates, and makes a real request
through the proxy — so a green test and a successful launch mean the same
thing. A proxy that fails the test is a 200 result, not an error. The call
itself can be refused: 400 for an unknown check or vantage, 404 for a
proxy that is not yours, 403 for a key without account_owner, a team
member, a VPN proxy your plan does not include or check=full where it is not
available, and 429 past the daily check allowance (retry_after_seconds;
see the notes on daily checks below):
{ "ok": true, "latency_ms": 142 }
{
"ok": false,
"reason": "The proxy connected but could not reach the internet. Check with your proxy provider."
}
reason is a fixed sentence written for a person to read and act on, drawn from
the same four cases a refused launch reports (see
Why a launch is refused), or a fifth that says the
check could not reach a result at all — a launch is not refused for that one (see
When the check cannot tell). It is not an enum — branch
on the reason field of a 422 instead. Raw error text from the network or
from the proxy itself never appears in the response.
A proxy that authenticates but cannot route is the case worth knowing about: it looks healthy to anything that only opens the port, and it fails every launch. This test reports it.
An ok: true result carries os_fingerprint when Driftstack read the proxy’s
own TCP stack during the test, and os_fingerprint_unavailable when it could
not:
| Code | What it means | Worth retrying? |
|---|---|---|
not_available_for_vpn |
An openvpn or wireguard proxy has no single address of its own to read a stack from. |
No — permanent for this proxy. |
not_captured |
The reading was attempted and produced nothing this time. | Yes. |
not_offered_here |
This deployment does not take this reading at all. | No — permanent for this deployment. |
When this test read nothing but the proxy has a STORED reading, that
one is returned instead, with os_fingerprint_at saying when it was taken and
the os_fingerprint_unavailable cause still beside it: the cause is about this
test, the stamp is about the reading. A reading with no os_fingerprint_at
was measured by the request you just made.
An ok: true result also carries quic_probe, quic_probe_at, udp_probe and
udp_probe_at exactly as the proxy object lists them after this test — so a
full test that checked QUIC or UDP returns the answer it just stored, dated to
the test, and any other returns the stored answer at its own, older date (or
null when the proxy has never been tested for it). A proxy that fails the test
stores nothing: there was no working proxy to check them over. If the proxy’s
address, scheme, or credentials were submitted again while the test was running
— even with the same values — or the proxy was deleted, the test’s answer may not
be stored. Either way the four fields match the proxy object as it then stands:
null after a change that resets them, and the earlier answer otherwise.
?check=quick (the default) is measured by Driftstack itself, right now. Add
?check=full to run a fuller check, dispatched through the machine that will
actually run your profile — the same path a real session takes, and worth
running before you rely on a proxy for a real session. The not_run values
below cover the cases where a full check could not run. So check takes one
of two values, quick or full.
An openvpn or wireguard proxy is always given the full check, even when
you ask for quick (or ask for nothing): a VPN can only be checked by bringing
its tunnel up, and most VPN servers answer only over UDP, which a quick
connection test cannot reach. The result then carries check_ran: "full" to
say a different check ran from the one you asked for. When no machine is free
to bring the tunnel up, the result is a not_run (check_unavailable), never
a failed proxy.
A full-check result also carries measured_by, saying where the
measurement actually came from: phone when one of the Macs that run sessions
took it — the machine ?check=full asks for; the value keeps its original
name — or driftstack when Driftstack itself did, the honest fallback when a
?check=full request for a SOCKS5 or HTTP proxy could not reach that machine
in time. A quick check of a SOCKS5 or HTTP proxy is always driftstack and
carries no field to say so.
The original measured_from field (fleet / control_plane) is still sent
beside it, unchanged, for an integration that already reads it; measured_by
is the name documented from here on.
An ok: false result that also carries not_run is not a result about the
proxy — nothing was measured. Branch on not_run, never on the reason
prose, before treating the result as a failed proxy:
{
"ok": false,
"not_run": "live_session",
"reason": "This VPN is being used by a running session, so the exit IP shown is from that session. End the session to check the VPN.",
"measured_by": "driftstack",
"exit_observed": {
"ip": "203.0.113.9",
"country": "NL",
"timezone": "Europe/Amsterdam",
"region": null,
"city": null,
"observed_at": "2026-09-09T18:21:07.000Z"
}
}
live_session— a?check=fulltest of anopenvpn/wireguardproxy was skipped because the VPN is in use by a session (running, paused or still starting). A second connection on a one-connection VPN account would drop that session, so nothing was tested.measured_byisdriftstack(nothing measured the tunnel) andexit_observed, when present, is the exit that session saw — the sameexit_observedthe proxy object lists — so a client can still show where the tunnel exits. End the session if you started it, or wait for it to end, then test the tunnel.config_unresolvable— the stored configuration could not be turned into anything Driftstack could run, or the machine that would have run it could not read it (the same fact theconfig_unresolvablereason in Why a launch is refused names — thereasonfield says to re-add it; a retry will not help).check_unavailable— a test of anopenvpn/wireguardproxy that was not run for a reason on our side: no machine was free to bring the tunnel up, the dispatch timed out, or another check of the same proxy was already running (try again in a minute), the tunnel came up but the check of where its traffic exits did not finish (also worth a retry), a timeout or reset left no proof either way that the tunnel carries traffic, so its exit address could not be checked (the connection itself may be fine; try the test again), or full checks are not set up on this deployment (a retry will not change that). Thereasonfield says which. In every case there is no fallback to a plain reachability check of the tunnel endpoint.measured_byisdriftstackandexit_observed, when present, is the stored exit a session observed.
The exit_observed beside a not_run is the proxy’s stored observation,
not something this test measured, so it carries observed_at — when it was
observed (null for an observation recorded before the field existed). Date
it by that, never by the reply: a later test that found the tunnel down can
postdate it. A stored exit a later test has since contradicted (the proxy’s
exit_superseded_at is set) is not attached — and the live_session
reason then does not say the exit is shown — because the last check found the
tunnel down and produced no exit to show.
Absent not_run, an ok: false result is a measurement. For a VPN proxy the
reason names what failed: the server did not answer in time, it refused the
username or password, it did not accept the configuration’s keys or
certificates, or the tunnel came up but traffic did not leave through it. Two
of those are the configuration’s to fix, and checking again will not change
them: traffic left outside the tunnel because the configuration sends only part
of it through the VPN (a split tunnel — set WireGuard’s AllowedIPs to
0.0.0.0/0, or remove OpenVPN’s route-nopull and partial routes), or the
tunnel came up but no traffic came back through it (the VPN server is usually
not forwarding traffic to the internet). A session through such a proxy stops
the same way, with an error_event whose retryable is false.
A full check of an openvpn or wireguard proxy can also carry vpn_dns,
saying where the tunnel’s DNS server comes from: pushed (the OpenVPN server
pushes dhcp-option DNS), configured (the WireGuard configuration’s own
DNS = line) or none. Driftstack never uses a public resolver in place of
yours, so none is a failed check, whatever else the result says, and the
reason says so and what to do: add a DNS = line naming a resolver inside
your VPN (WireGuard), or have the server push dhcp-option DNS (OpenVPN). It is the
configuration’s to fix: checking again will not change it, and a session
through that proxy is not started, with the reason vpn_no_dns and an
error_event whose retryable is false. vpn_dns is absent when the
check did not report it; read that as “not reported”, not as none.
A full check can also carry exit_ip_check: ok when the exit address was
checked, unavailable when it was not. An ok: true result with
exit_ip_check: "unavailable" and exit_ip: null is a pass: the proxy
answered and carried the check’s traffic, but no exit address came back this
time, so none is shown or stored and the proxy’s stored exit is left as it was.
Absent when not reported.
exit_ip_check_detail says why the exit address was not checked, when there is
more to say, and exit_ip_check_warning carries a warning in plain words to
show beside the result:
refused_check_host— your proxy works, but it blocks the address we use to check its exit address, so we couldn’t confirm the exit address. The result is still a pass (ok: true).connect_refused_all— your proxy refused both of our check addresses, so we could not prove it routes (ok: false). When it accepted the sign-in, the warning is also thereason: sessions through it still start, but we can’t confirm the exit address. That result is not stored as a failed check, and the proxy’s stored exit is left as it was.not_run— the check did not run;not_runandreasonsay why. No warning.
Both are absent when there is nothing more to say or the check did not report them.
A VPN proxy on a plan that no longer includes VPN proxies gets a 403 on either check — the same refusal you get when launching a session through it.
A VPN proxy never falls back to a plain reachability check: a quick check of its address cannot tell a working UDP VPN server from a dead one.
Required scope: account_owner — a broad write key is not sufficient.
Change the IP from your provider’s link
Many mobile and rotating proxy providers give each proxy a change-IP link:
open it and the provider gives the proxy a new exit address. You can save that
link on a SOCKS5 or HTTP proxy and have Driftstack open it for you. This is
being switched on account by account; until it is on for your account, saving a
link answers 400 with code: "change_ip_not_available" and the change route
answers 404.
The link is a secret. It usually carries your provider key in its path or
its query. Driftstack stores it encrypted under your account key and never
returns it, not even masked: a proxy shows has_change_ip_url, the link’s host
(change_ip_url_host, no path, query or port) and change_ip_url_plain_http.
Save or remove the link
PUT /v1/account/me/proxies/{id}/change-ip-url
{ "change_ip_url": "https://provider.example/changeip/…", "confirm_plain_http": false }
DELETE /v1/account/me/proxies/{id}/change-ip-url
The link must start with https://. A plain http:// link (common on modem
kits) is saved only with "confirm_plain_http": true, because its key travels
unencrypted over the internet; the proxy then shows
change_ip_url_plain_http: true. Refused with 422 and a fixed code, and the
answer never repeats your link: a private or local address
(change_ip_url_private_target), a Driftstack address
(change_ip_url_own_infrastructure), a user name or password before the host
(change_ip_url_credentials), a port below 1024 other than 443 (80 only for a
confirmed http:// link) (change_ip_url_port), and a link over 2048
characters (change_ip_url_too_long). Removing a link always works.
Change the IP
POST /v1/account/me/proxies/{id}/change-ip
Idempotency-Key: <optional>
{ "running_sessions": "refuse" }
Driftstack opens your link once (GET, no redirects followed, 10 seconds,
from Driftstack’s own server address: if your provider only accepts listed
addresses, add ours, but keep a key in the link: every Driftstack customer
calls from that same address, so a provider that checks only the address
would accept the call from anyone) and answers 202 with a change_id. Then it re-reads
the proxy’s exit with the quick check: first after 10 seconds, then every 5
seconds for up to a minute. While it does, the proxy’s stored exit is marked
as no longer current (exit_superseded_at), so every computer shows
“measuring” instead of the old place, and no session is launched with the old
exit.
Poll GET /v1/account/me/proxies/{id}/change-ip/{change_id}. outcome is
pending, then one of: changed (exit.old → exit.new, and exit.changed
lists which of ip, country and timezone moved; exit.new also carries the city), exit_unchanged,
exit_read (no earlier exit to compare), exit_unknown (no reading within the
minute: the modem may still be reconnecting; the next launch checks the proxy
as usual), proxy_unusable, provider_unreachable (provider.result
unreachable_dns, unreachable_connect, unreachable_tls or
unreachable_blocked; nothing reached the provider), or interrupted.
provider carries the result (ok, refused, redirected, timeout), the
HTTP status and at most 300 characters of the provider’s answer, with every
form of your link replaced by [hidden].
Running sessions cannot follow a new exit: a session keeps the clock,
timezone and location it started with. With sessions running on the proxy the
default ("refuse") is 409 proxy_in_use with the count (and how many were
started with stop_on_exit_ip_change, which end themselves when their exit
moves). Send "stop" to stop them first (not across workspaces: a session of
another workspace on the proxy answers 409 proxy_in_use_other_workspace, and
then only "keep" is offered), or "keep" to keep them running:
the change then carries sessions.notice, “location unchanged” when the
country and timezone stayed, otherwise “… Restart it to match.” A session
launched while the change was running is counted too.
When Driftstack cannot read which sessions run on the proxy, nothing is called
(503 change_ip_sessions_unreadable).
Limits: one change per proxy at a time (409 change_ip_in_flight, with its
change_id), one per proxy per minute (429 with retry_after_seconds), and
a daily number per account (free 5, trial 20, paid 500; 429). An
Idempotency-Key retry answers the same change and never opens the link
twice. Team admins only, like a test. Each save, removal and change is in the
audit log as proxy.updated, with the link’s host only.
Required scope: account_owner.
Find a proxy’s id
The id is what an agent session’s proxy_id takes.
- From the API.
GET /v1/account/me/proxieslists your saved proxies with their ids, andPOST /v1/account/me/proxiesreturns the id of the one it saves. Both need a key with theaccount_ownerscope (Full access on the dashboard’s API keys page); aread+writekey gets403. A job that only creates sessions does not need that scope: look the id up once with an owner key and give the job the id. - From the desktop app. A proxy you add under Proxies is saved to your
account the first time the app tests it or launches a session through it,
and from then on
GET /v1/account/me/proxieslists it too. The app itself does not display the id.
Team workspaces. A proxy belongs to the account that saved it. When a team
admin creates a session in the owner’s workspace (X-Driftstack-Account), the
proxy_id may name a proxy the owner saved or one saved on the admin’s own
account — the desktop app saves every proxy to the signed-in person’s account.
The session belongs to the owner; the proxy stays on the admin’s account, and
the owner cannot list it or start a session through it. A proxy on any other
account is a 404.
Route a session through a proxy
Pass proxy_id when you
create an agent session:
{ "profile_id": "prof_...", "proxy_id": "a1b2c3d4-..." }
The session’s traffic is routed through that proxy. The proxy_id must
be one of your account’s proxies (an unknown or not-owned id returns
404; for a team admin, see Team workspaces above), and its scheme must be one that can route a session — an
http proxy returns 400 with a message naming the supported
schemes. Without it, a create whose profile_id is bound to a proxy uses
that proxy; a detached profile is refused with 409
(code: "proxy_detached"); with an unset profile, or no profile, the create
is refused with 422 proxy-required and nothing is created. The session’s own
proxy_id then says which proxy its traffic goes out through, from the
create’s response onwards.
The Driftstack desktop app manages this for you: add a proxy under Proxies, set it as a profile’s default, and launching the profile routes that session through it automatically.
On your account, on every computer (2026-09-28)
Saved proxies live on the account, not on the computer that saved them. Sign in anywhere and the same list is there; a profile bound to a proxy launches through it from every computer. What changed on this surface:
- The workspace. Every proxy route follows
X-Driftstack-Accountlike the profile routes: without it you act on your own proxies; with it, on the team owner’s. A team admin reads and writes the owner’s proxies; a team member may only list them, and gets a narrow row —{ member_view: true, id, label, scheme, country, status, revision, created_at, updated_at }(statusisunknown,okorfailing) — never the address, the username or whether a password is stored. The cap and the VPN entitlement are always the owner’s plan, whoever makes the request. The list carriescapandcountfor the workspace; your account summary carries your personalproxy_capandproxy_count. - Revisions. Every row has a
revisionthat moves on every PUT you make (a background reading never moves it). Send it back asexpected_revisionand a proxy another computer changed first is refused with409(code: "stale_revision",current_revision) and nothing changes. Omit it to write unconditionally, as before. password: ""is refused with400. Clearing a password needs an explicitnull; leaving the field out keeps the stored one.Idempotency-Keyon create. A retry with the same key returns the proxy already created under it — the same id, one row — so a create lost to a timeout is safe to repeat. The endpoints that honour the header are listed under Idempotency keys.- Deleting a proxy detaches its profiles. Every profile bound to it
reads
proxy_choice: "detached"and refuses to launch until you choose a proxy. A profile is never re-pointed at another proxy, and never at the first in the list. - VPN rows can be shown without their keys. The list carries
config— the WireGuard peer public key, endpoint, allowed IPs, address, DNS,mtuandpersistent_keepalive, or the OpenVPN username — andhas_preshared_key. The private key, the pre-shared key and the.ovpnblob stay write-only. - The Trial gets a daily allowance (2026-10-07). While the Trial runs,
both checks are available, 10 a day in all (
429past it, withretry_after_seconds). - An ended Trial gets the quick check only.
POST …/proxies/{id}/testwithcheck=quick(the default) runs on Driftstack’s API server and is available 5 times a day once the Trial has ended (429past the budget,retry_after_secondssays when);check=fullruns where your sessions run and needs a paid plan (403). VPN proxies need a paid plan too, so a check of one on an ended Trial is a403and spends none of the budget.
Moving a computer’s proxies onto the account (2026-09-28)
POST /v1/account/me/proxies/import is the desktop app’s one-time move. It
takes a batch (at most 512 KB and 200 items) of { local_ref, server_link?, proxy }, where local_ref is the proxy’s id on that computer and proxy is
the same body as create. local_ref is stored as the row’s idempotency key,
so a batch sent twice creates nothing twice, and the whole batch runs under
the account’s proxy-create lock in one transaction: two computers moving at
the same moment take turns, and the second links to the first one’s rows.
The answer is a verdict per item, with a reason:
link— the account already holds it:already_imported(thislocal_refwas moved before),linked_row(server_linknames a row with the same address and secret) orsame_proxy(a row with the same address and secret).create— a new row, under the owner’s cap. The same address with a different password is a different proxy, never mapped onto the other.ask— nothing was written and you choose:password_differs(theserver_linkrow holds another password; neither is overwritten) orsame_address_over_limit(a different-password twin at the cap).refuse—over_limit,unsafe_host(a private address),vpn_not_on_planorinvalid(thedetailsays which line).
A link or create item also carries changes when the save removed DNS
addresses from its WireGuard config, the same entries create answers (see
WireGuard DNS); a dry run reports what it would remove. An
ask wrote nothing and carries none. A WireGuard item with no usable DNS
address is refuse / invalid, with the reason in detail.
On your own account, exact duplicates the account already held — the same
address and the same secret — are merged first: the oldest row survives,
its profiles follow it, and merged maps each merged id to its survivor. In
a team workspace (X-Driftstack-Account, admin role) no secret is ever
compared with the owner’s rows: every item is created new, and the owner’s
audit log records each as proxy.shared_to_team.
dry_run: true answers the same verdicts, counts and merge map and writes
nothing. backfill_profiles lists the profiles that have no proxy choice on
that computer at all: each still unset on the account is bound to the proxy
its last session actually ran through, looked up through the merge map, and
never overwrites a choice. detach_profiles lists the profiles whose proxy
was deleted on that computer: each still unset becomes detached — first,
so no backfill and no “use this proxy for every profile without one” fills it
in later — and detached lists them (in a dry run, those that would be). Every batch, dry runs included, is recorded as
proxy.imported by its counts — never a host, a label or a secret.
Why a launch is refused
Before a session is created, Driftstack checks the proxy can actually carry it.
If it cannot, the create returns 422
errors.driftstack.dev/proxy-validation-failed and no session is created and
nothing is billed — the refusal happens before any browser starts.
The problem body carries a reason alongside the human detail:
{
"type": "https://errors.driftstack.dev/proxy-validation-failed",
"title": "Proxy validation failed",
"status": 422,
"detail": "The proxy connected but could not reach the internet. Check with your proxy provider.",
"reason": "egress_blocked",
"resource": "proxy"
}
reason is a closed set. Branch on it — detail is prose and may be reworded.
| Reason | What it means | What fixes it |
|---|---|---|
unreachable |
host:port refused the connection, the host name does not exist or resolves to a private or local address, there is no route to the host, or what answered is not a proxy Driftstack can use. |
Check the host, the port, and that the proxy is online. |
auth_failed |
The proxy answered and rejected the username or password. | Re-enter the credentials. |
timeout |
The proxy accepted the connection but did not finish in time. | It is overloaded or half-down; retry, then change proxy. |
egress_blocked |
The proxy authenticated, then refused or failed to reach the destination. | Ask the provider — this is usually plan, quota, or an ACL. |
config_unresolvable |
The stored configuration could not be used, so nothing was dialled. The other four reasons are results from a real round-trip through the proxy; this one is not a measurement of the proxy at all. | Open the proxy and fix or re-paste its configuration. Checking the host and port will not help — they were never tried. |
egress_blocked is the one that surprises people. The credentials are correct
and the proxy is up, so anything that only checks reachability calls it healthy;
the provider is simply declining to route. A provider that has suspended an
account, exhausted its bandwidth quota, or restricted destinations by ruleset
refuses every request this way. Nothing on the Driftstack side will change it.
To distinguish “this proxy is broken” from “this provider is refusing everything”, test a second proxy from a different provider: if that one launches, the fault is the first provider’s.
Retrying a 422 with the same proxy will fail the same way — it is a statement
about the proxy, not a transient error, so the SDKs do not retry it.
When the check cannot tell
The check runs from Driftstack’s servers, and your sessions do not: they connect
from the computer that runs them. So when the check reaches no result at all —
nothing answered our servers’ connection attempt within the time limit, or the
check failed on our side — the launch is not refused. The session is created
and connects through the proxy as usual, and no 422 is returned. A proxy that
only accepts connections from certain addresses can look like this from our
servers and still work for your sessions. If the proxy really is down, that shows
up in the session rather than as a refused launch.
The proxy test reports this case in its reason instead of
calling the proxy broken.