API reference
The submission and retrieval routes for durable LLM inference, SSE replay with Last-Event-ID, and the record a request leaves behind.
Every route below is scoped to a tenancy and a namespace:
https://api.perdurance.dev/{tenancy}/{namespace}/v1and authenticated with an API key issued for that namespace:
Authorization: Bearer sar_ab12cd34_...A key is sar_<prefix>_<secret>. The eight-character prefix is the public half — it is what
listings and log lines name a key by, and the secret is stored only as a hash.
A submission may send the same key in a dedicated header instead:
X-Perdurance-Key: sar_ab12cd34_...The dedicated header wins when both are present. It exists so that Authorization can carry
something else, which is what a backend forwarding the caller's own credential needs. See
Claude Code and the Agent SDK.
A bearer token starting sk-ant- is an Anthropic credential rather than a Perdurance key, and
is refused with 401 provider_credential rather than a bare unauthorised.
The administration routes the console drives — namespaces, backends, routing rules, users and keys — are in Administration. Every status the routes on this page return is in Errors.
Submission
| Route | Dialect | Answers |
|---|---|---|
POST /chat/completions | OpenAI | The connection is held: a completed response, or SSE of chat.completion.chunk when the body says "stream": true |
POST /messages | Anthropic | The connection is held: a completed message, or SSE of Anthropic's own events when streaming |
POST /requests?dialect= | Named by the parameter | 202 with the request id, immediately |
The request body is forwarded to the provider with every field, every content block and every array in the order it arrived, except the model id a routing rule rewrites. Object keys are re-serialised in a canonical order, which is what lets an identical retry hash to the same request. The answer comes back exactly as the provider sent it, with no envelope — which is what lets an unmodified vendor SDK work against these routes.
A failed call is answered the same way. The provider's own status, its body byte for byte, and
its retry-after are passed through rather than wrapped, and only Perdurance's own failures
carry Perdurance's envelope. Errors has the rule.
A stream decides on its first item
A streaming submission does not answer 200 text/event-stream until it has something to send.
It waits up to the deployment's synchronous hold, five minutes by default, for the first
chunk.
| First item | Answer |
|---|---|
| A chunk | 200 text/event-stream, then the provider's own stream |
| A refusal the provider answered with | The provider's status and body |
| A failure that was not an HTTP answer | 502 upstream_failed |
| Nothing, within the hold | 504 held_too_long, and the execution carries on |
A failure after the first chunk arrives as event: failed, because the status line has
already been sent. The provider's own mid-stream errors are chunks, and are forwarded as they
are.
Dialect is decided by the route, never sniffed
dialect on POST /requests takes openai or anthropic, and defaults to openai. Nothing
inspects the body to guess.
When a routing rule resolves a request to a backend of the other dialect it is refused with
422 and no record is written. Perdurance does not translate between dialects: a translation
layer owns a mapping it has to keep current with two vendors, and every field it fails to
understand is a corrupted upstream call. Reaching an Anthropic model from an OpenAI client is
what an openai_compat or openrouter backend is for — see Routing.
202 from POST /requests
{ "request_id": "01J8F2ZK9QX3M4NBVWT7", "status": "pending" }Re-sending an identical body
Inside the idempotency window — ten minutes — the same body hashes onto the record already there. The hash is over the parsed body canonicalised, so object keys may move; numeric spelling and array order may not. Idempotency and resume covers what counts as identical.
| Record's state when the hash hits | Not streaming | Streaming |
|---|---|---|
running | Holds until it settles, then returns the stored answer | Replays the stored chunks, then live-tails to completion |
succeeded | Returns what was stored | Replays the stored chunks |
The provider is called once across all of it.
A failed record is not in that table. A terminal failure releases the idempotency claim, so
the same body sent afterwards is a new request, with a second fee and a second provider call.
The stored error is read by id, below.
Retrieval
GET /requests/{id}
Without Accept: text/event-stream, one JSON object:
{
"request_id": "01J8F2ZK9QX3M4NBVWT7",
"status": "succeeded",
"dialect": "openai",
"streaming": false,
"attempts": 1,
"recoveries": 0,
"next_retry_at": null,
"response": {}
}response carries the stored answer, embedded exactly as it was stored rather than
reserialised — a response whose keys moved is no longer the provider's own bytes. A failed
request carries error instead. A request that has not finished carries neither, which is what
makes "no answer yet" a fact about the shape rather than something you parse for.
A failed request carries two more fields beside error, when its failure was an answer the
provider gave:
{
"request_id": "01J8F2ZK9QX3M4NBVWT7",
"status": "failed",
"error": {
"type": "error",
"error": { "type": "rate_limit_error", "message": "…" }
},
"error_status": 429,
"retry_after": "30"
}error_status is the provider's own HTTP status and retry_after is its retry-after header,
verbatim, so a caller reading a record back knows what the provider said as well as that it
said no. Both are absent when the failure was not an HTTP answer, and neither appears in the
listing below, which stays a summary.
A streamed answer lives in its chunks and appears under neither field. Read it through the replay below.
attempts, recoveries and next_retry_at are the execution history: how many times the
request was taken up, how many of those followed a worker dying mid-flight, and when the next
attempt is due.
GET /requests/{id} with Accept: text/event-stream
The same URL replays the answer as SSE. Each event carries its sequence number as its id.
| Request state | Behaviour |
|---|---|
pending | Waits for the first chunk, or returns the terminal state if execution fails first |
running | Replays the stored chunks, then live-tails |
succeeded, failed | Replays the stored chunks, then ends |
Chunks are replayed as the provider framed them, event names included, with id: <seq> written
in front. Strip the id lines and you have exactly what the provider sent.
A request that failed ends its replay with an event: failed carrying the stored error. It has
no id, because a failure is not a place to resume from.
Resuming
Last-Event-ID: 25The reply starts at sequence 26 and otherwise behaves exactly as above, live tail included. A
Last-Event-ID past the end simply waits for what has not arrived yet.
GET /requests
A listing, newest first, for finding an id you did not keep. It needs a key that can manage the
namespace, where GET /requests/{id} needs only one that can use it.
| Parameter | Meaning |
|---|---|
limit | How many to return: 50 by default, 200 at most. 0 or more than 200 is refused with 400 bad_request rather than clamped |
after | An opaque cursor from a previous page — hand back what you were given |
Each row carries the request's status, model, dialect, key and backend, its token counts, how
many bytes it holds, and its created, started and finished timestamps. The stored body and
answer are deliberately absent: they are the largest columns in the table and a page of them
would be megabytes. GET /requests/{id} is where a body is read, one at a time.

