For the hands-on builder

Keep your SDK.
Meet one API.

Everything you need to integrate: one OpenAI-compatible endpoint, the exact request and response shapes, and every limit and error stated plainly.

Compatibility is a contract, not a logo
Start here

Make your first request

Oproute speaks the OpenAI chat completions protocol. If you already have an OpenAI client, you change two things: the base URL and the key. Nothing else about your integration changes.

Python · OpenAI SDK
import os
from openai import OpenAI

client = OpenAI(
    base_url="https://oproute.web.id/v1",
    api_key=os.environ["OPROUTE_API_KEY"],
)

response = client.chat.completions.create(
    model="model-id-from-catalog",
    messages=[{"role": "user", "content": "Hello, Oproute!"}],
)

print(response.choices[0].message.content)

Keep keys server-side
Read the key from a server environment variable. Never put it in browser code, a mobile app bundle, or source control — anyone holding it can spend your account's access.

Credentials

Authenticate with an API key

Send the key as a bearer token on every gateway request:

Authorization: Bearer $OPROUTE_API_KEY

A key is shown exactly once, at creation. Afterwards the dashboard lists only a masked prefix, and the server stores only a keyed digest — so a database leak cannot be turned into a working credential. If you lose a key, revoke it and create a new one; there is no way to recover it.

Keys belong to your account, not to a single integration. Every key shares one access window, one rate allowance and one fair-usage counter. Creating a second key does not create a second allowance — it is a way to separate credentials, not to multiply capacity.

Dashboard sign-in is separate and uses a server-managed HttpOnly session cookie. Your API key is never used to sign in to the website.

The surface

One endpoint carries your chat traffic

POST https://oproute.web.id/v1/chat/completions
cURL · chat completions
curl https://oproute.web.id/v1/chat/completions \
  -H "Authorization: Bearer $OPROUTE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "model-id-from-catalog",
    "messages": [{"role": "user", "content": "Hello, Oproute!"}]
  }'

Provider-specific features are available when the model you chose declares the matching capability. A parameter that a model cannot honour is rejected with a clear error rather than silently dropped, so you never ship an integration that quietly ignores half of what you sent.

Request

What you may send

Requests are validated before anything reaches an upstream provider. Unknown fields are rejected rather than ignored.

FieldTypeRequiredNotes
modelstringyesAn id from GET /api/v1/models that is currently available.
messagesarrayyesConversation turns, oldest first. At least 1, at most 200. Each has role and content.
streambooleannoDefault false. true switches to server-sent events.
temperaturenumbernoSampling randomness, 0 to 2 inclusive. Anything outside is rejected.
top_pnumbernoNucleus sampling, 0 to 1 inclusive.
max_tokensintegernoMust be positive, and at most the service ceiling of 32,768.
stoparray of stringsnoAt most 4 sequences.
tools / tool_choicearray / objectnoOnly for models whose capability flags include tools.

Hard limits

Every bound below is enforced before anything reaches a provider, so an oversized or out-of-range request costs you nothing.

BoundValueIf exceeded
Messages per request200 maximum400 invalid_request
Total message content1 MiB (1,048,576 bytes)413 payload_too_large
Request body1 MiB413 payload_too_large
max_tokens32,768 maximum400 invalid_request
temperature0 to 2400 invalid_request
top_p0 to 1400 invalid_request
stop sequences4 maximum400 invalid_request

Unknown fields are refused rather than ignored. That is deliberate: a typo in a parameter name would otherwise be silently dropped, and you would debug a model's behaviour instead of your own request.

Message roles

Five roles are accepted: system sets behaviour for the conversation, developer does the same where a provider supports it, user is the human turn, assistant is a prior model turn you replay to keep context, and tool carries a tool result back and pairs with the call it answers. Any other role is rejected with the index of the offending message.

Replay the conversation yourself.
The gateway is stateless: it does not remember your previous requests. To keep context, send the earlier turns again with each call. This is the same model the OpenAI API uses, so existing code already does the right thing.

