ostradocs Menu

API basics

The rules every route follows: where the API is, how to send a credential, the scopes, the one error shape, the limits, cursors and idempotency. Each route is on the Routes page.

Base URL and format

  • The base URL is https://api.ostranetwork.com, and every route is under /v1.
  • JSON in and out, with content-type: application/json. The OAuth token, revocation and introspection routes take form bodies, as OAuth does.
  • Times are ISO 8601 in UTC with a Z. Ids are strings.
  • The API can use your lines and order more (Orders); Ostra assigns each line, and no route texts or calls out from a line.

Credentials

CredentialSent asBound toUsed by
API keyAuthorization: Bearer osk_...One organization, its scopes, optionally some lines and an expiryYour backends, scripts and CI, the CLI, the MCP server
OAuth access tokenAuthorization: Bearer osa_...One person, one organization, one client, its scopes and its resourceThe Ostra apps, the browser extension, MCP clients Ostra registered
SessionThe __Host-ostra_session cookie, plus X-CSRF-Token on every change and Ostra-Organization on organization routesOne person; the organization comes from the headerThe dashboard
  • A key or token names its organization itself. Do not send Ostra-Organization with it, or send the same organization (403 forbidden otherwise).
  • When an Authorization header is present the cookie is ignored. A malformed one is a failed credential, not a missing one.
  • Keys, members, the verification record and billing take a session only: a key or token there is 403 forbidden. A key can never create or see another key.
  • Every secret (a key, a proxy credential’s secret, a webhook signing secret) is shown once, in the answer that makes it, and never again.

Scopes

One list for keys, OAuth tokens and sessions. A key with only :read scopes cannot ask for a new carrier IP, issue a proxy credential or change a webhook; it can still send the sandbox line a simulated text. A call that lacks a scope is 403 insufficient_scope, and required_scopes names the scopes the route needs, or, when only the content scope is missing for include_content=true, that one scope.

ScopeAllowsRoutes that need it
lines:readSee your lines, their status, addresses and proxy endpoints.GET /lines
GET /lines/{line_id}
GET /lines/{line_id}/ip
GET /lines/{line_id}/proxy
GET /lines/{line_id}/sources
GET /lines/{line_id}/attestations
lines:new_carrier_ipAsk the carrier for a new IP on your lines.POST /lines/{line_id}/new-carrier-ip
GET /lines/{line_id}/new-carrier-ip/{job_id}
messages:readSee who texted your lines and when.GET /lines/{line_id}/messages
GET /lines/{line_id}/messages/wait
GET /lines/{line_id}/messages/{message_id}
POST /lines/{line_id}/simulate/message
GET /labels
messages:read_contentRead the texts on your lines and their codes.GET /lines/{line_id}/messages/{message_id}
calls:readSee who called your lines and when.GET /lines/{line_id}/calls
GET /lines/{line_id}/calls/{call_id}
POST /voice/token
DELETE /voice/registration
calls:read_contentRead call transcripts and codes.GET /lines/{line_id}/calls/{call_id}
POST /voice/token
connections:readSee your lines' connection records.GET /lines/{line_id}/connections
GET /lines/{line_id}/connections/export
usage:readSee your usage.GET /usage
reports:readSee line reports.GET /lines/{line_id}/report
proxy_credentials:writeIssue and revoke proxy credentials for your lines, and set the addresses their SOCKS5 and HTTP ports answer.POST /lines/{line_id}/proxy-credentials
DELETE /lines/{line_id}/proxy-credentials/{credential_id}
PUT /lines/{line_id}/sources
lines:attestState each line's purpose (its attestation), which may ask Ostra to open a blocked site for it.POST /lines/{line_id}/attestations
webhooks:readSee your webhook endpoints and deliveries.GET /webhooks
GET /webhooks/{endpoint_id}
GET /webhooks/{endpoint_id}/deliveries
webhooks:writeChange your webhook endpoints.POST /webhooks
PATCH /webhooks/{endpoint_id}
DELETE /webhooks/{endpoint_id}
POST /webhooks/{endpoint_id}/secret
POST /webhooks/{endpoint_id}/test
POST /webhooks/{endpoint_id}/deliveries/{delivery_id}/retry
devices:readSee your Ostra app devices and their tunnel settings.GET /devices
GET /devices/{device_id}
GET /devices/{device_id}/tunnel-config
GET /devices/{device_id}/push
devices:writeRegister and revoke Ostra app devices on your lines.POST /devices
DELETE /devices/{device_id}
PUT /devices/{device_id}/push
DELETE /devices/{device_id}/push
POST /voice/token
orders:readSee your line orders and where each one stands.GET /orders
GET /orders/renewal-terms
GET /orders/{order_id}
orders:writeOrder lines and cancel orders; each line is charged its first month when it is assigned.POST /orders/renewal-terms
POST /orders
POST /orders/{order_id}/cancel
POST /orders/{order_id}/resume
labels:writeName the numbers that text and call your lines.PUT /labels/{number}
DELETE /labels/{number}

Roles

