Skip to content

Idempotency

Twenty endpoints change state in a way that must not happen twice. Each one requires an Idempotency-Key header, and rejects the request outright if it is missing:

KEY=$(uuidgen)   # mint ONCE; reuse this exact value on every retry

curl -X POST https://api.trilocore.ai/api/v1/bevm/sessions/current/transactions \
  -H "Authorization: Bearer $TRILOCORE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d '{"to": "0x…", "selector": "transfer(address,uint256)", "args": ["0x…", "1"]}'
import os
import uuid

import httpx

key = str(uuid.uuid4())  # mint ONCE, outside the retry loop

r = httpx.post(
    "https://api.trilocore.ai/api/v1/bevm/sessions/current/transactions",
    headers={
        "Authorization": f"Bearer {os.environ['TRILOCORE_API_KEY']}",
        "Idempotency-Key": key,
    },
    json={"to": to_address, "selector": "transfer(address,uint256)", "args": args},
)
const key = crypto.randomUUID(); // mint ONCE, outside the retry loop

const r = await fetch("https://api.trilocore.ai/api/v1/bevm/sessions/current/transactions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.TRILOCORE_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": key,
  },
  body: JSON.stringify({ to, selector: "transfer(address,uint256)", args }),
});

The point of the header is retries. Mint one key per logical operation, then reuse that same key for every retry of it. A network timeout leaves you unable to tell whether the operation happened; retrying with the same key resolves that safely, because the second request replays the first one's result instead of performing the work again.

The failure this prevents is concrete: retrying an attestation without a key mints a second attestation, and retrying a fork transaction without one sends the transaction twice.

The header

Name Idempotency-Key
Value 1–255 printable ASCII characters (0x21–0x7E), no spaces
Applies to POST, PUT, PATCH and DELETE
Typical value a UUID v4

A UUID is the obvious choice, but any value you can regenerate deterministically for the same logical operation works — and is better if your retry happens in a different process than the original attempt.

Never reuse a key for a different operation

The key is not a nonce you rotate per request; it is the identity of one logical operation. Mint it once, store it with the work item, and reuse it until that item succeeds or you give up on it. Minting a new key inside a retry loop defeats the entire mechanism — that is the single most common way to double-execute against an API like this one.

The endpoints that require it

Method Path
POST /api/v1/bevm/sessions/{uid}/transactions
POST /api/v1/bevm/sessions/{uid}/transactions:replay
POST /api/v1/bevm/sessions/{uid}/snapshots:restore
POST /api/v1/bevm/sessions/{uid}/deployments
POST /api/v1/bevm/sessions/{uid}/storage-slots:write
POST /api/v1/bevm/sessions/{uid}/blocks
POST /api/v1/bevm/stateless/deployments
POST /api/v1/bsvm/sessions/{uid}/transactions
POST /api/v1/bsvm/sessions/{uid}/airdrops
POST /api/v1/bsvm/sessions/{uid}/snapshots:restore
POST /api/v1/arch/{architecture_id}/snapshots
POST /api/v1/ide/workspaces/{workspace_id}/github/publish
POST /api/v1/ide/workspaces/{workspace_id}/bindings/push
POST /api/v1/workspaces/{workspace_id}/attestations
POST /api/v1/workspaces/{workspace_id}/invitations
POST /api/v1/workspaces/{workspace_id}/reports/{report_id}/share-links
POST /api/v1/workspaces/projects/{project_id}/invitations
POST /api/v1/workspaces/projects/{project_id}/transfers
POST /api/v1/workspaces/projects/{project_id}/transfers/{transfer_id}/complete
POST /api/v1/arch/project-transfers

They have a shape in common: each either mints something durable and externally visible — an attestation, an invitation, a share link, a published commit — or advances the state of a fork. Each endpoint's page in the reference repeats the requirement in its Idempotency-Key row, so you never have to hold this list in your head.

You may send the header on any POST, PUT, PATCH or DELETE, not just these twenty, and get the same replay protection. Doing so is a good default for a client library.

Replay semantics

A key is scoped to (you, your organisation, the key, the method, the endpoint): the same key sent to a different endpoint is a different operation, not a reuse. Within that scope, a stored response is replayed only when the whole request matches:

  • the body;
  • the query string, except the credential-carrying parameters token, access_token and api_key (a retry with a rotated credential is still the same request);
  • Prefer: respond-async, which selects the queued form of an operation;
  • the trilocore-session-tag, which selects the session the call runs on;
  • on the auditing surfaces, a contract address in the path.

The same key with any of those different answers 422 idempotency_key_reuse and never a replay, so an accidental key collision is caught rather than served another request's answer.

