> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ochy.io/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> API access is self-serve: register at https://developers.ochy.io/register, verify the email (grants 1 free analysis credit), generate the API key in the portal, then buy prepaid credits at 2.00 EUR per analysis via Stripe Checkout. See /get-api-access for the exact funnel.
> Agents can complete every funnel step except card entry: hand the Stripe Checkout URL to the user for payment.
> Base URL: https://partner-api.ochy-prod.com. Pass the API key as `Authorization: Bearer` (preferred) or `X-API-Key`; never as a query parameter.
> One analysis consumes one credit, debited when the analysis starts. HTTP 424 with code insufficient_api_credits means the balance is empty: buy credits in the portal. If starting the analysis fails on Ochy's side, the credit is refunded automatically.
> Low-credit alerts: set a threshold and an optional webhook URL in the portal Settings tab or with PUT /settings/low-credit-alerts; an email and a credits.low event are sent once per crossing, and the alert rearms when the balance recovers.
> All endpoints use metric units: height in meters, weight in kilograms.

# Limits and reliability

> Rate limits, concurrency, timeouts, safe retries with Idempotency-Key, and how to recover from lost responses or missed webhooks.

This guide gathers the operational rules to design a reliable integration: how many requests you
can send, what happens when analyses queue up, how to retry safely, and how to recover when a
response or a webhook never reaches you.

## Rate limits

Limits apply to every Partner API endpoint, including `POST /analysis/start` and the polling
endpoint `GET /analysis/{analysis_id}/results`. There is no separate budget for starting analyses
and polling results.

| Scope | Limit |
| - | - |
| Per API key | **100 requests per minute** |
| Per IP address | **500 requests per minute** |

The limits are **throttling, not banning**: when you exceed them, requests are slowed down or
rejected with `429 Too Many Requests` for a short time, then traffic flows again as soon as your
request rate drops back under the limit. Your key is never blocked or revoked because of it.

<Note>
  The IP limit is shared by every key calling from the same address. If several of your services
  or stores egress through one NAT or proxy IP, they share the 500 requests per minute.
</Note>

### Budget your polling

Polling is usually what consumes the 100 requests per minute. Each analysis polled every 5 seconds
uses 12 requests per minute, so a single key can poll roughly 8 analyses in parallel before being
throttled.

If you run several stores or many concurrent analyses on one key:

* Use [webhooks](/guides/webhooks-and-api-keys) as the primary signal and poll only as a fallback,
  at a slow interval (for example every 30 seconds for analyses that have not reached a terminal
  event).
* Use a **single poller per API key** that schedules all analyses, rather than one poller per
  store, so you can enforce your own budget.
* Do not poll `GET /analysis` (the list endpoint) to track progress. It returns every analysis in
  one unpaginated response, without timestamps.
