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.
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.
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.
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.
One endpoint carries your chat traffic
POST https://oproute.web.id/v1/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.
What you may send
Requests are validated before anything reaches an upstream provider. Unknown fields are rejected rather than ignored.
| Field | Type | Required | Notes |
|---|---|---|---|
model | string | yes | An id from GET /api/v1/models that is currently available. |
messages | array | yes | Conversation turns, oldest first. At least 1, at most 200. Each has role and content. |
stream | boolean | no | Default false. true switches to server-sent events. |
temperature | number | no | Sampling randomness, 0 to 2 inclusive. Anything outside is rejected. |
top_p | number | no | Nucleus sampling, 0 to 1 inclusive. |
max_tokens | integer | no | Must be positive, and at most the service ceiling of 32,768. |
stop | array of strings | no | At most 4 sequences. |
tools / tool_choice | array / object | no | Only 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.
| Bound | Value | If exceeded |
|---|---|---|
| Messages per request | 200 maximum | 400 invalid_request |
| Total message content | 1 MiB (1,048,576 bytes) | 413 payload_too_large |
| Request body | 1 MiB | 413 payload_too_large |
max_tokens | 32,768 maximum | 400 invalid_request |
temperature | 0 to 2 | 400 invalid_request |
top_p | 0 to 1 | 400 invalid_request |
stop sequences | 4 maximum | 400 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.
What comes back
A non-streaming request returns an OpenAI-compatible completion object. The fields you will actually read are:
| Path | Meaning |
|---|---|
choices[0].message.content | The model's reply as text. |
choices[0].finish_reason | Why generation stopped — stop, length, or a tool-related reason. |
choices[0].message.tool_calls | Present when the model asked for a tool instead of answering directly. |
usage.prompt_tokens / usage.completion_tokens | Token counts for this request. Informational. |
model | The 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.
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 -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
}'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.
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.
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 {
"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 catalogRead 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.
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| Header | Meaning |
|---|---|
X-Oproute-Request-ID | Correlation id for this request. Quote it in any support message — it is the fastest way to find what happened. |
X-Oproute-FUP-Limit | The account-wide weekly processed-token threshold. |
X-Oproute-FUP-Used | Processed tokens recorded for the account when this request was admitted. |
X-Oproute-FUP-Reset | When the rolling window rolls over. |
X-Oproute-RateLimit-RPM | Requests per minute currently allowed for the account. |
X-Oproute-Concurrency-Limit | Concurrent requests currently allowed. |
These appear on throttled responses too, which is what lets a client back off correctly instead of retrying blind.
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.
| State | Requests per minute | Concurrent requests |
|---|---|---|
| Below the threshold | 70 | 4 |
| At or above it | 15 | 2 |
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.
Every error is typed
Non-success responses always use one shape, so a single error handler covers the whole API:
{
"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.
| Code | Status | What to do |
|---|---|---|
unauthenticated | 401 | The key is missing, revoked or wrong. Check the header, then the dashboard. |
subscription_required | 402 | No active access window. Buy or extend a plan. Access begins only after the payment webhook confirms it. |
forbidden | 403 | The account is suspended or banned. Contact support. |
validation_failed | 422 | The request is well-formed but asks for something this route cannot do — an unsupported capability, for example. |
client_closed_request | 499 | Your client disconnected before the answer finished. Nothing to retry; the upstream call was cancelled too. |
invalid_request | 400 | The body could not be parsed as JSON, or an unknown field was sent. |
model_not_found | 404 | That model id is not in the catalog. Re-read /api/v1/models. |
payload_too_large | 413 | The request body exceeded the size bound. Shorten the conversation. |
rate_limit_exceeded | 429 | Fair-usage pace. Read the headers and wait. |
concurrency_limit_exceeded | 429 | Too many requests in flight. Queue them. |
provider_not_configured | 503 | The operator has not finished provisioning this route. Not something you can fix; report it. |
provider_unavailable | 502 | The upstream failed. Safe to retry with backoff. |
upstream_timeout | 504 | The upstream took too long. Safe to retry; consider streaming for long answers. |
internal_error | 500 | An 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.
Public endpoints you can call
These need no key. They are what a pricing page or a status banner is built from.
| Endpoint | Returns |
|---|---|
GET /api/v1/models | The model catalog: ids, providers, context windows, capability flags and status. |
GET /api/v1/plans | The plan catalog: id, name, duration in hours, price in IDR and the key ceiling. |
GET /api/v1/status | Observed health of the service, its database, provider routing, sign-in, billing and mail. |
GET /healthz | Liveness — the process is serving. |
GET /readyz | Readiness — 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.
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
429with backoff, not retries in a tight loop. ReadX-Oproute-RateLimit-RPMand 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. Alengthstop means the answer was truncated — raisemax_tokensor 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.