Every member of an organization has a role. A session carries every scope above; an OAuth token carries the scopes allowed at consent, cut at every request to what the person’s role and the client allow, so a person removed from the organization takes the access they granted with them.

RoleCan also
memberUse the lines with every scope, but not order lines, state a line’s purpose or cancel a line; see members, the verification record, billing and the apps they approved
adminRename the organization, make and revoke API keys, add and change members other than owners, fill in and submit verification, accept the acceptable use policy, add a card and open the card portal, order lines, state a line’s purpose and cancel a line
ownerEverything an admin can, and add, change or remove owners. An organization always keeps one owner

Errors

Every refusal has one shape, with a code to branch on and a message for people. Some codes carry more fields, named below.

JSONA read-only key asking for a new carrier IP, 403
{ "error": { "code": "insufficient_scope", "message": "this credential lacks lines:new_carrier_ip", "required_scopes": ["lines:new_carrier_ip"] } }
HTTPCodeWhen
400invalid_requestA body, query or header that does not parse; message names the field. Also a sign-in address the mail provider refuses.
400sandbox_onlyA sandbox-only route called on a real line.
400invalid_tokenA sign-in link that is unknown, used or expired (one answer for all three).
400invalid_codeA sign-in code that does not work: wrong, used, expired, replaced or ended by wrong tries, whether or not the address has an account (one answer for all of them).
400phone_not_acceptedA number no code is texted to: an internet-phone (VoIP) number, not a mobile one, not a US one, one Ostra uses, or one that cannot take a text; reason says which.
401unauthenticatedNo credential, or an unknown, revoked or expired one, or an ended session.
403forbiddenThe role does not allow it, the route takes a signed-in session and got a key or token, or the credential names another organization.
403insufficient_scopeThe key or token lacks a scope; required_scopes names the route's scopes, or the content scope alone when only that is missing.
403csrf_failedA session's change without the matching X-CSRF-Token.
403origin_not_allowedA session request from an origin the dashboard does not live on.
403organization_closedOstra closed the organization.
403two_step_requiredThe organization requires two-step sign-in and this session did not pass a second step: sign out and sign in again, with the authenticator code or setting it up. Turning two-step on from such a session does not lift it. Also approving an app for it from such a session.
403recent_sign_in_requiredTurning two-step sign-in on from a signed-in session needs a sign-in from the last 10 minutes (20 for the confirming code), and cancelling a line one, or a second step, from the last 10: sign out, sign in again, then try again.
403internal_org_staff_onlyMembership of Ostra's internal test organization is changed only by Ostra staff.
403region_not_supportedA sign-up from a place Ostra does not open accounts from, judged on the request's own address: the United States only, unless Ostra chose to refuse only the places under US sanctions.
403turnstile_failedA sign-up step, or a sign-in email past a network's third in an hour, without a Cloudflare Turnstile token that passes, while the server asks for one: missing, used already, expired, or made for another step or site. turnstile_action names the step (sign_in for the email). Nothing was made or sent; run the check again.
403access_code_requiredOstra is open by invitation on this server (the access gate) and the request carries no pass, or one whose access code was revoked or has ended, on a route that makes an account or a session. Nothing was sent or made: enter an access code (POST /v1/auth/access) and try again.
403access_code_used_upThe pass's access code has already let in as many people as it allows, and this person is not one of them. Nothing was sent, made or spent: an emailed code or link used at that step still works once a code with room opens the browser.
404not_foundAnything you may not see, including a line your organization does not hold now, so a line's existence never leaks.
409conflictThe request contradicts the current state, including a second request racing one still running with the same Idempotency-Key; message says how.
409line_suspendedOstra suspended the line, on its own or with your whole account. Reads still work.
409aup_not_acceptedThe acceptable use policy in force has not been accepted.
409aup_version_mismatchAccepting a version that is not the one in force; current_version is given.
409kyb_incompleteSubmitting a verification record with fields missing; missing lists them.
409attestation_requiredSubmitting a sign-up record without the 18-or-older statement or the policy version; missing lists them, with current_version.
409idempotency_conflictAn Idempotency-Key used again with a different request.
409organization_not_verifiedOstra has not verified the organization (or suspended it): adding a card, ordering lines and asking for a calls token wait for verification.
409price_pendingStarting Checkout, or ordering lines, while the price sheet is pending.
409card_requiredOrdering lines with billing on and no card on file. Add one on the Billing page; nothing is charged until a line is assigned.
409payment_owedOrdering lines while a payment is owed on a line or on usage.
409line_limit_reachedAn order that would take the organization past the most lines one account holds and has on order; line_limit, held and on_order are given.
409renewal_terms_requiredAn order or a resume that names no renewal terms while billing is on: read them (GET /v1/orders/renewal-terms), show them to the person agreeing, and send their id as accept_renewal_terms.
409renewal_terms_changedAn order or a resume that names other renewal terms than those in force (a price changed, or a change was announced, since they were read): read them again and agree to the new ones.
410device_revokedA revoked device's tunnel settings or push token; register again with a new key pair.
429rate_limitedToo many requests; retry_after_seconds and a Retry-After header say when to try again.
429new_carrier_ip_refusedPast the line's new carrier IP limits; next_allowed_at, retry_after_seconds and Retry-After say when. Nothing is queued.
500internal_errorAnything else. It never says why.
503phone_check_unavailableThe number could not be checked, or the code sent or checked, right now: the service did not answer, or the day's limit across Ostra was reached. Try again later.
503turnstile_unavailableA sign-up step, or a sign-in email that needs the check, while Cloudflare's check cannot be asked. Nothing was made or sent; try again in a minute.
503signup_check_unavailableSubmitting a sign-up in the first seconds after Ostra's server starts, before its sanctions list is in force. Nothing was submitted; retry_after_seconds and Retry-After say when to try again.
503gateway_pendingIssuing a proxy credential while the gateway is not taking them for the line (nothing was issued); revoking one while the gateway has not pulled in the last 30 seconds (the revocation is recorded and the credential may work until the gateway pulls); or reading a tunnel config before the gateway reported its key. state is pending.
503billing_unavailableBilling is off on this server, or Stripe did not answer. Nothing was charged.
503voice_unavailableCalls to lines are not on on this server, or the push credential for the app's build is not set up. No token was issued.