Response

What comes back

A non-streaming request returns an OpenAI-compatible completion object. The fields you will actually read are:

PathMeaning
choices[0].message.contentThe model's reply as text.
choices[0].finish_reasonWhy generation stopped — stop, length, or a tool-related reason.
choices[0].message.tool_callsPresent when the model asked for a tool instead of answering directly.
usage.prompt_tokens / usage.completion_tokensToken counts for this request. Informational.
modelThe model that actually served the request.

Token counts are a measurement of what happened, not a budget. Oproute sells access by time, so nothing here depletes and no balance exists to check.

Streaming

Stream tokens as they are produced

Set stream to true and the response becomes text/event-stream. Each event is a data: line containing one OpenAI-compatible chunk, and the stream ends with the literal sentinel data: [DONE].

cURL · streaming
curl -N https://oproute.web.id/v1/chat/completions \
  -H "Authorization: Bearer $OPROUTE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "model-id-from-catalog",
    "messages": [{"role": "user", "content": "Count to five"}],
    "stream": true
  }'
The event sequence
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":1758873600,"model":"...","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}

data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":1758873600,"model":"...","choices":[{"index":0,"delta":{"content":"One"},"finish_reason":null}]}

data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":1758873600,"model":"...","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: [DONE]

The first chunk carries the role. Middle chunks carry delta.content fragments — concatenate them in order. The final chunk before the sentinel carries an empty delta and a finish_reason. Read delta.content, not message.content, while streaming.

Node.js · consuming a stream
import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "https://oproute.web.id/v1",
  apiKey: process.env.OPROUTE_API_KEY,
});

const stream = await client.chat.completions.create({
  model: "model-id-from-catalog",
  messages: [{ role: "user", content: "Hello, Oproute!" }],
  stream: true,
});

for await (const chunk of stream) {
  process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}

Disconnects are honoured.
If your client closes the connection, the upstream request is cancelled too. You are not billed for a generation nobody is waiting for, and no capacity stays occupied.

Model selection

Read the catalog before you build

Model ids are never hardcoded in your integration — ask for the catalog and choose from what is actually serving. The response is a bare JSON array:

GET https://oproute.web.id/api/v1/models
One catalog entry
{
  "id": "deepseek-v4.1-flash",
  "name": "DeepSeek V4.1 Flash",
  "provider": "deepseek",
  "context_window": 64000,
  "capabilities": {
    "vision": false,
    "tools": true,
    "streaming": true,
    "reasoning": false
  },
  "status": "available"
}

capabilities tells you what a model can actually do. Check the flag before sending a feature: a request for a capability the route does not declare is refused with a clear error rather than silently ignored. status is available for anything that can serve traffic right now.

Every plan includes the same model access. Plans differ only in how long the access lasts, so there is no model to unlock by paying more.

Browse the configured catalog
Observability

Read the response headers

Successful and fair-usage-throttled completions both carry these, so a client can pace itself from the account's current state instead of guessing.

Response headers · example
X-Oproute-Gateway: oproute.web.id
X-Oproute-Request-ID: 9f2c41d8ab70e315
X-Oproute-FUP-Limit: 75000000
X-Oproute-FUP-Used: 18204416
X-Oproute-FUP-Reset: 2026-10-03T00:00:00Z
X-Oproute-RateLimit-RPM: 70
X-Oproute-Concurrency-Limit: 4
HeaderMeaning
X-Oproute-Request-IDCorrelation id for this request. Quote it in any support message — it is the fastest way to find what happened.
X-Oproute-FUP-LimitThe account-wide weekly processed-token threshold.
X-Oproute-FUP-UsedProcessed tokens recorded for the account when this request was admitted.
X-Oproute-FUP-ResetWhen the rolling window rolls over.
X-Oproute-RateLimit-RPMRequests per minute currently allowed for the account.
X-Oproute-Concurrency-LimitConcurrent requests currently allowed.

