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
| Credential | Sent as | Bound to | Used by |
|---|---|---|---|
| API key | Authorization: Bearer osk_... | One organization, its scopes, optionally some lines and an expiry | Your backends, scripts and CI, the CLI, the MCP server |
| OAuth access token | Authorization: Bearer osa_... | One person, one organization, one client, its scopes and its resource | The Ostra apps, the browser extension, MCP clients Ostra registered |
| Session | The __Host-ostra_session cookie, plus X-CSRF-Token on every change and Ostra-Organization on organization routes | One person; the organization comes from the header | The dashboard |
- A key or token names its organization itself. Do not send
Ostra-Organizationwith it, or send the same organization (403 forbiddenotherwise). - When an
Authorizationheader 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.
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.
| Role | Can also |
|---|---|
member | Use 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 |
admin | Rename 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 |
owner | Everything 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.
{ "error": { "code": "insufficient_scope", "message": "this credential lacks lines:new_carrier_ip", "required_scopes": ["lines:new_carrier_ip"] } }| HTTP | Code | When |
|---|---|---|
400 | invalid_request | A body, query or header that does not parse; message names the field. Also a sign-in address the mail provider refuses. |
400 | sandbox_only | A sandbox-only route called on a real line. |
400 | invalid_token | A sign-in link that is unknown, used or expired (one answer for all three). |
400 | invalid_code | A 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). |
400 | phone_not_accepted | A 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. |
401 | unauthenticated | No credential, or an unknown, revoked or expired one, or an ended session. |
403 | forbidden | The role does not allow it, the route takes a signed-in session and got a key or token, or the credential names another organization. |
403 | insufficient_scope | The key or token lacks a scope; required_scopes names the route's scopes, or the content scope alone when only that is missing. |
403 | csrf_failed | A session's change without the matching X-CSRF-Token. |
403 | origin_not_allowed | A session request from an origin the dashboard does not live on. |
403 | organization_closed | Ostra closed the organization. |
403 | two_step_required | The 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. |
403 | recent_sign_in_required | Turning 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. |
403 | internal_org_staff_only | Membership of Ostra's internal test organization is changed only by Ostra staff. |
403 | region_not_supported | A 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. |
403 | turnstile_failed | A 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. |
403 | access_code_required | Ostra 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. |
403 | access_code_used_up | The 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. |
404 | not_found | Anything you may not see, including a line your organization does not hold now, so a line's existence never leaks. |
409 | conflict | The request contradicts the current state, including a second request racing one still running with the same Idempotency-Key; message says how. |
409 | line_suspended | Ostra suspended the line, on its own or with your whole account. Reads still work. |
409 | aup_not_accepted | The acceptable use policy in force has not been accepted. |
409 | aup_version_mismatch | Accepting a version that is not the one in force; current_version is given. |
409 | kyb_incomplete | Submitting a verification record with fields missing; missing lists them. |
409 | attestation_required | Submitting a sign-up record without the 18-or-older statement or the policy version; missing lists them, with current_version. |
409 | idempotency_conflict | An Idempotency-Key used again with a different request. |
409 | organization_not_verified | Ostra has not verified the organization (or suspended it): adding a card, ordering lines and asking for a calls token wait for verification. |
409 | price_pending | Starting Checkout, or ordering lines, while the price sheet is pending. |
409 | card_required | Ordering lines with billing on and no card on file. Add one on the Billing page; nothing is charged until a line is assigned. |
409 | payment_owed | Ordering lines while a payment is owed on a line or on usage. |
409 | line_limit_reached | An 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. |
409 | renewal_terms_required | An 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. |
409 | renewal_terms_changed | An 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. |
410 | device_revoked | A revoked device's tunnel settings or push token; register again with a new key pair. |
429 | rate_limited | Too many requests; retry_after_seconds and a Retry-After header say when to try again. |
429 | new_carrier_ip_refused | Past the line's new carrier IP limits; next_allowed_at, retry_after_seconds and Retry-After say when. Nothing is queued. |
500 | internal_error | Anything else. It never says why. |
503 | phone_check_unavailable | The 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. |
503 | turnstile_unavailable | A 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. |
503 | signup_check_unavailable | Submitting 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. |
503 | gateway_pending | Issuing 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. |
503 | billing_unavailable | Billing is off on this server, or Stripe did not answer. Nothing was charged. |
503 | voice_unavailable | Calls 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.
| What | Limit |
|---|---|
| Requests per key, per app approval (all its OAuth tokens together) or per session | 600 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 line | One 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 texts | 5 at once per line per organization, 90 seconds each |
| Simulated texts on the sandbox line | 60 an hour per organization |
| Proxy credentials | 20 live on a line |
| Webhook endpoints | 10 per organization |
| Webhook test sends | 30 an hour per organization |
| API keys | 50 live per organization |
| Sign-in emails | 5 per address and 30 per client network (an IPv6 address counts by its /64) an hour |
| Organizations one person creates | 5 |
| Members an organization adds | 20 a day |
| Stripe Checkout sessions | 10 an hour per organization |
| Stripe Customer Portal sessions | 30 an hour per organization |
| Ostra app devices | 253 addresses per line, one per device; a revoked or ended device's address waits 60 minutes before another device gets it |
| Push for texts | One 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 is409 idempotency_conflict. While the first is still running, a second is409 conflict. - An answer that carried a secret comes back without it:
"secret": nulland"secret_withheld": true. - The MCP server’s
new_carrier_iptool 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.
- Send the person’s browser to
/oauth/authorizewithresponse_type=code,client_id,redirect_uri,scope(space-separated),state,code_challenge,code_challenge_method=S256and, for the MCP server,resource=https://mcp.ostranetwork.com/mcp(the API is the default resource). - They sign in to the dashboard, pick the organization and allow it. The redirect carries
code,stateandiss; check all three. - Trade the code at
POST /oauth/token(form-encoded) with yourcode_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/...orhttp://[::1]/...), which matches on any port. Native apps use a reverse-domain scheme with one slash, such ascom.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 is401.