Drıftstack docs
Docs

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 as 10.x.x.x, 172.16.x.x–172.31.x.x, or 192.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. http proxies 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-local fec0::1;
  • names that only exist on a private network: localhost and *.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_ips send 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-tunnel allowed_ips, so every IPv4 address is covered; an IPv6 address is covered when allowed_ips include it (for example ::/0), though the tunnel itself carries IPv4 only today. An address allowed_ips do 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 address fd00:ec2::254).

  • Removed, with a note: an address that never leaves the session’s machine through a tunnel, wherever allowed_ips point — 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 address 255.255.255.255), an IPv4 address written as ::ffff:a.b.c.d or ::a.b.c.d (write 10.64.0.1, not ::ffff:10.64.0.1); an address allowed_ips do not cover that is in a reserved or special-purpose range; or a numeric spelling of an address (such as 0177.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 in changes:

    {
      "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_code is loopback, link_local, multicast, outside_allowed_ips, not_plain_ip or reserved; the removed address is never repeated. changes is absent when the config was stored as sent, and the list never carries it. A create retried with the same Idempotency-Key answers the changes of the proxy’s last save.

  • Refused: when no address in dns can be used, the save is a 400 whose detail gives the reason and asks for the DNS address your VPN provider gives for the config; nothing is saved. A config with no dns at all is not judged by this rule; vpn_dns below says what a session through it does. That 400 also carries field: "wireguard.dns" and reason_codes (the same codes as above).

  • Which value was refused: on POST /v1/account/me/proxies and PUT /v1/account/me/proxies/{id}, a 400 about one WireGuard value carries field, naming it: wireguard.private_key, wireguard.peer_public_key, wireguard.preshared_key, wireguard.endpoint, wireguard.allowed_ips, wireguard.address, wireguard.dns, wireguard.mtu or wireguard.persistent_keepalive. A refusal that is not about one value (an unknown key, a missing block) has no field. The detail is 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=full test of an openvpn / wireguard proxy 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_by is driftstack (nothing measured the tunnel) and exit_observed, when present, is the exit that session saw — the same exit_observed the 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 the config_unresolvable reason in Why a launch is refused names — the reason field says to re-add it; a retry will not help).
  • check_unavailable — a test of an openvpn / wireguard proxy 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). The reason field says which. In every case there is no fallback to a plain reachability check of the tunnel endpoint. measured_by is driftstack and exit_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 the reason: 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_run and reason say 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.

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.

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/proxies lists your saved proxies with their ids, and POST /v1/account/me/proxies returns the id of the one it saves. Both need a key with the account_owner scope (Full access on the dashboard’s API keys page); a read + write key gets 403. 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/proxies lists 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-Account like 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 } (status is unknown, ok or failing) — 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 carries cap and count for the workspace; your account summary carries your personal proxy_cap and proxy_count.
  • Revisions. Every row has a revision that moves on every PUT you make (a background reading never moves it). Send it back as expected_revision and a proxy another computer changed first is refused with 409 (code: "stale_revision", current_revision) and nothing changes. Omit it to write unconditionally, as before.
  • password: "" is refused with 400. Clearing a password needs an explicit null; leaving the field out keeps the stored one.
  • Idempotency-Key on 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, mtu and persistent_keepalive, or the OpenVPN username — and has_preshared_key. The private key, the pre-shared key and the .ovpn blob 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 (429 past it, with retry_after_seconds).
  • An ended Trial gets the quick check only. POST …/proxies/{id}/test with check=quick (the default) runs on Driftstack’s API server and is available 5 times a day once the Trial has ended (429 past the budget, retry_after_seconds says when); check=full runs 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 a 403 and 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 (this local_ref was moved before), linked_row (server_link names a row with the same address and secret) or same_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 (the server_link row holds another password; neither is overwritten) or same_address_over_limit (a different-password twin at the cap).
  • refuse — over_limit, unsafe_host (a private address), vpn_not_on_plan or invalid (the detail says 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.