API Reference
Idempotency

Idempotency

Idempotency keys let you safely retry API requests without creating duplicate resources or triggering duplicate actions.


Why Idempotency Matters

Network requests can fail at any point:

  • The request reaches the server and succeeds, but the response is lost in transit
  • The connection times out before the server responds
  • Your client crashes after sending the request

Without idempotency, retrying POST /environments/{id}/deploy after a timeout could start two deployments. With an idempotency key, the retry returns the result of the first request and nothing runs twice.

Idempotency keys are especially useful in CI/CD pipelines and AI agents, where retries are common. Send one with every create and every action.


Which Header

Two header names are in use, depending on the endpoint:

EndpointHeaderReplayed response
POST /projectsX-Idempotency-Key201 with the project from the first request
POST /projects/{id}/environmentsX-Idempotency-Key201 with the environment from the first request
POST /webhooksX-Idempotency-Key201 with the webhook from the first request, including its signing secret
POST /environments/{id}/deployIdempotency-Key202 with the task from the first request
POST /environments/{id}/restartIdempotency-Key202 with the task from the first request
POST /environments/{id}/startIdempotency-Key202 with the task from the first request
POST /environments/{id}/stopIdempotency-Key202 with the task from the first request
POST /environments/{id}/quick-updateIdempotency-Key202 with the task from the first request

The header is optional on all of these. Without it, each request is processed on its own and a retry can create a duplicate.

Other endpoints ignore both headers. POST /environments/{id}/backups has no idempotency support: a retry while the first backup is still running gets 409, and a retry after it finished takes a second backup.


How It Works

  1. First request. Processed normally. If it succeeds, the response is stored for 24 hours under your organization, the endpoint and the key (and, for an environment create, the project in the path; for a webhook create, its project_id).
  2. Same key within 24 hours. The stored response is returned and nothing is created or queued again.
  3. Same key after 24 hours. The key has expired and the request is treated as new.

Only successful responses are stored. If the first request failed (for example 402 or 404), retrying with the same key processes the request again. For environment actions, a request that failed after taking the 30-second lock (409 task_in_progress or 503 enqueue_failed, see below) leaves the lock in place, so a retry inside those 30 seconds gets 409 concurrent_action. Wait about 30 seconds before retrying it.

The replayed body is the stored one. For actions, its status is the status at the time the task was queued (queued); poll the poll_url to see where the task is now.

For a create, the body is not part of the identity: a second POST /projects with the same key and a different body still gets the first project back. An environment create is identified by the key and the project, a webhook create by the key and its project_id, so a key scoped to one project never receives a resource created for another. For actions, the key is combined with the action, so the same key on /restart and /stop are two separate requests. For deploy the module list in the body is part of the identity too, and for quick update the mode.

If a project created with a key was deleted since, a replay of POST /projects with that key answers 409 with error idempotency_key_references_deleted_resource. Use a new key to create a new project.


Retrying Environment Actions

Environment actions (deploy, restart, start, stop, quick update) also take a 30-second lock per environment when they are about to queue a task. The lock is not released early: while it is held, any other action request for that environment gets:

HTTP/1.1 409 Conflict
{
  "detail": {
    "error": "concurrent_action",
    "message": "Another action is being queued for this environment. Retry in a few seconds."
  }
}

A retry with the same Idempotency-Key is answered from the stored response before the lock is checked, so it gets the original 202 straight away, even inside those 30 seconds and even if the environment has changed state since (for example a start that has already brought the environment up).

If two copies of the same request arrive at the same moment, one is queued and the other gets 409 concurrent_action. Retry that one with the same key a moment later and it gets the stored 202.

A request without a key, or with a different key, inside the 30 seconds gets 409 concurrent_action. Once the lock has expired, a new action is still refused with 409 task_in_progress (which includes the running task_id) while an earlier task for the environment is queued or running.


Key Format

Use a UUID v4. It is random, unique and available in every language:

KEY=$(uuidgen)
curl -X POST https://api.oec.sh/api/public/v1/environments/$ENV_ID/deploy \
  -H "Authorization: Bearer oec_live_rw_your_key" \
  -H "Idempotency-Key: $KEY"

Naming Patterns

PatternExampleGood for
Random UUID550e8400-e29b-41d4-a716-446655440000One-off operations
Action + commit hashdeploy-abc123def456Git-triggered deploys (same commit = same key)
Pipeline run IDgh-actions-run-12345678CI/CD pipelines
⚠️

Keys must be unique per operation. If you reuse one key for every deploy of an environment, every deploy after the first returns the stored result of the first for 24 hours and never runs. Put a commit hash, run ID or UUID in the key.


Safe Retry Pattern

Generate the key once per operation and reuse it for every retry of that operation:

import time
import uuid
import requests
 
def deploy_with_retry(session, env_id, max_retries=5):
    idempotency_key = str(uuid.uuid4())  # once, before the first attempt
    url = f"https://api.oec.sh/api/public/v1/environments/{env_id}/deploy"
 
    for attempt in range(max_retries):
        try:
            response = session.post(
                url, headers={"Idempotency-Key": idempotency_key}, timeout=10
            )
        except requests.exceptions.Timeout:
            time.sleep(2 ** attempt)  # 1s, 2s, 4s, ...
            continue
 
        if response.status_code == 202:
            return response.json()  # first answer or the stored replay
 
        if response.status_code in (429, 503):
            time.sleep(int(response.headers.get("Retry-After", 30)))
            continue
 
        detail = response.json().get("detail")
        if response.status_code == 409 and isinstance(detail, dict):
            if detail.get("error") == "concurrent_action":
                time.sleep(5)
                continue
            if detail.get("error") == "task_in_progress":
                return {"task_id": detail["task_id"], "already_running": True}
 
        response.raise_for_status()
 
    raise RuntimeError("Deploy could not be queued")