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.
The administration routes the console drives — namespaces, backends, routing rules, users and keys — are in Administration. Every status this API can 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 byte for byte, except the model id a routing rule rewrites. 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.
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 — a byte-identical body hashes onto the record already there. 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, failed | Returns what was stored | Replays the stored chunks |
The provider is called once across all of it.
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 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.
| Parameter | Meaning |
|---|---|
limit | How many to return |
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.

