It is the OpenAI API. If your client already speaks that, it already speaks this — change two lines and keep everything else. Two minutes from a key to a billed call.
Rather see it work first? Every model has a page with a playground that runs it on your balance, and shows the exact request it sent.
The base URL
…
Both forms work. There is nothing to get wrong here..../v1 and .../v1/chat/completions both resolve, because the gateway normalises the path rather than printing a warning in bold and hoping. If your SDK appends the route itself, leave it alone; if it does not, append it yourself.
If you set Loot up before September 2026, your config may name loot-api-production-2f1c.up.railway.app. It is the same gateway and keeps working — a base URL that stops resolving is a broken deploy for everybody who typed it into a config file.
Your first call
The key goes in the Authorization header as a bearer token. There is no other credential, no org id and no project id, because there is no account for those to belong to.
What a credit is.1 credit = $0.0001, so $10 buys 100,000 credits and gpt-5 costs 11,875 credits per million input tokens — against OpenAI’s own $1.25. Credits are what you hold and spend; every price on this site and every cost header carries the dollar figure beside them, because the only claim worth making here is that we are cheaper than the vendor and you should be able to check it in thirty seconds.
Charges are never rounded up to a whole credit. A small chat call costs 0.23 credits and is billed as 0.23. The ledger counts to the millionth of a dollar underneath, and /v1/me publishes usd_per_credit so nothing has to be assumed.
Every response tells you what it cost
Five headers on every call, and the shape is deliberately the one the incumbent proved rather than a third convention nobody parses.
x-loot-cost-usd
What this call cost, to the micro-dollar. Non-streaming only — a streamed call does not know its own cost until the last chunk.
x-loot-balance-usd
The balance at the start of the request, before this call settled. Not after, so two concurrent calls do not report contradictory numbers.
x-loot-ttft-ms
Time to first token, measured at the gateway.
x-loot-provider
Which upstream served it. One value today; the field exists so that a second one does not become a breaking change.
x-loot-request-id
The id this call carries in your ledger. Quote it and both entries can be found.
Streaming
Send "stream": true and tokens arrive as server-sent events, the same chunks the OpenAI SDKs already read. The charge travels in the body, not a header: headers are sent before the first token exists, so a streamed cost header would be a guess. The last chunk before [DONE] carries usage, and in it usage.cost in dollars. The gateway asks upstream for that usage on every stream whether or not you set stream_options.include_usage, because it is what the call settles against.
Python
stream = client.chat.completions.create(
model="deepseek-v4-flash",
messages=[{"role": "user", "content": "Write a haiku"}],
stream=True,
)
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="")
if chunk.usage:
print("cost $", chunk.usage.model_extra["cost"])
Stopping a stream does not stop the bill.
Closing the connection early does not cancel the generation upstream, and what the model produced is billed. If you only want a short answer, set max_tokens rather than disconnecting.
Images and video
Generation is a task, because a video can take minutes. Submitting answers at once with the task; you follow GET /v1/tasks/{id} until it finishes, and the finished task carries a link to the file. The same key, the same balance and the same base URL as chat.
What to make. It is checked against the content policy before anything is reserved, so a refused prompt costs nothing.
size
Images: the aspect ratio — 1:1, 16:9, 9:16, 4:3 or 3:4.
aspect_ratio
Video: the same ratios. Families name this field differently upstream, and the gateway translates it, so you always send this one.
resolution
The priced tier: 1K, 2K or 4K for images; 480p, 720p or 1080p for video. It decides the price, so a tier the model does not publish is refused rather than billed at its default. Case does not matter.
duration
Video, in seconds. A per-second model bills exactly this many; most take 4–15.
image_urls
Reference images, for models that take them. Some bill each reference on top of the generation; the model's page lists it.
queued, running
Still going. reserved_usd is what is held against your balance; nothing has been charged yet.
completed
output_url is the file and charged_usd is what it cost — exactly the price quoted before you submitted, because media is fixed-price.
failed, expired
Nothing was made and nothing is charged. The hold is released.
blocked
The finished file broke the content policy and was withheld. blocked_reason names the category, and the charge is our upstream cost with no margin.
Which fields a model takes, and what each tier costs, is on its page.The catalogue links every model to a page with every price row beside the maker's, the request its playground sends, and a Run button that shows the exact price before anything is spent.
Endpoints
Two credentials, for two audiences. The key authenticates the API — every OpenAI-compatible client sends Authorization: Bearer and nothing else, so asking a caller to sign per request would break the SDK, curl, and every tool worth naming. A wallet session authenticates the dashboard, where a person is looking at their own account. A session can mint a key and a key can spend; neither can ever show you a key you already hold.
GET /v1/health
No auth. Answers {"ok":true} and touches nothing. Live
POST /v1/chat/completions
Streaming and non-streaming. Live
POST /v1/embeddings
Synchronous. Live
GET /v1/models
The catalogue, with live prices. Models we cannot price are not in it. Each entry carries its family and kind (chat, embedding, image, video, music, other). Live
GET /v1/models/{id}
One model: what it is, its limits, every price we charge beside the maker's, and the fields its playground sends. No auth. Live
GET /v1/stats
Public usage counts, as the landing page shows them: requests served, tokens processed, images and videos generated, $ROB accepted, keys minted, wallets signed in. Recomputed at most once a minute. No auth. Live
POST /v1/keys
Issue a key. Empty body, no auth. The token is in that response and in no other, ever. Live
GET /v1/me
Balance, holds, available, spend, deposit code, bound wallet, key preview. Never the token. Live
GET /v1/me/usage
Ledger entries, newest first, paginated by before. Live
GET /v1/me/deposits
What has arrived on this key. Live
POST /v1/me/deposit-tx
A payment for your browser wallet to sign. Send asset (USDC or ROB), amount in whole tokens and payer, the address your wallet connected as; get back an unsigned transaction carrying a one-off reference. Pass the bytes to your wallet's signAndSendTransaction. We never sign it. Live
POST /v1/me/deposit-intent
A one-off Solana Pay link, for a phone. Returns a reference and a solana: URL for a QR — no memo to type. A desktop browser cannot open that URL; use deposit-tx there. Optional amount_usd or amount_rob only prefills the wallet; you are credited what arrives. Live
POST /v1/me/wallet
Claim a Solana address as a no-memo deposit fallback. This checks no signature — it records that nobody else had claimed it. Signing in proves it instead, and a proof displaces a claim. Live
The route is open, but no speech model publishes a price upstream, so there is nothing yet to call it with. No model yet
GET /v1/tasks/{id}
Poll a media job. Live
GET /v1/files/{id}
The generated file, hosted by us. No key required — see below. Live
DELETE /v1/tasks/{id}/output
Delete your own output before its window ends. Live
Signing in with a wallet
Optional, and nothing here is needed to call the API. It exists for the two things a bare key cannot do: hold several keys against one balance, and get a new key when you lose one. You are signing a sentence — it costs nothing, sends nothing, and authorises no transaction.
POST /v1/auth/nonce
Send address, get back the exact bytes to sign. The message names this domain, which is what stops a signature farmed by another site working here. Live
POST /v1/auth/verify
Send address, nonce and signature; get a session token. The nonce is consumed first and exactly once, so a replay finds nothing left to spend. Sessions last 24 hours. Live
GET /v1/account
The balance, and every key on it by preview. Never a token. Live
POST /v1/account/keys
Mint another key under this wallet. Shown once, like every other. Live
POST /v1/account/keys/disable
Disable one by preview. What you do with a leaked key, since no key can be re-shown. Live
POST /v1/account/claim
Bring a key minted before you signed in under this wallet. Refused rather than pooled if both already hold a balance — merging two balances is irreversible and should not happen as a side effect. Live
POST /v1/account/wallet
Bind the wallet this session proved. Takes no address: the body is not read, because a request that can be proven must never be recorded as a claim. Live
POST /v1/account/deposit-tx
The same browser-wallet payment as the key route, for the account rather than one key. Live
POST /v1/account/deposit-intent
The same Solana Pay link as the key route, for the account rather than one key. Live
GET /v1/account/usage
The account's ledger, not one key's — the only place that answers “what has this wallet spent”. Live
GET /v1/account/audit
Every action this session took, written as it happened. A session can mint and spend, so it is worth being able to read back. Live
POST /v1/account/signout
Revoke it now rather than waiting out the 24 hours. Live
Putting money on a key
In USDC or $ROB, from the key page or the dashboard. The ways differ only in how we work out whose payment it is, and they are not equally reliable, so they are listed in the order to try them.
From the wallet in your browser — the Pay button.
With Phantom, Solflare or Backpack installed, the Pay panel on the key page and the dashboard asks your wallet to approve a transfer we built for it, carrying a one-off reference. Approve it, and the page follows the payment until it is credited. Nothing to copy, and no memo field to look for.
From your phone — the QR.
With no wallet in the browser, the panel shows a Solana Pay QR carrying the same kind of reference. Scan it with your phone's wallet. A desktop browser cannot open the solana: link behind it — that is the phone's job.
From a wallet you have signed in with.
Sign in and bind it, and any USDC you send from that wallet is credited even with no memo and no reference. The binding is proven by the signature you already gave, not claimed.
By hand, or from an exchange — the memo, if it has one.
Put your deposit code in the transfer memo. Many exchanges have no memo field for Solana, and a withdrawal carries neither a reference nor your wallet as the sender — it arrives from the exchange's own router. USDC that arrives with none of the three is not lost, but it is a manual reconciliation rather than a balance. If your exchange has no memo field, withdraw to your own wallet first and pay from there.
Paying with $ROB.
Pay in $ROB from the Pay panel and we sell it for USDC through Jupiter, then credit exactly the USDC the sale returned — not what your $ROB was worth when it arrived. Nothing is ever priced in $ROB; your balance is dollars and credits like everybody else's.
Large amounts sell in parts. The $ROB pool is thin, so an amount one sale cannot take without moving the price too far is split, and each part is credited as it lands. The price can move between parts, and if nobody buys in between, later parts sell lower. That risk is yours from the moment you send, which is why the page shows Jupiter's estimate and calls it an estimate. Unsold $ROB cannot be sent back.
What we hold, and for how long. Your $ROB waits in a separate conversion wallet until it sells. That wallet's key is the only key our server holds; it can sell $ROB into the treasury and nothing else, and it never holds USDC. The treasury itself is still watched, never signed for. $ROB payments pause themselves if the token's transfer fee changes or the wallet runs short of fees, and new ones stop being offered while a lot of $ROB is already waiting — that refusal is rob_not_open.
Rather do it yourself? The key page's Paying by hand still links a $ROB → USDC swap on Jupiter. Same pool, same fees, and you choose the moment; then send the USDC like any other deposit.
Worth saying plainly either way: paying with $ROB sells $ROB. The thing that buys it is platform fees, which is a separate mechanism and not this one.
Generated files, and how long we keep them
Media generation is open. This was written down before the first byte was ever stored, so it is a promise being kept rather than a policy assembled afterwards.
We host it
The provider serves its own output for exactly 24 hours. We copy every completed generation into our own storage within seconds of it finishing, and from then on output_url is ours.
For 30 days
Counted from the moment we store it, stamped on the file, and never recomputed. If we ever shorten the window it applies to new files — nothing already generated loses days it was promised.
Then it is deleted
Properly, from storage. The task record survives so the charge stays explicable, and GET /v1/files/{id} answers 410 output_gone with the date rather than a bare 404.
Read one field
output_hosted_by is "upstream" in the seconds before our copy lands and "loot" after. output_expires_at always refers to whichever URL you were just handed.
Delete it sooner
DELETE /v1/tasks/{id}/output, any time. The charge is unaffected — you paid for the generation, not the hosting.
A file URL is a capability: anybody holding the link can fetch the file./v1/files/{id} deliberately takes no Authorization header, because a URL that needs one cannot go in an <img> tag. The id is 122 bits of randomness, so it cannot be guessed — but treat the link the way you would treat any unlisted URL, and delete the output if it gets somewhere you did not intend. The record is still private: GET /v1/tasks/{id} answers 404 to every key but yours, so the prompt, the model and the price never travel with the link.
What we will not generate
Both the prompt and the finished file are checked. The list is short on purpose, and it is published so that a refusal is never a mystery.
Always refused
Sexual content involving minors. No threshold, no exceptions, no setting that loosens it.
Refused at high confidence
Graphic violence, illicit and violent-illicit content, self-harm instructions, and the threatening forms of harassment and hate.
Not refused
Adult content, ordinary violence, offensive jokes, and edgy or unflattering material. Rude is not the same as harmful, and we are not policing taste.
What it costs you
A refused prompt costs nothing at all — no hold, no charge, no ledger entry. A generation refused after it ran is charged at our upstream cost with no margin, because we already paid for it and we would rather not profit from blocking you.
Text is not moderated
/v1/chat/completions and /v1/embeddings pass through unexamined. We store nothing from them and host nothing on your behalf, so there is no artefact to moderate — and reading every prompt on a passthrough API is a cost we will not impose for a risk we do not carry.
Reporting something we are hosting.
If you have been sent a Loot file URL that should not exist, tell us: [email protected]. Include the URL — that is all we need to find it. Every file we serve carries a link to this page in its X-Loot-Report header, because whoever needs it most likely has the file URL and nothing else.
A report does not delete anything automatically, and that is deliberate: file URLs are unguessable links rather than accounts, so an automatic purge would let anyone holding a link delete someone else's work. A person reads the report and acts. We will not tell you who generated it. There is no SLA here and pretending otherwise would be worse than saying so.
Errors
Every error string we emit contains the URL of the page that explains it. Copy the code, paste it after the hash, read the paragraph. No error should ever require you to guess or to open a support ticket to find out what it meant.
Status
Code
What actually happened
401
missing_key
No Authorization header, or nothing usable in it. Send Authorization: Bearer loot-live-….
401
malformed_key
Something was sent, but it is not the shape of a Loot key. Usually a key from somewhere else, or a truncated paste.
401
unknown_key
Correctly shaped and not one we issued. We store only a hash, so we cannot tell you whether it once existed.
403
key_disabled
Your key, switched off. Deliberately distinct from unknown_key: telling you "no such key" when the answer is "disabled" would send you looking in the wrong place.
402
insufficient_balance
Available balance is below what this call reserves — available is balance minus holds, not balance. A call already in flight has claimed the difference. The response says what was required.
404
unknown_model
Not in the catalogue. Check /v1/models, which lists exactly what is callable.
409
unpriced_model
The model exists upstream but publishes no price, so we will not sell it. Billing a call we cannot price means guessing at your bill.
404
unknown_task
No such generation on this key. A task belonging to somebody else answers the same way, because confirming an id exists already says something about it.
409
both_funded
You asked to attach a key to your wallet, and both the wallet and the key hold a balance. We will not pool two balances automatically: it is irreversible, and it would happen as a side effect of an action whose stated purpose is attaching a key. Spend one down to zero and try again, or leave the key on its own wallet. A key with no balance attaches freely, and so does a funded key joining an empty wallet.
401
bad_signature
Wallet sign-in: the signature did not match this wallet and nonce, or the nonce was unknown, expired or already spent. Nonces are single-use, so a retry needs a fresh one from /v1/auth/nonce.
401
missing_session
An /v1/account route was called without a session. These are authenticated by Authorization: Bearer loot-sess-… from /v1/auth/verify, not by an API key — the key authenticates the API, the wallet authenticates the dashboard.
401
session_expired
That session is expired, revoked, or was never issued. Sessions last 24 hours, deliberately: one can mint a key and a key can spend. Sign in again.
400
content_blocked
The content policy refused this request. The message names the category and not the score. Retrying will not help — change the prompt. If a generation is refused after it ran, you are charged our upstream cost and no margin; see the policy below.
410
output_gone
We hosted this and deleted it at the end of its retention window, or you deleted it yourself. Deliberately not a 404: a client that cannot tell “never existed” from “expired on schedule” retries the second one forever. The task record survives, so the charge is still explicable.
501
media_not_open
Media generation is closed on this deployment. It is open here — so you should not see this — but the code stays documented because the gateway refuses to open media without both a retention window and a moderation policy, and answering 501 is what that refusal looks like from outside.
503
rob_not_open
A deposit intent asked to pay in $ROB and we are not taking $ROB right now — either it is not switched on here, a safety check has paused it, or a lot of $ROB is already waiting to be sold. Try again later. USDC deposits are unaffected, and you can always swap $ROB for USDC on Jupiter yourself and send that.
400
bad_request
The body is missing something required, or upstream rejected it. The message says which.
404
model_not_found
Upstream does not have the model, even though our catalogue does. Rare, and it means our price book is ahead of their fleet.
502
upstream_auth
The provider rejected our credentials, not yours. Nothing to do at your end and you are not billed; it means our upstream account needs attention.
429
upstream_rate_limited
The provider rate-limited us. You are not billed — the hold is released rather than settled.
502
upstream_malformed
The provider answered with something that is neither JSON nor a stream. You are not billed, and nothing reaches your ledger.
502
network
We could not reach the provider at all. Not billed, and not retried — a retry after a timeout can buy the same tokens twice with no way to settle both.
503
upstream_unavailable
The single upstream is unreachable, so everything is. There is no second provider to fail over to and it would be dishonest to imply otherwise.
504
upstream_timeout
No response headers within the deadline. Not billed, and not retried, for the same reason as above.
Nothing is retried, at any layer.
A timeout does not mean the call did not happen: the provider may have generated the tokens and be about to bill us for them, and there is no idempotency key on their side to lean on. So a failure is surfaced rather than papered over, the hold is released, and the decision to try again is yours.