404 not_found is also the answer for a line, text or anything else that exists but is not yours, the same as for one that does not exist. A body too large or of the wrong content type answers 413 or 415 with the code invalid_request.

Rate limits and other limits

Past a rate limit the answer is 429, with retry_after_seconds in the body and a Retry-After header. Wait that long; nothing you sent was queued.

WhatLimit
Requests per key, per app approval (all its OAuth tokens together) or per session600 a minute
Requests from all of an organization’s keys and OAuth tokens together (sessions aside)1800 a minute
A new carrier IP on one lineOne every 5 minutes and 24 in any 24 hours, shared with every other source on the line; none for 60 minutes after one that left the line much slower
Long-poll waits for texts5 at once per line per organization, 90 seconds each
Simulated texts on the sandbox line60 an hour per organization
Proxy credentials20 live on a line
Webhook endpoints10 per organization
Webhook test sends30 an hour per organization
API keys50 live per organization
Sign-in emails5 per address and 30 per client network (an IPv6 address counts by its /64) an hour
Organizations one person creates5
Members an organization adds20 a day
Stripe Checkout sessions10 an hour per organization
Stripe Customer Portal sessions30 an hour per organization
Ostra app devices253 addresses per line, one per device; a revoked or ended device's address waits 60 minutes before another device gets it
Push for textsOne token per device; a push is tried at most 4 times, within the code's 15 minutes

Cursors

Lists of texts and calls page with an opaque cursor. Every answer carries next_cursor; pass it back to continue, and keep it to resume later. Cursors follow the order Ostra stored the items, not the phone’s clock, so nothing that arrived late is skipped. Text cursors start s. on the sandbox line and m. on a real line, and call cursors c.; treat them as opaque all the same. A cursor is sealed, so it carries no count of anything, and one you make up or change is refused with 400 invalid_request.

Idempotency

Send Idempotency-Key (1 to 128 of A-Z a-z 0-9 . _ : -) to make a retry safe.

  • Required on POST /v1/lines/{line_id}/new-carrier-ip; accepted on issuing a proxy credential, creating a webhook endpoint and registering a device. Other routes ignore it.
  • Keys are kept 24 hours per organization and route. The same key with the same request returns the first answer again, with Ostra-Idempotent-Replay: true; with a different request it is 409 idempotency_conflict. While the first is still running, a second is 409 conflict.
  • An answer that carried a secret comes back without it: "secret": null and "secret_withheld": true.
  • The MCP server’s new_carrier_ip tool uses the same keys, so a key replays at either door.

OAuth

For an app that acts for a person. Authorization code with PKCE (S256 only), as a client Ostra registered; there is no dynamic client registration. The issuer is the API’s base URL, with metadata at /.well-known/oauth-authorization-server.

  1. Send the person’s browser to /oauth/authorize with response_type=code, client_id, redirect_uri, scope (space-separated), state, code_challenge, code_challenge_method=S256 and, for the MCP server, resource=https://mcp.ostranetwork.com/mcp (the API is the default resource).
  2. They sign in to the dashboard, pick the organization and allow it. The redirect carries code, state and iss; check all three.
  3. Trade the code at POST /oauth/token (form-encoded) with your code_verifier. The access token lasts 60 minutes; the refresh token works once and comes back with a new one, and using an old one again revokes the whole grant.
  • A redirect URI matches a registered one exactly, except a loopback one (http://127.0.0.1/..., http://localhost/... or http://[::1]/...), which matches on any port. Native apps use a reverse-domain scheme with one slash, such as com.example.app:/oauth/callback.
  • A code lasts 5 minutes and works once; a second use revokes everything issued from the first.
  • A token works only at its resource: one issued for the API is refused by the MCP server, and the reverse.
  • The person can revoke an app in the dashboard (DELETE /v1/oauth/grants/{grant_id}); its next call is 401.