A replay reproduces the stored status and body, and the ETag, Link, Cache-Control and Location headers the original response carried.

Two response headers tell you which happened:

idempotency-replayed: false     ← this request executed
idempotency-replayed: true      ← the stored response was replayed; nothing ran

Treat both as success. A replayed 201 is not a duplicate create — it is the same create, told to you a second time.

Details worth knowing when you build a retry policy:

  • Keys are scoped to you and your organisation. One caller can never replay another caller's response, even with an identical key, and one key used in two organisations is two independent requests.
  • A replay passes the same checks as a fresh request. The stored response is served only after the retry passes the same authentication and authorization a new request would. A retry with a missing or revoked credential gets the ordinary 401, never the stored answer.
  • An in-flight request holds the key. While the first attempt runs, a retry with the same key answers 409 idempotency_conflict. The lock lasts at most the service's deadline plus 30 seconds; a completed request releases it at once.
  • A timeout keeps the key locked. If the front door times out (504 upstream_timeout), or the connection breaks after your request reached the service, the first attempt may still be running. The key stays locked and retries answer 409 until the deadline plus 30 seconds has passed — then a retry executes. This is what stops a retried transaction from being sent twice.
  • Responses are kept for 24 hours. After that, the same key is a fresh request and will execute again. Any retry policy should complete well within that window.
  • Not every response is storable. A response is only remembered when the service declared its length and it was at most 1 MiB, and the status was below 500. This has a direct consequence: a retry after the service answered a 5xx re-executes (once the key is released). Idempotency protects you from ambiguous network outcomes; it does not turn a server error into a completed operation.

The owning service de-duplicates too

The operations that cannot be undone are also de-duplicated by the service that performs them, on the same key: attestations, workspace and project invitations, report share links, GitHub publish, bindings push, and session transactions. So a retry is safe even if it does not reach the front door's stored response. What that adds:

  • A retry re-checks your access to the object. If you have lost access since the first attempt — you were removed from the workspace, say — the retry answers 404, not the stored response.
  • A one-time secret is not replayed. A retried invitation or share-link creation returns the same invitation or link, with "token": null. The token is shown only in the first response and is never stored; if you lost it, revoke the invitation or link and create a new one.
  • A replay is still marked idempotency-replayed: true, wherever it was answered.
  • A request whose time budget ran out does nothing. If the service receives it too late to finish in time, it answers 504 deadline_exceeded before starting any side effect. Retry with the same key.

Errors

Six errors come from the idempotency layer itself. The first five are raised before your request reaches the service, so they mean nothing was executed; bad_gateway is the exception.

Status code Meaning What to do
400 idempotency_key_required the endpoint requires the header and it was absent add the header; do not retry unchanged
400 invalid_idempotency_key the value is empty, over 255 characters, or contains a space or a non-printable character fix the value's format
422 idempotency_key_reuse this key was already used on the same endpoint for a different request: another body, query, session tag or address (see Replay semantics) you reused a key across two operations — mint a distinct key per operation
409 idempotency_conflict a request with this key is still in flight, or is held after a timeout (see Replay semantics) wait and retry; the response carries retry-after: 2
503 idempotency_unavailable the store that guarantees one execution is unreachable, so the operation was refused rather than risked twice retry shortly with the same key; carries retry-after: 2
502 bad_gateway the service executed the request but its response broke off before it could be stored do not mint a new key: retry with the same one; it answers 409 until the lock expires, then executes

A 409 is the normal outcome of two of your own workers racing on the same work item. Honour retry-after and try again — the second attempt will usually replay the first one's result.

Omitting the header on a required endpoint looks like this:

POST /api/v1/workspaces/{workspace_id}/attestations     ← no Idempotency-Key sent

400 Bad Request
content-type: application/problem+json

{
  "type": "https://platform.trilocore.com/docs/en/api/errors/#idempotency_key_required",
  "title": "Idempotency-Key required",
  "status": 400,
  "detail": "this endpoint requires an Idempotency-Key header",
  "code": "idempotency_key_required",
  "error": "idempotency_key_required",
  "message": "this endpoint requires an Idempotency-Key header",
  "instance": "/api/v1/workspaces/{workspace_id}/attestations",
  "request_id": "req_…"
}

bad_gateway is plain JSON, not problem+json

The five refusals above (400, 409, 422, 503) are full RFC 9457 problem documents with a stable code, like the example. The 502 bad_gateway is not: it is returned as application/json with a minimal body and no code, title, type or status member:

{ "error": "bad_gateway", "message": "upstream body read failed" }

Read the error identifier as body.code ?? body.error. That one expression is correct for every error the API returns, because problem documents also carry error as a legacy alias of code. See Errors.