API reference
One OpenAI-compatible API for every text, image and video model, paid in crypto on Robinhood Chain. Base URL:
https://omnirail.org/api/v1
Quickstart
- Humans: open the dashboard, connect a wallet, top up with USDG or ETH (or $RAIL), create a key.
- Agents: read /agents.md. It installs the MCP server and picks a payment mode.
- No account at all: pay each call with x402.
- No code at all: try any model in the Playground, signed in with your wallet. Every run is a real call, billed at the same price as the API.
curl https://omnirail.org/api/v1/chat/completions \
-H "Authorization: Bearer $OMNIRAIL_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"anthropic/claude-opus-5.5","messages":[{"role":"user","content":"Hello"}],"max_tokens":300}'Authentication
Send Authorization: Bearer orl_…. Keys belong to the wallet that created them, are shown once, stored hashed, and can carry a cumulative spend limit, plus a daily and a monthly one (UTC calendar, editable anytime). Revoking is immediate. Without a key, paid endpoints answer 402 with an x402 price when x402 is enabled, otherwise 401. The dashboard's Usage tab shows what each key, each model and each day cost, and exports the requests as CSV.
Scoped keys
Hand an agent a key that can only do what it should. On the dashboard (create or edit a key, Restrictions), or with scopes on POST /api/account/keys and PATCH /api/account/keys/:id (signed-in session), a key can be limited to:
| Field | Meaning |
|---|---|
modalities | Any of text (chat), image (generations and edits), video, voice (speech and transcription), embeddings, rerank, decisions (decision models such as Jev), autopilot (jobs), trade (market data, quotes, submit and status; the tokens and amounts stay in the key's trade limits). Default: all. |
models | Up to 50 exact ids or prefix patterns ending in *: anthropic/*, openai/gpt-5*. Case-insensitive. Default: any model. |
max_output_price | Text models only: refuse models whose list output price is above this many USD per 1M tokens (provider price plus the list markup, so it does not move with the $RAIL pool). |
expires_at | ISO 8601 date-time with a time zone (UTC recommended). From that instant on the key answers 401 key_expired. |
ips | Up to 50 IPv4/IPv6 addresses or CIDR ranges. Calls from anywhere else are refused. The address is the one our edge sees (on Vercel it is set by the platform and cannot be forged with headers). |
Absent fields keep their value on PATCH; null removes a restriction and scopes: null removes them all. A call outside the scopes is refused before anything is reserved or charged, with 403 and code key_scope naming the restriction. Autopilot: a key without autopilot cannot create jobs; a job started by a scoped key is planned and run inside the same scopes (its planner call excepted), so a text-and-image key never gets a video through a job. GET /models?scoped=1 with the key lists only what it may use, and GET /balance returns the key's own scopes. The Playground and x402 calls have no key and are not scoped.
Body of a key creation (the dashboard sends it for you):
{ "name": "research-agent",
"scopes": { "modalities": ["text"], "models": ["anthropic/*", "openai/gpt-5*"], "expires_at": "2026-12-31T23:59:59Z" } }The same key then gets, for an image call or another model:
HTTP/1.1 403
{ "error": { "message": "This API key is restricted to text and may not use image.", "type": "permission_error", "code": "key_scope" } }Pricing & holds
You pay the list price of the model for the usage the provider reports; while the $RAIL pool is funded every model is billed about 7% below what the provider itself charges (their credit fees included). $RAIL trades on Pons and is listed on CoinGecko; you never need it to use the API. Before a call runs we reserve its worst case (input estimate + max_tokens, or the image/video estimate). After the call we charge the real cost and release the rest. In the rare case the provider bills more than the estimate (a price change mid-call, extra searches), the difference comes out of your free balance, never below zero.
- If you omit
max_tokens, the gateway sets it to the largest completion your balance covers (up to 8192). - Provider errors, filtered images and failed videos cost nothing.
- A non-streamed call still running after 5 minutes is cut and charged its reserved maximum, because the provider may already have billed it. Use
stream: truefor long generations: a stream stopped early is charged only for what was generated. - Every JSON response has
usage.cost(USD charged) andusage.promo_discount. Headers:X-OmniRail-Charge,X-Request-Id.
Chat completions
POST /chat/completions, OpenAI format. Supports stream, tools, tool_choice, response_format, reasoning, images in messages, and the usual sampling parameters. Not supported: n > 1, plugins, model fallback lists.
import OpenAI from "openai";
const ai = new OpenAI({ baseURL: "https://omnirail.org/api/v1", apiKey: process.env.OMNIRAIL_KEY });
const stream = await ai.chat.completions.create({
model: "google/gemini-3.8-flash",
messages: [{ role: "user", content: "Summarise this repo" }],
stream: true,
});
for await (const chunk of stream) process.stdout.write(chunk.choices[0]?.delta?.content ?? "");In streams, the final chunk carries usage with the amount charged.
Auto routing
Set model to "auto" and OmniRail picks the model per request from the live catalog. The pick is deterministic and free: no extra model call, no routing fee; you pay the list price of the model that answered. Works on /chat/completions (stream or not), the MCP chat tool and the Playground, and as the model of /messages (the X-OmniRail-Model header names the model that ran). How it chooses.
| Model | Picks |
|---|---|
auto | Reads the request: short chat goes to a good-value model, long prompts, code, tools or many turns go to a stronger one. Cheapest capable model at that level. |
auto:cheap | The cheapest capable model for this request, never below quality tier 2. |
auto:fast | Low-latency model families first (Flash, Haiku, mini…), then the cheapest of them. |
auto:best | The highest quality tier that can serve the request, then the cheapest model in it. |
Only models that can serve the request are considered: images need vision, tools need tool calling, response_format needs JSON or structured outputs, PDFs read natively need file input, audio needs audio input, and the context must hold the prompt plus max_tokens. Previews, aliases and classifiers are never picked, and a scoped API key only routes among the models it may use (403 key_scope when none of them can serve the request). Optional route narrows the choice:
| Field | Notes |
|---|---|
max_price_per_mtok | USD per 1M tokens; the higher of the model's input and output list prices must fit. |
allow | Up to 20 id patterns (up to 4 * each, matching anything): ["anthropic/*", "google/gemini-*"]. |
deny | Up to 20 id patterns, applied after allow. |
curl https://omnirail.org/api/v1/chat/completions -H "Authorization: Bearer $OMNIRAIL_KEY" -H "Content-Type: application/json" \
-d '{"model":"auto:cheap","messages":[{"role":"user","content":"Summarise: …"}],
"max_tokens":400,"route":{"max_price_per_mtok":5,"deny":["x-ai/*"]}}'The response's model is the model that ran, the X-OmniRail-Routed-Model header repeats it, and a routing object (in the JSON body, or in the first chunk of a stream) explains the pick: requested, chosen, reason, the top 3 candidates with their prices, and fallbacks. OpenAI SDKs ignore the extra field.
If the chosen model fails before producing anything (provider error, rate limit, provider timeout, no endpoint), the next candidate runs (up to 2 more models); a stream switches only before its first byte. Failed attempts are free and their holds are released before the next one is reserved. When nothing fits, the call answers 400 no_route with the reason.
Uncensored models
A curated group of open chat models that refuse less often than mainstream assistants: they keep a villain in character, give a critique without cushioning, and answer frank questions on health, law or history without a lecture. They are ordinary text models: same POST /chat/completions, keys, scopes, holds, limits, x402 and MCP chat tool, billed per token at their listed price. GET /models?kind=uncensored lists the ones currently served (each carries "category": "uncensored"); a model the provider stops serving simply drops out of the list.
| Model id | What it is |
|---|---|
cognitivecomputations/dolphin-mistral-24b-venice-edition | Dolphin on Mistral Small 24B, made with Venice.ai to follow your system prompt rather than a vendor's. |
nousresearch/hermes-4-405b | Nous Research's hybrid-reasoning Llama 3.1 405B, trained to be steerable and neutral. |
thedrummer/cydonia-24b-v4.1 | TheDrummer's uncensored creative-writing model on Mistral Small 3.2 24B. |
thedrummer/skyfall-36b-v2 | TheDrummer's upscaled Mistral Small for long-form fiction and storytelling. |
thedrummer/unslopnemo-12b | A 12B Mistral Nemo finetune for adventure writing, with less of the usual assistant phrasing. |
sao10k/l3.1-euryale-70b | Sao10K's Llama 3.1 70B finetune for creative writing and roleplay. |
anthracite-org/magnum-v4-72b | Anthracite's Qwen2.5 72B finetune for rich, natural prose. |
sao10k/l3-lunaris-8b | A small Llama 3 8B merge for fiction and general chat; the cheapest of the list (8K context). |
curl https://omnirail.org/api/v1/chat/completions -H "Authorization: Bearer $OMNIRAIL_KEY" -H "Content-Type: application/json" \
-d '{ "model": "cognitivecomputations/dolphin-mistral-24b-venice-edition", "max_tokens": 300,
"messages": [{ "role": "user", "content": "Write the cold speech of a cult leader to a captured journalist, under 110 words." }] }'- Fewer refusals is not no rules. The usage policy applies to every call on every model: nothing illegal, nothing sexual involving minors, no harassment or threats against real people, no malware or weapons meant to hurt anyone. Providers may still refuse or filter some requests, and the base model's own license terms (for example Meta's Llama acceptable use policy) still apply.
- Text only. These are language models: no image or video generation comes with this category.
- Quality varies. Several are creative-writing finetunes, strong at prose and weaker at code or math than the frontier models; pick by the task.
Anthropic-compatible API
POST /messages speaks the Anthropic Messages API, so the Anthropic SDKs and Claude Code run on any text model in the catalog: Claude, GPT, Gemini, DeepSeek, Qwen and the rest. Calls are billed exactly like chat completions (same hold, markup, $RAIL discount, key limits and activity log). Send your key as x-api-key or Authorization: Bearer. model is any catalog id (openai/gpt-5.6-sol) or a Claude id such as claude-sonnet-5-5 or claude-haiku-4-5-20251001, mapped to the closest anthropic/… model; the X-OmniRail-Model header says which one ran.
Claude Code, pointed at OmniRail with two variables, then on any model:
export ANTHROPIC_BASE_URL=https://omnirail.org/api export ANTHROPIC_AUTH_TOKEN=orl_YOUR_KEY claude # Claude ids map to the catalog's Claude models claude --model openai/gpt-5.6-sol # or: export ANTHROPIC_MODEL=openai/gpt-5.6-sol # Optional: background tasks (titles, summaries) use the small model, route them too, # and cap the output reserved per call (smaller holds) export ANTHROPIC_DEFAULT_HAIKU_MODEL=google/gemini-3.8-flash export CLAUDE_CODE_MAX_OUTPUT_TOKENS=16000
SDKs: set the base URL to https://omnirail.org/api (the SDK adds /v1/messages).
# Python
import os
from anthropic import Anthropic
client = Anthropic(base_url="https://omnirail.org/api", api_key=os.environ["OMNIRAIL_KEY"])
msg = client.messages.create(model="openai/gpt-5.6-sol", max_tokens=1024,
messages=[{"role": "user", "content": "Hello"}])
print(msg.content[0].text, msg.usage)
// TypeScript
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({ baseURL: "https://omnirail.org/api", apiKey: process.env.OMNIRAIL_KEY });
const stream = client.messages.stream({ model: "deepseek/deepseek-v4.1-flash", max_tokens: 1024,
messages: [{ role: "user", content: "Write a haiku about rails" }] });
stream.on("text", (t) => process.stdout.write(t));
console.log((await stream.finalMessage()).usage);- Supported:
system, text, image and document blocks (PDF as base64 or URL, plain text),toolsandtool_choice,tool_use/tool_resultturns,stream(the standard event sequence),stop_sequences,temperature,top_p,top_k,thinkingandoutput_config.effort(mapped to reasoning on models that support it),output_config.format(JSON schema). Theweb_searchserver tool runs as OmniRail web grounding. - Ignored:
cache_control(thecache_*usage fields are 0),metadata,context_management,anthropic-beta. Thinking blocks from earlier turns are dropped, as their signatures do not carry across models.max_tokensis capped to the model's output limit. - Not supported: server and Anthropic-defined tools (code execution, web fetch, bash, text editor, computer use),
file_idsources,mcp_servers, batches. usageaddscost(USD charged) andpromo_discount. Headers:request-id,X-OmniRail-Charge,X-OmniRail-Model. Errors use Anthropic's envelope{ type: "error", error: { type, message } }; a balance or key limit too low for the hold is402 billing_error.POST /messages/count_tokensreturns{ input_tokens }for free. It is a conservative estimate (about 3 characters per token), not the provider's tokenizer.
Images & edits
POST /images (alias /images/generations) generates; POST /images/edits requires at least one source image. Images come back as b64_json.
| Field | Notes |
|---|---|
model, prompt | Required. Pick an image model from /models?kind=image. |
n | 1 to 4, capped by the model. |
aspect_ratio, resolution, quality, background, output_format, seed | Validated against what the model supports; unsupported values return 400 with the allowed list. |
input_references / image | http(s) or data:image/… URLs, up to the model's limit. |
curl https://omnirail.org/api/v1/images/edits -H "Authorization: Bearer $OMNIRAIL_KEY" -H "Content-Type: application/json" \
-d '{"model":"openai/gpt-image-2","prompt":"make the background lime","image":"https://example.com/logo.png"}'Videos
Asynchronous. POST /videos returns 202 with id, token and poll_url. Poll GET /videos/{id} (with your key, or ?token=) every 30 seconds or so. When status is completed the real cost is charged and content_urls stream the MP4. Failed jobs are not charged.
{ "model": "google/veo-3.1-fast", "prompt": "a lime switchboard, slow dolly in",
"duration": 4, "resolution": "720p", "aspect_ratio": "16:9", "generate_audio": false,
"first_frame": "https://…/frame.png" }Video edit. Models listed with video_input: true accept source videos: pass up to two public http(s) URLs in input_videos with an edit prompt. Other models answer 400 unsupported_parameter.
{ "model": "bytedance/seedance-2.0-fast", "prompt": "make it night, neon reflections on the street",
"duration": 5, "input_videos": ["https://…/clip.mp4"] }Voice
POST /audio/speech is OpenAI-compatible text-to-speech: send model, input (up to 20,000 characters), an optional provider voice, response_format mp3 (default) or pcm, and the response is the audio bytes. Without voice, the model's first listed voice is used (the X-OmniRail-Voice response header names it); GET /models?kind=tts lists each model's voices. Add ?response=json to get { audio_b64, media_type, usage } instead. Billed per character.
curl https://omnirail.org/api/v1/audio/speech -H "Authorization: Bearer $OMNIRAIL_KEY" -H "Content-Type: application/json" -d '{"model":"hexgrad/kokoro-82m","input":"Every model, one key.","voice":"af_heart"}' --output hello.mp3POST /audio/transcriptions is speech-to-text. Send OpenAI-style multipart (file + model) or JSON with input_audio: { data, format } (raw base64, format wav/mp3/flac/m4a/ogg/webm/aac/opus) or input_audio: { url }. Optional language, temperature, response_format: "verbose_json" with timestamp_granularities for segments and words. Up to 25 MB. Billed per second of audio.
curl https://omnirail.org/api/v1/audio/transcriptions -H "Authorization: Bearer $OMNIRAIL_KEY" -F model=openai/whisper-large-v3-turbo -F file=@meeting.mp3
The same two endpoints power voice in the web Chat: dictation, hands-free voice mode with any text model (the answer is spoken sentence by sentence while it streams) and Read aloud. Each transcription and spoken chunk is billed to your balance at these prices, and the voice-turn receipt adds them to the answer's cost.
Embeddings & rerank
POST /embeddings is OpenAI-compatible: model and input (a string, up to 512 strings, or content blocks with image_url parts on multimodal models such as Gemini Embedding or Voyage Multimodal). Optional dimensions, encoding_format. Billed per input token. POST /rerank takes model, query, documents (up to 1,000), optional top_n and return_documents, and returns results[{ index, relevance_score }] sorted by relevance. MCP: embed, rerank.
curl https://omnirail.org/api/v1/embeddings -H "Authorization: Bearer $OMNIRAIL_KEY" -H "Content-Type: application/json" \
-d '{"model":"voyageai/voyage-4-lite","input":["brass lever espresso machine","coffee grinder"]}'
curl https://omnirail.org/api/v1/rerank -H "Authorization: Bearer $OMNIRAIL_KEY" -H "Content-Type: application/json" \
-d '{"model":"cohere/rerank-4-fast","query":"best espresso machine","documents":["brass lever espresso machine","a bicycle","coffee grinder"],"top_n":2}'Decisions (Jev)
Decision models answer typed questions instead of writing text. Jev, by TypeSafe, takes a state (what is going on: a string, a JSON object, or an array of strings and image parts) and questions keyed by ids you choose, and returns one typed answer per question with probabilities. Use it where your agent would otherwise ask a chat model a narrow question and parse a label out of the reply: gate a tool call, triage a ticket, classify, check a claim. It returns no explanation; when the probability is close to your threshold, ask a human or a chat model.
POST /decisions takes OpenRouter's Decisions body (model, state, questions, nothing else) and returns { id, model, provider, answers, usage }. Models: typesafe/jev-1.14, ~typesafe/jev-latest (follows the newest release) and typesafe/jev-1.13 (GET /models?kind=decision). The model in the answer names the dated snapshot that served it.
type | criteria | Answer |
|---|---|---|
noul | Optional { "true": …, "false": … } | noul: the probability that the answer is yes (0 to 1) |
choice | { option: description | null }, 2 to 255 options | choice, probabilities per option, confidence |
score | Ordered levels, lowest first, 2 to 10 | score (a position, 0 = first level, can fall between two), probabilities and legend per level, confidence |
box | None: instructions describes the target | box: { top, bottom, left, right } as fractions of the image, confidence. Jev 1.14 and jev-latest only, needs an image in state; it always returns a box, so ask a noul whether the target is there. |
- Up to 64 questions per call, answered in parallel: ask everything about the same state at once.
instructionsand criteria can be strings, JSON objects or arrays. - Images:
{ "type": "image_url", "image_url": { "url": "data:image/png;base64,…" } }parts, PNG or JPEG data URLs only (remote URLs are not fetched), up to 16 per call, within the 4 MB request body. Jev wants 256 to 1,568 pixels per side and does not apply EXIF rotation. - Context: 262,144 tokens of state and questions. Requests with more than about 32 KB of text, or with any image, run in TypeSafe's long-context mode, which TypeSafe describes as slower and less accurate.
- Billing: per input token at the model's price, the answers are free. The worst case is reserved from the request size, then you pay what the provider reports; refusals and provider errors cost nothing. A call the provider has not answered after 90 seconds is cut and charged its reserved maximum (a fraction of a cent), since the provider may have billed it.
usage.costis what you paid. - Probabilities are not certainties and shift a little between runs and versions: pick thresholds on your own labelled examples, and pin a version (
typesafe/jev-1.14) when you rely on them. - Scoped keys: modality
decisions. MCP:decide.
A real call (made on 2026-10-19): should the agent run rm -rf ~/ when the user asked to clean the build artifacts?
curl https://omnirail.org/api/v1/decisions -H "Authorization: Bearer $OMNIRAIL_KEY" -H "Content-Type: application/json" \
-d '{
"model": "typesafe/jev-1.14",
"state": {
"user_request": "Clean up the build artifacts in this repo.",
"proposed_tool_call": {
"tool": "bash",
"command": "rm -rf ~/"
}
},
"questions": {
"allowed": {
"type": "noul",
"instructions": "Does the proposed tool call do only what the user asked for, with nothing destructive beyond it?",
"criteria": {
"true": "The call stays inside the user'\''s request and touches nothing else.",
"false": "The call deletes, overwrites or exposes something the user did not ask about."
}
},
"action": {
"type": "choice",
"instructions": "What should the agent'\''s harness do with this tool call?",
"criteria": {
"run": "Safe and clearly within the request: run it without asking.",
"ask": "Probably fine but worth a human glance before it runs.",
"block": "Outside the request or not undoable: refuse it."
}
}
}
}'{
"model": "typesafe/jev-1.14-20261014",
"provider": "TypeSafe",
"answers": {
"allowed": {
"type": "noul",
"noul": 0.01
},
"action": {
"type": "choice",
"choice": "block",
"probabilities": {
"run": 0,
"ask": 0,
"block": 1
},
"confidence": 1
}
},
"usage": {
"input_tokens": 470,
"output_tokens": 54,
"cost": 0.00002102
}
}The cost above is the list price of that call; while the $RAIL pool is funded you pay less.
Web search & documents
Any chat model can be grounded with live web results: add web_search: true (or { max_results, engine: "native" | "exa" }) to a chat request. Sources come back as url_citation entries in message.annotations; about $0.01 per request on top of the tokens. PDFs go in the message content as { type: "file", file: { filename, file_data } } (a public URL or a data:application/pdf;base64,… URL, up to 4 MB inline), images as image_url parts. pdf_engine picks how the file is read: native (the model reads it), text (free extraction) or ocr (scans, $2 per 1,000 pages). MCP: chat with web, files, images.
curl https://omnirail.org/api/v1/chat/completions -H "Authorization: Bearer $OMNIRAIL_KEY" -H "Content-Type: application/json" -d '{"model":"anthropic/claude-sonnet-5.5","web_search":true,"messages":[{"role":"user","content":[
{"type":"text","text":"Compare this paper with the latest results on the topic."},
{"type":"file","file":{"filename":"paper.pdf","file_data":"https://arxiv.org/pdf/2501.00001"}}]}]}'Autopilot
One brief in, a finished deliverable out. POST /jobs with prompt (and an optional budget_usd, default 2) answers 202 immediately: OmniRail plans the steps (copy, images, edits, a short video, voice-over), picks a model for each from the live catalog, runs independent steps in parallel and bills each one at its real price, never past the budget. Poll GET /jobs/{id} (your key, or the token from the 202) every ~20 seconds; every poll also pushes unfinished steps forward. The response lists each step with its status, cost, text, asset_url (images, audio) or content_url (video). MCP: build and get_job.
curl https://omnirail.org/api/v1/jobs -H "Authorization: Bearer $OMNIRAIL_KEY" -H "Content-Type: application/json" -d '{"prompt":"Launch page for my brass lever espresso machine: three product lines, a hero image, a matte black variant, a 6-second video and a voice-over of the lines.","budget_usd":2}'
# → 202 { "id": "job_…", "status": "planning", "poll_url": "…", "token": "…" }
curl "https://omnirail.org/api/v1/jobs/job_…" -H "Authorization: Bearer $OMNIRAIL_KEY"Webhooks
Stop polling. Add webhook_url to POST /videos or POST /jobs (MCP: the webhook_url argument of generate_video and build) and OmniRail POSTs the final status to that URL once the job ends. The URL must be https on port 443 or 1024–65535, without credentials, and its host must resolve only to public addresses; it is checked when you submit (400 invalid_webhook_url, nothing is charged) and again before every delivery, and the connection goes to the address that was checked. Redirects are not followed.
| Event | When |
|---|---|
video.completed / video.failed | The video settled (real cost charged) or failed (hold released, nothing charged). |
job.completed / job.failed | The Autopilot job finished with at least one step done, or with none. |
trade.completed / trade.failed | A swap submitted with webhook_url on POST /trade/submit was confirmed or failed on-chain (see Trade); data is what GET /trade/{id} returns. |
Each job fires exactly one event. data is the same object GET /videos/{id} or GET /jobs/{id} returns, including the tokenised content_urls, asset_url and poll_url, so the receiver can download the results without an API key.
{
"id": "evt_…",
"type": "video.completed",
"created": 1760000000,
"data": {
"id": "req_…",
"object": "video",
"model": "google/veo-3.1-fast",
"status": "completed",
"cost": 0.4,
"content_urls": ["https://omnirail.org/api/v1/videos/req_…/content?index=0&token=…"],
"poll_url": "https://omnirail.org/api/v1/videos/req_…?token=…",
"created_at": 1759999900
}
}Signature
Deliveries follow Standard Webhooks, so its libraries work too. Headers: webhook-id (the event id, the same on every retry: de-duplicate on it), webhook-timestamp (unix seconds) and webhook-signature: v1, + base64 HMAC-SHA256 of ${webhook-id}.${webhook-timestamp}.${body}, keyed with the base64 part of your whsec_… secret. Verify the raw body before parsing it, compare in constant time and reject timestamps more than 5 minutes away.
Your account has one signing secret. It is created on first use and shown once: as webhook_secret in the 202 that created it, or with Reveal in the dashboard if that response never showed it. A scoped key (any restriction: modalities, models, price, IPs or expiry) or a key with any limit (spend, daily, monthly or trade limits) never receives the secret, because it authenticates every callback of the account: submit with it and the owner reveals the secret in the dashboard. Lost it? Rotate: you get a new one, and for 24 hours every delivery carries both signatures (space-separated), so you can switch receivers without dropping events. The dashboard also sends test events and lists recent deliveries with their payload, status and a redeliver button.
import crypto from "node:crypto";
// rawBody: the exact request body as a string (verify before JSON.parse).
export function verifyOmniRail(secret, headers, rawBody) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
const sig = headers["webhook-signature"];
if (!id || !ts || !sig || !/^\d+$/.test(ts)) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // 5-minute tolerance
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto.createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest();
// Several "v1,<base64>" values are sent while a rotated secret is still valid.
return sig.split(" ").some((part) => {
const [version, b64] = part.split(",");
const got = Buffer.from(b64 ?? "", "base64");
return version === "v1" && got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
}import base64, hashlib, hmac, time
def verify_omnirail(secret: str, headers, raw_body: bytes) -> bool:
msg_id = headers.get("webhook-id")
ts = headers.get("webhook-timestamp")
sig = headers.get("webhook-signature")
if not (msg_id and ts and sig) or not ts.isdigit():
return False
if abs(time.time() - int(ts)) > 300: # 5-minute tolerance
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
digest = hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256).digest()
expected = base64.b64encode(digest).decode()
# Several "v1,<base64>" values are sent while a rotated secret is still valid.
return any(
hmac.compare_digest(part.split(",", 1)[1], expected)
for part in sig.split(" ")
if part.startswith("v1,")
)Retries and timing
- Delivered = a
2xxanswer within 10 seconds (only the first 4 KB of your response are read). Anything else is retried about 30 s, 2 min, 10 min, 1 h, 6 h, 12 h and 24 h later (±20% jitter): 8 attempts over about 43 hours. A410 Gonestops the retries. - Delivery is at least once: the same
webhook-idcan arrive twice. Answer fast and do the work after. - Videos are watched server-side for about 4.5 minutes after the submit and Autopilot jobs run for about 4 minutes, so most events leave within about 10 seconds of the job finishing. Work that runs longer, and retries, are driven by background sweeps that follow the API's traffic (at most every 30 seconds), with a pass at least every 30 minutes and a daily reconciliation as the backstop, so they can take longer. Polling
GETstill works and also triggers the event.
curl https://omnirail.org/api/v1/videos -H "Authorization: Bearer $OMNIRAIL_KEY" -H "Content-Type: application/json" -d '{"model":"google/veo-3.1-fast","prompt":"a lime switchboard, slow dolly in","duration":4,
"webhook_url":"https://example.com/hooks/omnirail"}'
# → 202 { "id": "req_…", "status": "pending", "webhook_url": "https://example.com/hooks/omnirail",
# "webhook_secret": "whsec_…" } (webhook_secret only the first time, never for a scoped key or a key with limits)Models & balance
GET /models is public: ?kind=text|image|video|tts|stt, ?q=, ?limit=. Prices are what you pay (?kind=embedding|rerank|decision|uncensored too). GET /balance returns the key's wallet balance, holds and live markup. GET https://omnirail.org/api/status lists the chain, payment asset, treasury and whether x402 is on.
x402 payments
Every paid endpoint speaks x402 v2. Call it without a key: the response is 402 with a PAYMENT-REQUIRED header asking for USDG on Robinhood Chain. Sign it with any x402 v2 client and retry with PAYMENT-SIGNATURE. We verify the EIP-3009 authorization, settle it on-chain ourselves (no third-party facilitator, no gas for you), then run the call. The unused part of the hold stays as balance on the paying wallet: sign in with that wallet on the dashboard to see or use it.
| Scheme | exact (EIP-3009 transferWithAuthorization) |
| Network | eip155:4663 |
| Asset | USDG 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168, 6 decimals |
| EIP-712 domain | name Global Dollar, version 1 |
Official clients only sign known assets by default, so allow USDG explicitly and cap each payment:
import { wrapFetchWithPayment, x402Client } from "@x402/fetch";
import { ExactEvmScheme } from "@x402/evm/exact/client";
import { privateKeyToAccount } from "viem/accounts";
const client = new x402Client()
.register("eip155:4663", new ExactEvmScheme(privateKeyToAccount(process.env.AGENT_KEY)))
.setSpendControls({ allowedAssets: [{ network: "eip155:4663", asset: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168", maxAmountPerPayment: "2000000" }] });
const pay = wrapFetchWithPayment(fetch, client);Buying credit in bulk
POST /credits?amount=5 charges exactly that many USDG through x402 and credits the paying wallet. The first time (no Authorization header) it also returns a new API key bound to that wallet; send the key on later top-ups to credit the same account. This is what the local MCP server does: fewer on-chain transactions, exact per-call metering.
MCP server
Tools: list_models, chat, generate_image, edit_image, generate_video, get_video, account_balance, decide (decision models).
Remote (Streamable HTTP, key)
claude mcp add --transport http omnirail https://omnirail.org/api/mcp --header "Authorization: Bearer orl_YOUR_KEY"
{
"mcpServers": {
"omnirail": {
"url": "https://omnirail.org/api/mcp",
"headers": {
"Authorization": "Bearer orl_YOUR_KEY"
}
}
}
}Local (npx, key or wallet)
Adds file paths: save generated images and videos with save_to, pass local images to edits. With OMNIRAIL_WALLET_KEY it buys credit itself via x402, capped by OMNIRAIL_TOPUP_USD (default 5) and OMNIRAIL_DAILY_BUDGET_USD (default 20). The minted key is kept in ~/.config/omnirail/ with owner-only permissions. Use a dedicated wallet.
claude mcp add omnirail -e OMNIRAIL_WALLET_KEY=0xAGENT_WALLET_PRIVATE_KEY -e OMNIRAIL_DAILY_BUDGET_USD=20 -- npx -y https://omnirail.org/downloads/omnirail-mcp.tgz
Agent Skill
The OmniRail Agent Skill teaches a coding agent how to use OmniRail by itself: check the connection, pick the tool or endpoint for a task, stay within budget, read what each call cost. It is a folder with a SKILL.md and short reference files, generated from the same facts as /agents.md. All install options: /skills.
Claude Code plugin (skill + MCP server)
Inside Claude Code (2.1.224 or later); it asks for your API key:
/plugin marketplace add https://omnirail.org/skills/marketplace.json /plugin install omnirail@omnirail
Personal skill
Claude Code reads ~/.claude/skills; Codex CLI, Cursor and Gemini CLI read ~/.agents/skills:
curl -fsSL https://omnirail.org/skills/omnirail.zip -o /tmp/omnirail-skill.zip && mkdir -p ~/.claude/skills && unzip -oq /tmp/omnirail-skill.zip -d ~/.claude/skills && rm /tmp/omnirail-skill.zip curl -fsSL https://omnirail.org/skills/omnirail.zip -o /tmp/omnirail-skill.zip && mkdir -p ~/.agents/skills && unzip -oq /tmp/omnirail-skill.zip -d ~/.agents/skills && rm /tmp/omnirail-skill.zip
Files: https://omnirail.org/skills/omnirail/SKILL.md, its references/, the folder as https://omnirail.org/skills/omnirail.zip and the plugin marketplace https://omnirail.org/skills/marketplace.json.
Wallet deposits
On the dashboard: connect, sign in (a signature, not a transaction), choose USDG or ETH and an amount. The site sends a plain transfer to the treasury and claims it automatically after 2 confirmations. USDG is credited 1:1; ETH at the market price of the block's minute. A transaction can only be claimed by the wallet that sent it, and only once. If the page closed mid-way, paste the transaction hash to claim it.
Pay with $RAIL
Choose $RAIL in Add credits to pay with the token. The $RAIL you send is valued in dollars on-chain, from the RAIL/ETH Uniswap v4 pool at your deposit's block: the lower of the spot price and the 30-minute average, after the price impact of selling that amount into the pool, converted with ETH/USD at that minute. Limits: $1 to $100 per deposit and $250 per wallet per UTC day. A deposit is refused, with nothing credited and the $RAIL kept safe for support to return or credit, when the spot price and the average disagree by more than 15%, when its price impact exceeds 5%, or when it is outside the limits (over the daily limit, claim it again after midnight UTC).
The live rate (public, no key, cached 15 s):
curl "https://omnirail.org/api/rail/quote?usd=25"
# → { "rail_amount": "…", "usd_per_rail": "…", "basis": "twap", "impact_bps": …,
# "bonus_bps": 0, "credited_usd": 25, "min_usd": …, "max_usd": …, "note": "…" }Android app & phone sign-in
The OmniRail Android app is the site itself, full screen (a Trusted Web Activity signed by us, verified through /.well-known/assetlinks.json): every model, your dashboard and the web apps, with the same account and prices. It is installed from /app (Google Play is coming); Chrome on Android can also install the site as a web app.
Phones rarely have a wallet extension, so any device can sign in with your computer instead: tap Get a sign-in code, open /link on a computer where you are signed in, enter the code, check the device and approve. The code works once, expires after 10 minutes and is useless without the secret only the requesting device holds. Wallet in-app browsers (MetaMask, Rabby) can still sign in with the wallet directly. API keys are unaffected: agents keep using Authorization: Bearer orl_….
Trade
Non-custodial token swaps on Robinhood Chain for agents (ETH, WETH, USDG, $RAIL, and a list of Pons launchpad tokens): POST /trade/quote returns the route, price, limits, fee, gas and the transactions the agent signs with its own wallet; POST /trade/submit checks the signed transaction against the quote and broadcasts it; GET /trade/{id} reports the result. Market data: GET /markets/tokens, /markets/price, /markets/candles, GET /wallets/{address}/balances. Keys can carry trade limits and a human-approval threshold. The agent pays its own gas; OmniRail never holds funds. Full guide: Trade. Curated Robinhood Stock Tokens are also supported for eligible holders, with a per-key eligibility attestation: Stock Tokens.
Errors & limits
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request, unsupported_parameter | Body or parameter rejected; the message says what is allowed. |
| 401 | invalid_api_key, key_expired | Missing, malformed or revoked key; or a scoped key past its expiry. |
| 403 | key_scope | A scoped key used outside its modalities, models, price cap or IP allowlist. Nothing was charged. |
| 402 | insufficient_balance or x402 challenge | Top up, lower max_tokens, or pay via x402. |
| 404 | not_found | Unknown model or job. |
| 409 | conflict, tx_confirming | Payment already used, deposit already credited, or still confirming. |
| 422 | rail_price_unstable, rail_liquidity, rail_below_minimum, rail_above_cap, rail_daily_cap | $RAIL deposit refused by the price or limit checks; nothing credited (see Pay with $RAIL). |
| 429 | rate_limited | 120 requests/minute per key (per IP without a key). Respect Retry-After. |
| 502/504 | upstream_error | The provider failed or timed out. The hold is released. |
Request bodies are limited to 4 MB (use image URLs rather than large data URLs). Streams and generations may run up to 5 minutes.
Stuck? The chat bubble at the bottom right of every page reaches a human: sign in with your wallet to write, and the answer arrives in the same bubble. Errors on the site carry a Contact support button that attaches the error for you. Keep the X-Request-Id of a failed API call: it lets us find it.
Updates are announced on X and on Telegram. We never message you first: anyone who contacts you in private in our name is not us.