Errors & Status Codes
The oec.sh API uses standard HTTP status codes. Error bodies are JSON with a single top-level detail field.
Error Response Format
Most errors put an object in detail, with a machine-readable error code and a human-readable message:
{
"detail": {
"error": "task_in_progress",
"message": "A task is already queued or running for this environment.",
"task_id": "3f2b8c1e-6d4a-4f7b-9a51-0c2d8e7f6a10"
}
}Some errors add fields next to error and message, such as task_id above, docs_url, or required_plan and current_plan. Branch on detail.error, not on the message text.
There are three exceptions to that shape.
Plain-text detail. Some responses carry a plain string in detail instead of an object. This is the case for most errors of the backup endpoints (create and download) and for a webhook created with a project_id that does not exist:
{
"detail": "A backup is already in progress for this environment. Please wait for it to complete."
}Validation errors (422). When the request body, path or query parameters do not match the schema, or the body is not valid JSON, detail is a list with one entry per problem:
{
"detail": [
{
"type": "enum",
"loc": ["body", "mode"],
"msg": "Input should be 'restart_only', 'pull_restart', 'update_all', 'update_specific' or 'reinitialize_specific'",
"input": "nope",
"ctx": {
"expected": "'restart_only', 'pull_restart', 'update_all', 'update_specific' or 'reinitialize_specific'"
}
}
]
}loc is the path to the field (body, query, path or header, then the field name), msg describes the problem and input is the value received.
A few 422s come from checks made after validation and use the object shape instead, for example invalid_odoo_version, invalid_environment_type, invalid_vm_id and webhook_limit_reached.
Subscription blocked (402). Most write requests are refused while the organization's subscription is blocked (for example when the payment grace period has ended, or the subscription is suspended, expired or paused). This body uses reason instead of error:
{
"detail": {
"reason": "subscription_blocked",
"subscription_status": "suspended",
"message": "<why the subscription is blocked>",
"renewal_url": "/dashboard/settings?tab=billing"
}
}renewal_url is a path in the oec.sh dashboard (https://platform.oec.sh). Read requests keep working.
HTTP Status Codes
| Status | Meaning | When you'll see it |
|---|---|---|
200 OK | Success | GET and PATCH requests that succeed |
201 Created | Resource created | POST that created a project, environment, webhook or backup |
202 Accepted | Action queued | Deploy, restart, start, stop and quick update; poll the returned poll_url |
204 No Content | Success, no body | Successful DELETE |
400 Bad Request | Request not usable | A value the endpoint does not accept (for example an unsupported quick update mode), or a delete without its confirmation header |
401 Unauthorized | Authentication failed | Missing, malformed, unknown, revoked or expired key |
402 Payment Required | Subscription blocked | A write while the organization's subscription is blocked |
403 Forbidden | Not allowed | Read-only key on a write endpoint, plan does not include the Public API, quota reached, key scope too narrow |
404 Not Found | Resource not found | The resource does not exist, or belongs to another organization or another project than the key's |
409 Conflict | State conflict | A task is already running, another action was just queued, the environment is in the wrong state |
422 Unprocessable Entity | Invalid input | Body or parameters fail validation, or the body is not valid JSON |
429 Too Many Requests | Rate limited | Read or write limit reached, webhook change limit reached, IP blocked after failed authentication |
500 Internal Server Error | Server error | Unexpected error on our side |
503 Service Unavailable | Try again shortly | The task could not be queued; the response has a Retry-After header |
404 is also returned for resources outside your key's reach. If you request a resource that belongs to another organization, or to another project than your project-scoped key's, you receive the same 404 with error not_found as for a resource that does not exist, and not 403 Forbidden. This prevents leaking which resources exist.
Common Error Codes
Authentication and Access
| Code | Status | Description |
|---|---|---|
missing_auth | 401 | No Authorization: Bearer or X-API-Key header was provided |
invalid_key_format | 401 | The key doesn't match the expected format (oec_live_ro_* or oec_live_rw_*) |
invalid_key | 401 | The key does not exist or has been revoked |
key_expired | 401 | The key's expiry date has passed |
read_only_key | 403 | A read-only key was used on a write endpoint |
plan_feature_required | 403 | The organization's plan does not include the Public API (Starter or higher); includes required_plan and current_plan |
org_scope_required | 403 | The endpoint needs an organization-scoped key (API key management, server registration) |
project_scoped_key | 403 | A project-scoped key tried to create a project, delete a project, or create a webhook outside its project |
Resources and Quotas
| Code | Status | Description |
|---|---|---|
not_found | 404 | The resource does not exist, or is outside your key's organization or project |
quota_exceeded | 403 | Creating the project or environment would exceed your plan's limit; includes current_usage and limit |
resource_quota_exceeded | 403 | The CPU, memory or disk requested would exceed your plan's limit |
confirm_required | 400 | A delete was sent without its confirmation header |
environments_active | 409 | The project still has environments; delete them first (errored ones are cleaned up automatically) |
environment_busy | 409 | The environment cannot be deleted right now: it is deploying or cloning, or a deployment, clone, restore or backup is running on it. Retry once it finishes |
Actions
| Code | Status | Description |
|---|---|---|
task_in_progress | 409 | A task is already queued or running for this environment; includes its task_id |
concurrent_action | 409 | Another action was queued for this environment in the last 30 seconds; retry shortly |
environment_not_running | 409 | The action needs a running environment (restart, stop, quick update) |
environment_not_stopped | 409 | Start needs a stopped environment |
environment_unassigned | 409 | The environment has no server assigned |
unsupported_mode | 400 | The quick update mode is not one of pull_restart, update_all, update_specific, reinitialize_specific |
modules_required | 400 | The quick update mode needs at least one module name |
enqueue_failed | 503 | The task could not be queued; retry after the Retry-After seconds |
Runtime Logs and Metrics Errors
| Code | Status | Description |
|---|---|---|
no_server | 409 | The environment has no server assigned yet |
environment_not_ready | 409 | The environment is pending, cloning or deploying, so there is nothing to read yet |
container_not_found | 409 | Docker on the server reports that the environment's container does not exist |
server_unreachable | 502 | The server could not be reached, or Docker on it did not answer |
docker_timeout | 504 | Docker on the server did not return logs within 20 seconds |
metrics_unavailable | 502 | The server or its monitoring agent did not return metrics |
Rate Limits
| Code | Status | Description |
|---|---|---|
rate_limit_exceeded | 429 | Read or write limit reached for this key; see the Retry-After header |
target_rate_limit | 429 | Too many runtime-log or metrics calls for the same environment or server; see the Retry-After header |
webhook_mutation_rate_limit | 429 | More than 10 webhook changes in 60 seconds |
ip_blocked | 429 | Too many requests with an unknown key from your IP address; blocked for 15 minutes, and Retry-After gives the seconds left |
See Rate Limits for the headers sent with a 429.
Handling Errors in Code
A client that handles every shape above:
import time
import requests
def error_code(body):
detail = body.get("detail")
if isinstance(detail, dict):
return detail.get("error") or detail.get("reason"), detail.get("message")
if isinstance(detail, list): # 422 validation errors
return "validation_error", "; ".join(e["msg"] for e in detail)
return None, detail # plain-text detail
def api_call(session, method, url, **kwargs):
response = session.request(method, url, **kwargs)
if response.status_code in (200, 201, 202):
return response.json()
if response.status_code == 204:
return None
code, message = error_code(response.json())
if response.status_code in (429, 503):
wait = int(response.headers.get("Retry-After", 60))
raise RuntimeError(f"Retry in {wait}s ({code}): {message}")
if response.status_code == 402:
raise RuntimeError(f"Subscription blocked: {message}")
if response.status_code in (401, 403):
raise PermissionError(f"{response.status_code} {code}: {message}")
if response.status_code == 404:
return None
raise RuntimeError(f"{response.status_code} {code}: {message}")500 errors are worth reporting. If you receive a 500 and it's repeatable, contact support@oec.sh with the request details and the X-Request-ID response header, so we can trace the exact request in our logs.