* Back off when you receive `429` or `5xx` (see [retry guidelines](#retry-guidelines)).

## Concurrency and pending analyses

There is **no per-account cap** on the number of analyses processed concurrently or waiting in
`pending`. Ochy does not reject a start request because other analyses are in progress: a valid
`POST /analysis/start` is always accepted with `202` and a `video_id`, and waits its turn in
`pending` if needed.

An accepted analysis moves through the statuses described in the
[analysis workflow](/guides/analysis-workflow#status-progression). While capacity is busy it stays
`pending`; it then becomes `analyzing`, and ends as `success` or `failed`.

The only business reasons for a start request to be refused are validation errors (`400`, `413`)
and an empty balance (`424 insufficient_api_credits`).

### How long can an analysis take?

Most analyses complete within 10 to 30 seconds. If an analysis does not reach a terminal state
(`success` or `failed`) within **30 minutes** of being accepted, Ochy marks it as `failed` and
automatically refunds the credit. The check runs hourly, so the `failed` status can appear up to
about 90 minutes after the start.

<Tip>
  We recommend investigating an analysis that is still `pending` after about 5 minutes, or whose
  `percentage` has not moved for about 5 minutes while `analyzing`. There is no need to resubmit
  it: it will either complete or be failed and refunded automatically. Resubmitting creates a new
  analysis and consumes another credit.
</Tip>

## Safe retries with `Idempotency-Key`

If you do not receive the response of `POST /analysis/start` (timeout, dropped connection), you
cannot know whether the analysis was created. Send an `Idempotency-Key` header with every start
request so you can retry without creating a duplicate or consuming another credit.

```bash theme={null}
curl -X POST "https://partner-api.ochy-prod.com/analysis/start" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: 6f1c2d7e-0b7a-4c52-9a43-4f7f3d1d2a10" \
  -F "video=@runner_side.mp4" \
  -F "analysis_type=side_view"
```

| Rule | Detail |
| - | - |
| Format | 1 to 255 printable ASCII characters (a UUID is a good choice). Anything else returns `400` with code `invalid_idempotency_key`. |
| Scope | Per API key scope (personal or organization). |
| Lifetime | **24 hours** after the analysis was accepted. |
| Replay | Retrying with the same key returns `202` with the **original** `video_id` and the response header `Idempotent-Replayed: true`. No new analysis is created and no new credit is debited. |
| Empty balance | A replay still succeeds when your balance has reached zero. |
| Request body | The retry must still include the video. The server does not compare it to the original request: reuse a key only for the same logical submission. |
| Failure on our side | If the analysis could not be handed over for processing, the credit is refunded and the key is released, so a retry with the same key starts a fresh analysis. |

Generate the key **once per logical submission** (for example when you create the job in your own
database), store it with the job, and reuse it for every retry of that job.

## Recovering a lost `video_id`

<Steps>
  <Step title="Retry with the same Idempotency-Key (recommended)">
    Send the same request again with the same `Idempotency-Key`. You get back the original
    `video_id` whether or not the first request had reached us. This works the same for polling
    and webhook integrations: once you hold the `video_id`, you can poll it or match its webhook
    events.
  </Step>

  <Step title="Look the analysis up by subject (fallback)">
    If you attached a `subject_id`, [`GET /analysis/subjects/{subject_id}`](/api-reference/analysis/list-analyses-for-a-subject)
    lists the analyses linked to that subject with their `id` and `status`.
  </Step>

  <Step title="List your analyses (last resort)">
    [`GET /analysis`](/api-reference/analysis/list-analyses) returns `id`, `analysis_type`,
    `status` and `subject_id` for every analysis. It has no creation time and no client
    reference, so it cannot identify one specific request on its own.
  </Step>
</Steps>

<Warning>
  Webhook events carry only the `analysis_id` (the same value as `video_id`). They do not include
  any client-side reference. If you never received the `video_id`, you cannot match an incoming
  event to a request: recover the `video_id` with an idempotent retry first.
</Warning>

## Webhook delivery guarantees

Each webhook event is delivered **at most once**: Ochy makes a single attempt with a 10-second
timeout. There is **no automatic retry** if your endpoint is down, times out, or answers with a
non-`2xx` status.

This is deliberate. Progress events are only valuable when they arrive on time: a progress update
delivered several seconds late is already outdated. A retry policy would also mean repeatedly
calling an endpoint that may have been misconfigured, for example a wrong URL.

What this means for your integration:

* Treat webhooks as a fast notification, not as a guaranteed log.
* Keep a **fallback poll** for every analysis that has not reached a terminal event
  (`analysis.completed` or `analysis.failed`), at a slow interval such as 30 seconds, and within
  your [rate limit budget](#budget-your-polling).
* Order and deduplicate with the per-analysis `sequence`. Missing `sequence` numbers are expected;
  only the terminal state matters.
* Respond with `2xx` quickly and process the payload in the background.
* Use `GET /analysis/{analysis_id}/results` as the single source of truth after any delivery issue.

## Retry guidelines

| Response | Retry? | What to do |
| - | - | - |
| Network error, timeout, no response | Yes | Retry with the same `Idempotency-Key` for `POST /analysis/start`. |
| `429` | Yes | You are throttled. Wait and retry with exponential backoff. |
| `500`, `502`, `503`, `504` | Yes | Retry with exponential backoff and the same `Idempotency-Key`. |
| `400`, `413` | No | Fix the request (parameters, file type, size, duration, `Idempotency-Key` format). |
| `401`, `403` | No | Fix or rotate your API key. |
| `404` | No | The `analysis_id` is unknown for your key, or its data retention period has expired. |
| `424` | No | Your balance is empty. Add credits, then start again. |

Use exponential backoff with jitter, for example 2 s, 4 s, 8 s, up to a ceiling of about 60 s,
and stop after a bounded number of attempts. Ochy does not send a `Retry-After` header, so rely
on your own backoff.

```python theme={null}
import random
import time
import uuid

import requests

START_URL = "https://partner-api.ochy-prod.com/analysis/start"
RETRYABLE = {429, 500, 502, 503, 504}


def start_analysis(api_key: str, video_path: str, max_attempts: int = 5) -> str:
    """Start an analysis; safe to retry because every attempt reuses the same key."""
    idempotency_key = str(uuid.uuid4())  # generate once per submission, persist it with your job
    for attempt in range(max_attempts):
        try:
            with open(video_path, "rb") as video:
                response = requests.post(
                    START_URL,
                    headers={
                        "Authorization": f"Bearer {api_key}",
                        "Idempotency-Key": idempotency_key,
                    },
                    files={"video": video},
                    data={"analysis_type": "side_view"},
                    timeout=120,
                )
        except requests.RequestException:
            response = None

        if response is not None and response.status_code == 202:
            return response.json()["video_id"]
        if response is not None and response.status_code not in RETRYABLE:
            response.raise_for_status()

        time.sleep(min(60, 2 ** (attempt + 1)) + random.random())
    raise RuntimeError("Could not start the analysis")
```
