PerdurancePerdurance

Getting started

Point an unmodified OpenAI or Anthropic SDK at Perdurance and read a stored request back out again — from an empty account to a durable LLM call, in six steps.

By the end of this you will have made a request through Perdurance with a client you did not modify, and read the same request back out of storage afterwards.

You need one thing that is not on this page: an API key from a model provider. Perdurance calls the provider on your behalf with your credential, so there is no Perdurance-hosted model and nothing to try this against until you have one.

Sign in

Open the console and sign in. If nobody has registered your organisation yet, the console is also where that happens — the first account created owns the tenancy.

A tenancy is your organisation. Everything below lives inside it, and its slug is the first segment of every URL you will call.

Create a namespace

NamespacesNew namespace.

A namespace is an isolated set of provider connections, routing rules, keys and stored requests. Production and staging want different ones; so do two teams who should not read each other's transcripts.

Its slug is the second segment of every URL you will call. This guide assumes a tenancy acme and a namespace prod.

Add a backend

BackendsNew backend, then attach it to your namespace.

A backend is the connection to a provider: its address, its dialect, and your key for it.

KindSpeaksFor
openai_compatOpenAIOpenAI itself, and the many servers that copy its API
anthropicAnthropicAnthropic's Messages API
openrouterOpenAIOpenRouter, including its Anthropic-family models

The provider key you paste here is encrypted before it is stored, and it is never returned by any route — not to the console, and not to you.

Add a routing rule

Routing rulesNew rule. Pattern *, pointing at the backend you just attached.

A routing rule is what turns the model name in a request body into a decision about which backend serves it. * sends every model name straight through, which is what you want while you are finding out whether this works; narrower patterns and a model_rewrite are how one backend comes to answer for several names later.

Nothing routes until a rule exists. A request whose model matches no rule is refused rather than guessed at.

Issue an API key

KeysNew key, scoped to the namespace.

This is the key your code sends to Perdurance, and it is not the key Perdurance sends your provider. It looks like sar_ab12cd34_… — a scheme, an eight-character public prefix that identifies the key in listings and in logs, and the secret. It is shown once. Copy it now.

export PERDURANCE_KEY='sar_ab12cd34_…'
export PERDURANCE_URL='https://api.perdurance.dev/acme/prod/v1'

Make a request

Your base URL is the tenancy and the namespace, then /v1. Under it the routes have your provider's own names and take your provider's own bodies.

curl "$PERDURANCE_URL/chat/completions" \
  -H "Authorization: Bearer $PERDURANCE_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "gpt-4.1",
    "messages": [{ "role": "user", "content": "Say hello." }]
  }'

The response is the provider's, byte for byte. That is the point: the SDK above is unmodified and has never heard of Perdurance, and the only line that changed is the base URL.

Add "stream": true and you get the provider's own stream, forwarded as each chunk is stored.

Losing the connection

Nothing you have to do. The upstream call is already running server-side and will finish whether or not you are still listening, and the answer is stored when it does.

To pick it back up, send the identical body again. Inside the idempotency window — ten minutes from submission — it hashes to the record already there, so you attach to that execution rather than starting, and paying for, a second one. It has to be byte-identical: a re-serialised body with its keys in another order is a different request. Idempotency has the detail. A streaming request replays what was stored and then continues live from where the stored chunks end.

This is why an unmodified SDK gets durability for free: its own built-in retry is the resume mechanism, and it never learns a Perdurance-specific concept to use it.

Reading a request back

The synchronous routes above answer with the provider's response and no envelope, which means they do not hand you a request id — an id in the body is a field a vendor SDK would have to be taught to ignore. Find the request in the console, or list them:

curl "$PERDURANCE_URL/requests?limit=10" \
  -H "Authorization: Bearer $PERDURANCE_KEY"

Then fetch one by id. Without an Accept header you get the stored record as JSON:

curl "$PERDURANCE_URL/requests/$ID" \
  -H "Authorization: Bearer $PERDURANCE_KEY"
{
  "request_id": "01J8F2ZK9QX3M4NBVWT7",
  "status": "succeeded",
  "dialect": "openai",
  "streaming": false,
  "attempts": 1,
  "recoveries": 0,
  "next_retry_at": null,
  "response": { "id": "chatcmpl-...", "choices": [] }
}

With Accept: text/event-stream the same URL replays the answer as a stream instead, from the first chunk, however long ago it was stored:

curl "$PERDURANCE_URL/requests/$ID" \
  -H "Authorization: Bearer $PERDURANCE_KEY" \
  -H 'Accept: text/event-stream'

Each event carries its sequence number as its SSE id. Send the last one you saw back as Last-Event-ID and the replay starts from the one after it — which is what makes a reader that died halfway through able to carry on rather than start over.

When you want the id up front

Submit to POST /requests instead. It stores the request, answers immediately, and runs the execution without you:

curl "$PERDURANCE_URL/requests?dialect=openai" \
  -H "Authorization: Bearer $PERDURANCE_KEY" \
  -H 'Content-Type: application/json' \
  -d '{ "model": "gpt-4.1", "messages": [{ "role": "user", "content": "Say hello." }] }'
{ "request_id": "01J8F2ZK9QX3M4NBVWT7", "status": "pending" }

The body is byte-for-byte the one the synchronous route takes — the dialect moves into a query parameter precisely so it can be — so the same request submitted either way hashes the same and deduplicates together. Collect it with GET /requests/{id} exactly as above.

Next

  • Routing — patterns, priority, and pointing one name at another model
  • Idempotency and resume — the window, and how a stream gets picked back up
  • API reference — the full shape of every submission and retrieval route
  • The sar CLI — all of the above from a shell
  • Errors — including the 504 that does not mean what it looks like

On this page