Proxy ports and IPs
Point any client at a line’s proxy port and its requests leave through that phone’s cellular connection, from the carrier’s own addresses. This page covers the three ports, the credentials for the HTTPS one, asking the carrier for a new IP, and how to check what a line is doing.
The three ports
A line’s ports are on the gateway, lax.gw.ostranetwork.com, numbered from its slot: slot 07 gives 30007, 10007 and 20007. The HTTPS port takes a credential; the SOCKS5 and HTTP ports take none, and answer only the source addresses you list for the line.
HTTPS 300NN
https://USERNAME:SECRET@lax.gw.ostranetwork.com:300NN
- Who may connect
- Anyone with a live credential for the line
- Use it for
- Browsers, automation clients, curl, any language. The connection to the gateway is TLS. The default.
SOCKS5 100NN
socks5h://lax.gw.ostranetwork.com:100NN
- Who may connect
- The source addresses you list for the line, with no credential
- Use it for
- Clients that cannot send a SOCKS5 username: Chromium has no SOCKS5 sign-in, and Playwright refuses a SOCKS5 proxy with one. Use
socks5hso names are looked up through the line.
HTTP 200NN
http://lax.gw.ostranetwork.com:200NN
- Who may connect
- The source addresses you list for the line, with no credential
- Use it for
- Clients that speak only a plain HTTP proxy. Anything you send to the gateway on this port crosses the network unencrypted, so prefer HTTPS.
- TCP only. UDP is not carried, so QUIC falls back to TCP and a browser’s WebRTC media does not go through the line.
- Names are looked up through the line. With the HTTPS port’s
CONNECTand withsocks5h, the phone looks up the name over its cellular connection. - Some destinations are refused. Private and internal address ranges, the mail ports 25, 465 and 587, ports 8100 and 9100, which the line’s phone keeps for itself, and, on any port, a request that names a site on the acceptable use policy’s blocked list: the whole domain and every name under it, not only its sign-in or payment pages. The list covers the largest US banks, brokerages and payment apps, federal and large-state benefit and tax portals, and the major ticketing platforms. Ostra can open a listed site for a line whose stated purpose names it, never a ticketing site. A refused request gets
403atCONNECT, or SOCKS5’s “connection not allowed by ruleset”. - Some places cannot connect. The gateway refuses a connection from a place under comprehensive US sanctions, and from an address it cannot place. If its place database cannot be read at all, Ostra is paged at once and the gateway refuses every connection after ten checks in a row, about ten minutes.
- A line that is off its carrier’s network closes. Ostra checks every line’s exit network; when a check finds the exit on a network that is not the carrier’s, or one it cannot name, it closes the ports rather than let traffic leave any other way, and sends
line.down. If the check itself cannot run, Ostra is paged at once and closes the ports after ten checks in a row.
Source addresses
The SOCKS5 and HTTP ports answer only the addresses on the line’s list, and nobody while the list is empty. PUT /v1/lines/{line_id}/sources (scope proxy_credentials:write) sets the whole list, replacing the one there, and GET /v1/lines/{line_id}/sources (scope lines:read) reads it; the line’s Access tab in the dashboard does the same. The HTTPS port needs no list.
- An entry is one public IPv4 or IPv6 address, or a network no wider than a
/24or a/48, written with its host bits zero (203.0.113.0/24), with an optional label of your own. A line takes at most 20. - An entry is
activeonce the gateway has it, usually 2 to 6 seconds after the change. A removed entry is listed underremovinguntil the gateway confirms it gone. - The list belongs to your time on the line: it ends with your assignment, and a suspended line takes no new address.
Endpoints and credentials
A line’s endpoints, and its credentials without their secrets, come from GET /v1/lines/{line_id}/proxy (scope lines:read). The samples on this page use the line in $LINE; LAX-SANDBOX-00 works with all of them and points nowhere.
#!/bin/sh
# A line's proxy endpoints, whether the gateway is taking credentials,
# and the line's credentials (never their secrets).
curl -sS "$OSTRA_API_BASE_URL/v1/lines/$LINE/proxy" \
-H "Authorization: Bearer $OSTRA_API_KEY"{
"line_id": "LAX-V-07",
"sandbox": false,
"endpoints": {
"https": "https://lax.gw.ostranetwork.com:30007",
"socks5": "socks5h://lax.gw.ostranetwork.com:10007",
"http": "http://lax.gw.ostranetwork.com:20007"
},
"gateway": "connected",
"credentials": [
{
"id": "a07-3f9a2b7c",
"username": "line07-3f9a2b",
"label": "CI",
"created_at": "2026-10-12T17:10:00.000Z",
"expires_at": "2026-10-12T18:10:00.000Z",
"active": true,
"revocation": null,
"revoked_at": null
}
]
}gateway is connected while the gateway is taking credentials for the line, pending while it is not (issuing then answers 503 gateway_pending and issues nothing), and sandbox on the sandbox line.
Issue a credential
A credential is a username and secret for the line’s HTTPS port. Give it a label you will recognise, and an expiry from 60 seconds to a year, or none. The key needs proxy_credentials:write; a line holds at most 20 live ones.
#!/bin/sh
# Issue a proxy credential for the line's HTTPS port, good for one hour.
# The secret is in this answer only. After a lost answer, run this again with
# IDEMPOTENCY_KEY set to the key it printed: you get the same credential back,
# without its secret, instead of a second one.
IDEMPOTENCY_KEY="${IDEMPOTENCY_KEY:-ci-$(od -An -N12 -tx1 /dev/urandom | tr -d ' \n')}"
echo "Idempotency-Key: $IDEMPOTENCY_KEY" >&2
curl -sS -X POST "$OSTRA_API_BASE_URL/v1/lines/$LINE/proxy-credentials" \
-H "Authorization: Bearer $OSTRA_API_KEY" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-H 'content-type: application/json' \
-d '{"label": "CI", "expires_in_seconds": 3600}'The Idempotency-Key is optional here and recommended: a repeat with the same key returns the same credential, with secret null and secret_withheld true, instead of a second one.
{
"credential": {
"id": "a07-3f9a2b7c",
"username": "line07-3f9a2b",
"label": "CI",
"created_at": "2026-10-12T17:10:00.000Z",
"expires_at": "2026-10-12T18:10:00.000Z",
"active": false,
"revocation": null,
"revoked_at": null
},
"secret": "your-secret-shown-only-once"
}Wait for active. A new credential works once the gateway has loaded it, usually 2 to 6 seconds after the answer; until then active is false. Read GET /v1/lines/{line_id}/proxy about once a second until it is true. If you retry a 407 instead, wait at least 2 seconds between tries and stop after 5: the gateway blocks a client address for 5 minutes after 10 failed sign-ins in a minute.
"""Issue a proxy credential and wait until the gateway has it.
Prints the proxy URL to use, https://USERNAME:SECRET@HOST:PORT, on its own line.
The URL holds the secret: hand it to your client, and keep it out of logs.
Python 3.9 or later, standard library only.
"""
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
API = os.environ["OSTRA_API_BASE_URL"]
KEY = os.environ["OSTRA_API_KEY"]
LINE = os.environ.get("LINE", "LAX-SANDBOX-00")
def call(method, path, body=None, headers=None):
req = urllib.request.Request(
API + path,
method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Authorization": "Bearer " + KEY, "content-type": "application/json", **(headers or {})},
)
try:
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
except urllib.error.HTTPError as e:
error = json.load(e).get("error", {})
sys.exit(f"{e.code} {error.get('code')}: {error.get('message')}")
issued = call(
"POST",
f"/v1/lines/{LINE}/proxy-credentials",
{"label": "CI", "expires_in_seconds": 3600},
{"Idempotency-Key": str(uuid.uuid4())},
)
credential, secret = issued["credential"], issued["secret"]
proxy = call("GET", f"/v1/lines/{LINE}/proxy")
if not proxy["sandbox"]:
# A new credential works once the gateway has loaded it: usually 2 to 6 seconds.
deadline = time.time() + 20
while not any(c["id"] == credential["id"] and c["active"] for c in proxy["credentials"]):
if time.time() > deadline:
sys.exit("the gateway has not taken the credential up; revoke it and try again")
time.sleep(1)
proxy = call("GET", f"/v1/lines/{LINE}/proxy")
host_port = proxy["endpoints"]["https"].split("://", 1)[1]
user = urllib.parse.quote(credential["username"], safe="")
print(f"https://{user}:{urllib.parse.quote(secret, safe='')}@{host_port}")Prints the proxy URL to use. The username and secret are URL-encoded into it.
Revoking (DELETE /v1/lines/{line_id}/proxy-credentials/{credential_id}) stops new sign-ins within about 10 seconds; revocation reads pending, then done once the gateway confirms. A tunnel the credential already opened runs until your client or the far end closes it. A credential also ends on its own at expires_at, and every credential on a line is revoked when your assignment of the line ends or is suspended.
Send requests through the line
Each sample takes the proxy URL from OSTRA_PROXY_URL, as credential.py prints it: https://USERNAME:SECRET@lax.gw.ostranetwork.com:300NN. Point it at sites you own or are authorized to test; example.com stands in for yours.
#!/bin/sh
# One request through the line's HTTPS proxy port.
# OSTRA_PROXY_URL is https://USERNAME:SECRET@lax.gw.ostranetwork.com:300NN
curl -sS --proxy "$OSTRA_PROXY_URL" https://example.com/"""One request through the line's HTTPS proxy port, with requests.
OSTRA_PROXY_URL is https://USERNAME:SECRET@lax.gw.ostranetwork.com:300NN
(credential.py prints one). requests 2.32 or later: pip install requests
"""
import os
import requests
line = os.environ["OSTRA_PROXY_URL"]
r = requests.get("https://example.com/", proxies={"http": line, "https": line}, timeout=30)
print(r.status_code)
print(r.text)requests sends the request to the proxy over TLS and opens a CONNECT tunnel through it, so the site's own TLS runs end to end through the line.
// One request through the line's HTTPS proxy port, with Node's own fetch.
// Node 22.21 or 24 and later read the proxy from the environment when asked:
// NODE_USE_ENV_PROXY=1 HTTPS_PROXY="$OSTRA_PROXY_URL" node through-line.mjs
const res = await fetch('https://example.com/')
console.log(res.status)
console.log(await res.text())Node.js's built-in proxy support is marked as still in development in its own documentation (nodejs.org, NODE_USE_ENV_PROXY). Without it, use your HTTP client's proxy option.
Ostra’s checks run each of these three against a stand-in for the gateway’s HTTPS port, with a credential issued by the real API. On a real line the address the site sees is the carrier’s.
A new carrier IP
POST /v1/lines/{line_id}/new-carrier-ip asks the carrier for a new address on this one line: the phone reconnects and the carrier assigns the address, which can be the same one as before. It needs lines:new_carrier_ip and an Idempotency-Key, and returns at once with a job to read.
#!/bin/sh
# Ask the carrier for a new IP on the line. The answer is a job to read.
# The Idempotency-Key is required. After a lost answer, run this again with
# IDEMPOTENCY_KEY set to the key it printed: you get the first answer back,
# not a second request.
IDEMPOTENCY_KEY="${IDEMPOTENCY_KEY:-new-ip-$(od -An -N12 -tx1 /dev/urandom | tr -d ' \n')}"
echo "Idempotency-Key: $IDEMPOTENCY_KEY" >&2
curl -sS -X POST "$OSTRA_API_BASE_URL/v1/lines/$LINE/new-carrier-ip" \
-H "Authorization: Bearer $OSTRA_API_KEY" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY"{
"job": {
"id": "9f2e4c1a7b3d4e5f8a9b0c1d2e3f4a5b",
"line_id": "LAX-SANDBOX-00",
"status": "running",
"created_at": "2026-10-12T17:12:30.000Z",
"expires_at": "2026-10-12T17:17:30.000Z",
"finished_at": null,
"sandbox": true
}
}A job is pending (waiting for the Mac at the wall), running, then succeeded, failed or expired (the Mac never took it); refused means it was held back after it was made. On a real line, the Mac that runs the phones does the work. On the sandbox line the job reads succeeded after 20 seconds, and its new documentation address follows within about 10 seconds.
Limits, per line. At most one every 5 minutes and 24 in any 24 hours, counted with every other request on the line, Ostra’s own included, and none for 60 minutes after one that left requests through the line much slower than before. Past a limit the answer is 429 new_carrier_ip_refused with next_allowed_at, and nothing is queued: ask again then. Nothing changes a line’s IP on a schedule. Besides your own requests, Ostra’s health checks may ask for one when a line still answers but has lost its IPv4 or IPv6 address, at most once a day for that lost family and under the same limits; line.ip_changed tells you when the address changes.
"""Ask for a new carrier IP on a line and wait for the job to finish.
Prints the job's final status. The new address shows in GET /v1/lines/{line_id}/ip
once a check has seen it, and line.ip_changed tells you when that happens.
Python 3.9 or later, standard library only.
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
import uuid
API = os.environ["OSTRA_API_BASE_URL"]
KEY = os.environ["OSTRA_API_KEY"]
LINE = os.environ.get("LINE", "LAX-SANDBOX-00")
def call(method, path, headers=None):
req = urllib.request.Request(API + path, method=method, headers={"Authorization": "Bearer " + KEY, **(headers or {})})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
try:
job = call("POST", f"/v1/lines/{LINE}/new-carrier-ip", {"Idempotency-Key": str(uuid.uuid4())})["job"]
except urllib.error.HTTPError as e:
error = json.load(e)["error"]
if error["code"] == "new_carrier_ip_refused":
# Nothing was queued. Ask again at next_allowed_at, not before.
sys.exit(f"refused until {error['next_allowed_at']}: {error['message']}")
sys.exit(f"{e.code} {error['code']}: {error['message']}")
# Read the job until it finishes: not every ending sends new_carrier_ip.finished.
while job["status"] in ("pending", "running"):
time.sleep(2)
job = call("GET", f"/v1/lines/{LINE}/new-carrier-ip/{job['id']}")["job"]
print(job["status"])Read the job until it finishes, as the sample does. new_carrier_ip.finished is sent whenever a job ends: it succeeds, fails, expires before the Mac takes it, or is held back as refused. When a check sees the line’s new exit address you get line.ip_changed, and GET /v1/lines/{line_id}/ip shows it.
Checking a line
GET /v1/lines/{line_id}givesstatus(active, orsuspendedby Ostra; reads still work) andhealth.state:okwhen a check got through the port in the last 3 minutes and found the exit on the carrier’s network,downwhen Ostra closed the ports or the last check failed, andunknownwhen there was no check in 3 minutes, or one that went through without its exit confirmed on every address family. Only a fresh check makes a lineok.health.familiessays which families (ipv4,ipv6) the fresh check confirmed: on anunknownline with exactly one of them, the line works on that family only (“Working, IPv4 only”) while the other is missing; it isnullin every other case.GET /v1/lines/{line_id}/ipgives the exit addresses the checks last saw and the network (asn4,asn6) that owns each.geolocationisnullon a real line; the sandbox gives a labelled sample.GET /v1/lines/{line_id}/report, the line report (scopereports:read): on a real line it answers"available": false, since line reports are not kept on the server. The sandbox gives a fixed sample that describes no real line.GET /v1/lines/{line_id}/connections, the connection record (scopeconnections:read): when, from which address, to which host and port, and how many bytes, for your own time on the line, and why Ostra refused a connection when it did (refused). Never content. Kept 90 days.GET /v1/lines/{line_id}/connections/export?format=csv(orjson) saves every record in a range as one file. A connection is recorded when it ends, and reaches the record within about 5 seconds.
The line check page
check.ostranetwork.com, opened in a browser through the line, shows the address the check server saw, the network that owns it and whether it is an Ostra line, then what sites can see beside the address (WebRTC, the browser’s timezone and language, and which server looked up the page’s name), and a section about your own browser, which Ostra cannot change and says so. Nothing in that section leaves the tab. Anyone can open it, with no sign-in.