These appear on throttled responses too, which is what lets a client back off correctly instead of retrying blind.

Fair usage

What bounds a busy account

Fair usage exists so one account cannot occupy capacity that others depend on. It bounds pace, never access.

The threshold counts processed tokens for the whole account over a rolling window. Every key shares one counter, so adding a key does not add headroom.

StateRequests per minuteConcurrent requests
Below the threshold704
At or above it152

Crossing the threshold never switches your subscription off.
Your access window keeps running exactly as paid for. Only the pace changes, and it recovers on its own as the window rolls forward. The weekly figure is a measurement, not a balance you spend down.

When a request is refused for pace, the response is 429 and still carries the headers above, so your client can read the current limit and wait the right amount of time.

Reliability

Every error is typed

Non-success responses always use one shape, so a single error handler covers the whole API:

Error envelope
{
  "error": {
    "code": "provider_not_configured",
    "message": "No provider route can serve this model yet."
  }
}

Branch on error.code, never on the message text — messages are written for humans and may be reworded.

CodeStatusWhat to do
unauthenticated401The key is missing, revoked or wrong. Check the header, then the dashboard.
subscription_required402No active access window. Buy or extend a plan. Access begins only after the payment webhook confirms it.
forbidden403The account is suspended or banned. Contact support.
validation_failed422The request is well-formed but asks for something this route cannot do — an unsupported capability, for example.
client_closed_request499Your client disconnected before the answer finished. Nothing to retry; the upstream call was cancelled too.
invalid_request400The body could not be parsed as JSON, or an unknown field was sent.
model_not_found404That model id is not in the catalog. Re-read /api/v1/models.
payload_too_large413The request body exceeded the size bound. Shorten the conversation.
rate_limit_exceeded429Fair-usage pace. Read the headers and wait.
concurrency_limit_exceeded429Too many requests in flight. Queue them.
provider_not_configured503The operator has not finished provisioning this route. Not something you can fix; report it.
provider_unavailable502The upstream failed. Safe to retry with backoff.
upstream_timeout504The upstream took too long. Safe to retry; consider streaming for long answers.
internal_error500An unexpected fault. Retry once, then report it with the request id.

Retry only what is safe to retry.
502, 504 and 500 are worth retrying with exponential backoff. 401, 403, 404 and 400 will not fix themselves — retrying them just burns your rate allowance.

Other endpoints

Public endpoints you can call

These need no key. They are what a pricing page or a status banner is built from.

EndpointReturns
GET /api/v1/modelsThe model catalog: ids, providers, context windows, capability flags and status.
GET /api/v1/plansThe plan catalog: id, name, duration in hours, price in IDR and the key ceiling.
GET /api/v1/statusObserved health of the service, its database, provider routing, sign-in, billing and mail.
GET /healthzLiveness — the process is serving.
GET /readyzReadiness — the database answers and the plan catalog is present.

The status endpoint reports what it measured just now. It publishes no uptime percentage and no SLA, because a figure that is promised rather than measured is worth less than no figure at all.

Production practices

Before you ship

  • Keep the key on the server. A key in a browser bundle or a mobile app is a public key. Proxy through your own backend instead.
  • Handle 429 with backoff, not retries in a tight loop. Read X-Oproute-RateLimit-RPM and pace against it.
  • Set a timeout on every call. Long generations belong on stream: true, where you get tokens as they arrive instead of waiting for the whole answer.
  • Check finish_reason. A length stop means the answer was truncated — raise max_tokens or ask for something shorter.
  • Log X-Oproute-Request-ID. It turns "something failed yesterday" into a question that can actually be answered.
  • Rotate keys per environment. Separate keys for staging and production mean you can revoke one without touching the other.

Unlimited is a subscription entitlement. Usage analytics are informational, not token balances. Requests remain subject to fair usage, upstream availability and service protections.