# Public Developer API (arckep)

Human- and LLM-oriented guide for the public image, video, and chat API.

| | |
|---|---|
| **Base URL** | `https://arckep.ru/api/v1` |
| **Auth** | `Authorization: Bearer ark_live_…` |
| **Keys (studio session)** | `https://arckep.ru/api/developer/keys` (JWT cookie / studio login) |
| **Pricing** | Same RUB tables as the arckep studio |
| **Machine-readable** | [llms.txt](https://arckep.ru/llms.txt) · guide: [api.md](https://arckep.ru/docs/api.md) |

---

## 1. Quick start

```bash
export ARK_KEY="ark_live_…"   # Settings → API (or POST /api/developer/keys)

# Models available to this deployment (image + video + chat)
curl -sS -H "Authorization: Bearer $ARK_KEY" \
  https://arckep.ru/api/v1/models | jq .

# Balance (personal or corporate effective balance)
curl -sS -H "Authorization: Bearer $ARK_KEY" \
  https://arckep.ru/api/v1/balance | jq .

# Chat (OpenAI-compatible, sync JSON)
curl -sS -X POST -H "Authorization: Bearer $ARK_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"openai/gpt-5.4-mini","messages":[{"role":"user","content":"Say hi in one word"}],"stream":false}' \
  https://arckep.ru/api/v1/chat/completions | jq .

# Image generation (async job)
curl -sS -X POST -H "Authorization: Bearer $ARK_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"model":"gemini-3.1-flash-image","prompt":"a red apple on marble"}' \
  https://arckep.ru/api/v1/images/generations | jq .

# Poll until completed / failed
curl -sS -H "Authorization: Bearer $ARK_KEY" \
  https://arckep.ru/api/v1/images/generations/123 | jq .
```

---

## 2. Authentication

### Public API (`/api/v1/*`)

```http
Authorization: Bearer ark_live_<secret>
```

- Keys start with `ark_live_`.
- Secret is shown **once** on create; only a hash is stored.
- Banned users are rejected (`403 FORBIDDEN`).
- If public API is disabled server-side → `503 SERVICE_UNAVAILABLE`.

### Key management (studio JWT, not API key)

Create and manage keys while logged into arckep (browser session / JWT):

| Method | Path | Notes |
|--------|------|--------|
| `POST` | `/api/developer/keys` | Create. Body: `{ "name", "scopes"?, "webhook_url"? }`. Response includes raw `key` **and** `webhook_secret` **once** (HMAC for `X-Arckep-Signature`). |
| `POST` | `/api/developer/keys/{id}/rotate-webhook-secret` | New `webhook_secret` **once** (invalidates previous). |
| `GET` | `/api/developer/keys` | List keys (prefix, scopes, status, last used). |
| `PATCH` | `/api/developer/keys/{id}` | Update `name` and/or `webhook_url` (empty/null clears webhook). |
| `DELETE` | `/api/developer/keys/{id}` | Revoke (`204`). |

UI: **Settings → API**. Default scopes when omitted: `images:write`, `videos:write`, `balance:read` (**not** `chat:write` — LLM spend is opt-in).

---

## 3. Scopes

| Scope | Grants |
|-------|--------|
| `images:write` | Create/get/list image jobs |
| `videos:write` | Create/get/list video jobs |
| `chat:write` | `POST /chat/completions` (**opt-in** — pass explicitly when creating a key) |
| `balance:read` | `GET /balance` |
| `uploads:write` | `POST /uploads` (also allowed if the key has `images:write` **or** `videos:write`) |

Missing scope → `403` with `error_code: FORBIDDEN` and `details.required`.

**Chat keys:** create with `"scopes": ["chat:write","balance:read",…]`. Default key creation does **not** include chat.

---

## 4. Endpoints overview

All paths below are relative to `https://arckep.ru/api/v1`.

| Method | Path | Scope | Status |
|--------|------|--------|--------|
| `GET` | `/models` | any valid key | Live (image + video + chat) |
| `GET` | `/balance` | `balance:read` | Live |
| `POST` | `/uploads` | `images:write` **or** `videos:write` **or** `uploads:write` | Live |
| `POST` | `/chat/completions` | `chat:write` | Live (OpenAI-compatible; sync or SSE) |
| `POST` | `/images/generations` | `images:write` | Live → `202` |
| `GET` | `/images/generations/{id}` | `images:write` | Live |
| `GET` | `/images/generations` | `images:write` | Live (list, cursor `before_id`) |
| `POST` | `/images/estimate` | `images:write` | Live |
| `POST` | `/videos/generations` | `videos:write` | Live → `202` |
| `GET` | `/videos/generations/{id}` | `videos:write` | Live |
| `GET` | `/videos/generations` | `videos:write` | Live (list, cursor `before_id`) |
| `POST` | `/videos/estimate` | `videos:write` | Live |

**Authoritative model catalog:** always call `GET /models`. Models are an **explicit** server allowlist (not auto-all studio models). Types: `image`, `video`, `chat`. New studio models stay private until added to the allowlist and cost-checked. Do not hardcode model IDs from this doc alone.

---

## 4.1 Chat completions (`POST /chat/completions`)

OpenAI-compatible chat for studio text models (token billing in RUB). Money path matches the main chat billing proxy: **audit row + balance in one transaction**, with **overdraft allowed after a successful provider call** so a completed answer is never free.

| | |
|---|---|
| Scope | `chat:write` (opt-in) |
| Body | `{ "model", "messages", "stream"?, "temperature"?, "max_tokens"?, "tools"?, "tool_choice"?, "web_search"? }` |
| `stream: false` (default) | JSON `chat.completion` (`response_model` documented) |
| `stream: true` | SSE `text/event-stream` + final `usage` |
| Preflight | Balance ≥ max(5 ₽ floor, **estimated cost** of input + max_tokens; tools/web pad estimate) |
| Post-charge | `charge_with_audit(..., allow_overdraft=True)` + `chat_token_charges` journal — **provider usage as usual** (tool/web tokens included) |
| Concurrency | Max 5 concurrent chat streams per key (separate from image/video) |
| Input limits | ≤100k chars/message, ≤64 messages, ≤200k chars total, `max_tokens` ≤16384, ≤32 tools |
| Content | Text (+ tool rounds). Multimodal image parts → 422 |
| Tools | OpenAI-format **function** tools only (`type=function`) + `tool_choice`; multi-turn `assistant.tool_calls` + `role: tool`. Native provider tools (`code_interpreter`, `file_search`, bare `web_search` tool objects, …) are **rejected** |
| Web search | `web_search: true` → provider-native search where supported: Anthropic tool, Google `GoogleSearch`, xAI `search_parameters`. **OpenAI Chat Completions path does not support native web** — **HTTP 400** (not 500). **Fee:** tokens **plus** per-search surcharge (Anthropic $0.01/search × 110 ₽/$; xAI $0.01; Google tool fee $0 + tokens). Count from provider usage when present, else 1 search floor when the flag was on |
| Free models | **Not** on default allowlist (no open free LLM proxy). Re-enable only via env `PUBLIC_API_CHAT_MODELS` as a deliberate product choice |
| Studio history | Chat completions are **not** stored as studio conversations — only balance / `chat_token_charges` |

```bash
# Non-streaming
curl -sS -X POST -H "Authorization: Bearer $ARK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-5.4-mini",
    "messages": [
      {"role": "system", "content": "You are concise."},
      {"role": "user", "content": "What is 2+2?"}
    ],
    "stream": false,
    "temperature": 0.2,
    "max_tokens": 256
  }' \
  https://arckep.ru/api/v1/chat/completions | jq .

# Streaming (SSE)
curl -sSN -X POST -H "Authorization: Bearer $ARK_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"xai/grok-4.3","messages":[{"role":"user","content":"Hi"}],"stream":true}' \
  https://arckep.ru/api/v1/chat/completions
```

### Response shape (`stream: false`)

```json
{
  "id": "chatcmpl-…",
  "object": "chat.completion",
  "created": 1750000000,
  "model": "openai/gpt-5.4-mini",
  "choices": [
    {
      "index": 0,
      "message": {"role": "assistant", "content": "4"},
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 24,
    "completion_tokens": 1,
    "total_tokens": 25,
    "cost_rub": "0.0123"
  }
}
```

`usage.cost_rub` is an arckep extension (decimal string, RUB). Returned only after a real charge (or 0 for free models if you enable them). Provider usage preferred; if missing, tokens are estimated conservatively (`chars/2`, not `chars/4`) so Russian text is not under-billed. Partial streams that already produced text are still charged.

### Chat model IDs

- Prefer full registry ids from `GET /models` where `type == "chat"`.
- Bare aliases often work; **do not rely on aliases** in production.
- Image-output models are **not** on chat; use `POST /images/generations`.
- **Tools / function-calling:** pass OpenAI-format `tools` (`type=function` only) + optional `tool_choice`. When the model returns `finish_reason: tool_calls`, run tools on **your** side and continue with `role: tool` (we do not host your tools). Multiple tool results in one turn are supported (Anthropic receives them as a single user message with parallel `tool_result` blocks).
- **Web search:** set `web_search: true`. Billed as tokens + native search fee (see table). Prefer Anthropic / Google / xAI for web; OpenAI standard chat models return **400**.

### Tools example

```bash
curl -sS -X POST -H "Authorization: Bearer $ARK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-5.4-mini",
    "messages": [{"role": "user", "content": "What is the weather in Berlin?"}],
    "tools": [{
      "type": "function",
      "function": {
        "name": "get_weather",
        "description": "Get current weather by city",
        "parameters": {
          "type": "object",
          "properties": {"city": {"type": "string"}},
          "required": ["city"]
        }
      }
    }],
    "tool_choice": "auto"
  }' \
  https://arckep.ru/api/v1/chat/completions | jq .
```

### Web search example

```bash
curl -sS -X POST -H "Authorization: Bearer $ARK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "google-ai-studio/gemini-3.5-flash",
    "messages": [{"role": "user", "content": "What happened in tech news today?"}],
    "web_search": true,
    "max_tokens": 1024
  }' \
  https://arckep.ru/api/v1/chat/completions | jq .
```

### OpenAI SDK example

```python
from openai import OpenAI

client = OpenAI(
    api_key="ark_live_…",  # key must include chat:write
    base_url="https://arckep.ru/api/v1",
)
r = client.chat.completions.create(
    model="openai/gpt-5.4-mini",
    messages=[{"role": "user", "content": "Hello"}],
    # tools=[...], tool_choice="auto",
    # extra_body={"web_search": True},  # arckep extension
)
print(r.choices[0].message.content)
```

---

## 5. Job response shape (`PublicJobResponse`)

Creates return **HTTP 202**; GET returns the same shape.

```json
{
  "id": 123,
  "object": "image.generation",
  "status": "queued",
  "model": "gemini-3.1-flash-image",
  "created_at": "2026-07-27T12:00:00+00:00",
  "completed_at": null,
  "cost_rub": "4.24",
  "charged_from": "personal",
  "error": null,
  "output": null
}
```

| Field | Type | Description |
|-------|------|-------------|
| `id` | int | Job id (poll with GET) |
| `object` | string | `image.generation` or `video.generation` |
| `status` | string | `queued` \| `processing` \| `completed` \| `failed` |
| `model` | string | Model id used |
| `created_at` | string\|null | ISO-8601 |
| `completed_at` | string\|null | ISO-8601 when finished |
| `cost_rub` | string\|null | Amount in RUB (decimal string) |
| `charged_from` | string\|null | `personal` or `corporate` |
| `error` | string\|null | Failure message |
| `output` | object\|null | Present when `completed` |

### Status mapping (studio → public)

| Internal | Public |
|----------|--------|
| `pending` | `queued` |
| `generating` | `processing` |
| `completed` | `completed` |
| `failed` | `failed` |

### Image `output` (completed)

```json
{
  "images": [
    { "url": "https://arckep.ru/api/images/gen/…", "expires_in": null },
    { "url": "https://…presigned…", "expires_in": 86400 }
  ]
}
```

- App-relative delivery URLs are absolutized to `https://arckep.ru`.
- S3 keys are returned as **presigned** URLs (`expires_in` ≈ 86400 seconds). Re-fetch the job if a URL expires.

### Video `output` (completed)

```json
{
  "video_url": "https://…presigned…",
  "thumbnail_url": "https://…presigned…",
  "duration_seconds": 5
}
```

Video status is **DB-only** (no live re-poll of the provider on GET). Background poller updates the row; webhooks fire on terminal states.

---

## 6. `GET /models`

```bash
curl -sS -H "Authorization: Bearer $ARK_KEY" \
  https://arckep.ru/api/v1/models
```

```json
{
  "object": "list",
  "data": [
    {
      "id": "gemini-3.1-flash-image",
      "type": "image",
      "modes": ["generate", "edit_with_reference"],
      "aspect_ratios": ["1:1", "16:9", "9:16"],
      "resolutions": ["1K", "2K", "4K"],
      "max_images": 1,
      "supports_thinking": true,
      "pricing_hint": "same as studio (POST /images/estimate)"
    },
    {
      "id": "wan2.7-t2v",
      "type": "video",
      "name": "Wan 2.7 T2V",
      "pricing_hint": "… RUB/s",
      "aspect_ratios": ["16:9", "9:16"],
      "resolutions": ["720p", "1080p"]
    },
    {
      "id": "openai/gpt-5.4-mini",
      "type": "chat",
      "transport": "direct",
      "capabilities": ["vision", "function_calling", "streaming", "reasoning"],
      "pricing_rub_per_1m": { "input": 82.5, "output": 495.0 }
    },
    {
      "id": "moonshotai/kimi-k3",
      "type": "chat",
      "transport": "openrouter",
      "capabilities": ["vision", "function_calling", "streaming", "reasoning"]
    }
  ]
}
```

- Image models: allowlist ∩ studio dispatcher; catalog includes `aspect_ratios`, `resolutions`, `max_images`, optional `quality_levels` / `supports_thinking`. Money still via `POST /images/estimate`.
- Video models: allowlist ∩ studio `VIDEO_MODELS` with modes/duration/audio fields.
- Chat models: allowlist ≈ LobeChat paid text set. Field `transport`: `direct` (OpenAI / Anthropic / Google / xAI / **DeepSeek** / **Qwen** keys) or `openrouter` (Kimi, GLM, MiniMax, Hy3, MiMo, `openrouter/auto`, Mistral, … — same OR surface as LobeChat). Inworld is **not** used for chat. `capabilities` may include `web_search` where supported (not OpenAI Chat Completions).
- **Allowlist is explicit** (`PUBLIC_API_*_MODELS`). Treat `GET /models` as source of truth.

Example default image ids (may grow):  
`gemini-3.1-flash-image`, `gemini-3-pro-image`, `imagen-4.0-fast-generate-001`, `flux-2-pro`, `gpt-image-2`, `grok-imagine-image`, `qwen-image-2.0`, `wan2.7-image`, plus other studio models as coverage expands.

Example default video ids (may grow):  
`wan2.7-t2v`, `wan2.7-i2v`, `veo-3.1-fast-generate-preview`, `sora-2`, `kling-2.6-pro`, plus other studio models as coverage expands.

---

## 7. `GET /balance`

Scope: `balance:read`.

```json
{
  "balance_rub": "150.00",
  "is_corporate": false,
  "corporate_name": null
}
```

Uses the same **effective** balance as the studio (personal wallet or corporate org wallet when the user is on a corporate plan).

---

## 8. `POST /uploads`

Multipart upload for reference media used later as `image_url` / `reference_image_urls`.

```bash
curl -sS -X POST -H "Authorization: Bearer $ARK_KEY" \
  -F "file=@ref.png" \
  https://arckep.ru/api/v1/uploads
```

```json
{
  "key": "images/{user_id}/…",
  "url": "https://…presigned…"
}
```

| Rule | Value |
|------|--------|
| Field name | `file` |
| Max size | 10 MB |
| Types | `image/jpeg`, `image/png`, `image/webp`, `image/*`, `video/mp4`, `video/webm` |
| Response | `201` + `key` + temporary `url` |

Pass the returned `url` (or a stable public HTTPS URL you host) into generation requests. Prefer uploaded arckep URLs for reliability.

---

## 9. Images

### 9.1 `POST /images/generations` → `202`

Scope: `images:write`.

**Body (native + OpenAI-compat):**

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `model` | string | required | Must appear in `GET /models` (type=image) |
| `prompt` | string | required | 1–10000 chars |
| `n` | int | — | OpenAI-style count 1–8 |
| `num_images` | int | — | Same as `n`; if both set, `num_images` wins over `n`; else `n`; else `1` |
| `aspect_ratio` | string | null | e.g. `1:1`, `16:9`, `9:16`, `3:2`, `2:3` |
| `size` | string | null | OpenAI size → maps to aspect if `aspect_ratio` omitted: `1024x1024`→`1:1`, `1792x1024`→`16:9`, `1024x1792`→`9:16`, `1536x1024`→`3:2`, `1024x1536`→`2:3` |
| `resolution` | string | null | Model-specific (e.g. `1K` / `2K` where supported) |
| `quality` | string | null | Model-specific quality tier |
| `thinking` | bool | `false` | Higher-cost reasoning path where the model supports it |
| `reference_image_urls` | string[] | null | Edit / multi-ref inputs (HTTPS or arckep upload URLs) |
| `response_format` | `"url"` \| `"b64_json"` | `"url"` | Accepted for OpenAI clients; current jobs return URL outputs in `output.images` |
| `webhook_url` | string | null | Per-request HTTPS webhook (SSRF-checked); overrides key default for this job |

```bash
curl -sS -X POST -H "Authorization: Bearer $ARK_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "model": "gpt-image-2",
    "prompt": "product photo of ceramic mug, soft studio light",
    "n": 2,
    "size": "1024x1024",
    "quality": "low"
  }' \
  https://arckep.ru/api/v1/images/generations
```

With references:

```bash
curl -sS -X POST -H "Authorization: Bearer $ARK_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "model": "gemini-3-pro-image",
    "prompt": "same subject, golden hour outdoor",
    "reference_image_urls": ["https://…from-uploads…"],
    "webhook_url": "https://hooks.example.com/arckep"
  }' \
  https://arckep.ru/api/v1/images/generations
```

### 9.2 `GET /images/generations/{id}`

Returns `PublicJobResponse` for a job owned by the key’s user. Unknown id → `404`.

### 9.3 `GET /images/generations` (list)

List recent **public API** image jobs for the key owner. Studio UI history is never included (empty list if the key has not created any API jobs).

| Query | Description |
|-------|-------------|
| `limit` | Page size (typical 1–100, default ~20) |
| `status` | Optional filter: `queued` / `processing` / `completed` / `failed` |
| `cursor` / `offset` | Pagination when supported |

```json
{
  "object": "list",
  "data": [ /* PublicJobResponse items */ ],
  "has_more": false
}
```

### 9.4 `POST /images/estimate`

Dry-run cost using the same pricing engine as holds. Body mirrors create (without side effects). Response shape (indicative):

```json
{
  "cost_rub": "4.24",
  "currency": "RUB",
  "model": "gemini-3.1-flash-image",
  "num_images": 1
}
```

If the estimate route is not available on a given deploy, use studio pricing parity: charge amounts appear on create (`cost_rub` hold) and final job payload.

---

## 10. Videos

### 10.1 `POST /videos/generations` → `202`

Scope: `videos:write`.

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `model` | string | required | From `GET /models` (type=video) |
| `prompt` | string | required | 1–10000 chars |
| `mode` | string | `t2v` | `t2v` (text→video), `i2v` (image→video), or `auto` (infer from inputs when supported) |
| `duration_seconds` | int | `5` | 1–30; model max may be lower |
| `aspect_ratio` | string | `16:9` | e.g. `16:9`, `9:16`, `1:1` |
| `resolution` | string | null | e.g. `720p`, `1080p` when the model supports tiers |
| `with_audio` | bool | `true` | Native audio when the model supports it |
| `image_url` | string | null | **Required for `i2v`**. First/start frame |
| `last_frame_image_url` | string | null | End frame / interpolate (model-dependent) |
| `reference_image_urls` | string[] | null | Extra visual refs (multiref models) |
| `reference_video_urls` | string[] | null | Motion / style video refs |
| `reference_audio_urls` | string[] | null | Audio / lip-sync refs |
| `video_url` | string | null | Source video for edit / extend modes |
| `negative_prompt` | string | null | Where provider supports it |
| `seed` | int | null | Reproducibility when supported |
| `webhook_url` | string | null | Per-request HTTPS webhook override |

Fields beyond core `t2v`/`i2v` are accepted when the model and server coverage support them; unsupported combinations return a validation error with `error_code` / `details`.

```bash
# Text → video
curl -sS -X POST -H "Authorization: Bearer $ARK_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "model": "wan2.7-t2v",
    "prompt": "cinematic drone shot over misty mountains",
    "mode": "t2v",
    "duration_seconds": 5,
    "aspect_ratio": "16:9",
    "with_audio": true
  }' \
  https://arckep.ru/api/v1/videos/generations

# Image → video
curl -sS -X POST -H "Authorization: Bearer $ARK_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "model": "wan2.7-i2v",
    "prompt": "slow camera push-in, natural motion",
    "mode": "i2v",
    "image_url": "https://…upload-or-public…",
    "duration_seconds": 5
  }' \
  https://arckep.ru/api/v1/videos/generations
```

**Mode rules (core):**

- `i2v` requires `image_url`.
- Models that are I2V-only (id ends with `-i2v`) must use `mode=i2v`.
- Models without image input reject `mode=i2v`.

### 10.2 `GET /videos/generations/{id}`

Same `PublicJobResponse` contract; ownership scoped to the key’s user.

### 10.3 `GET /videos/generations` (list)

Same list pattern as images (`limit`, `before_id`). **Only public API video jobs** (`cost_details.public_api`); studio UI history is never returned.

### 10.4 `POST /videos/estimate`

Dry-run cost from model + duration + resolution + audio flags without charging.

---

## 11. Billing

Prices match the **studio** (RUB). Corporate members spend the **org** wallet when active (`charged_from: "corporate"`).

### Images — hold + reconcile / refund

1. On accept: **hold** estimated cost (`cost_rub` on the job).
2. On success: reconcile to actual provider cost (extra charge or partial refund).
3. On failure / safety block: **full refund** of the hold.
4. Concurrent jobs cannot overspend: hold is taken before the provider is paid.

### Videos — charge upfront + refund on start/poll failure

1. On accept: **charge full** estimated cost, create `pending` row, then start provider **without** holding a DB session.
2. If provider start fails: **refund**.
3. If poller marks the job failed: **refund** (same studio video refund path).
4. Success: keep charge; deliver video via poller + webhook.

### Insufficient funds

`402` with structured body (see Errors). `details` include required / available amounts when applicable.

---

## 12. Webhooks

### Configuration

1. **Key default:** `webhook_url` on create/PATCH of `/api/developer/keys`.
2. **Per request:** `webhook_url` on image/video create (overrides key default for that job).

Each key has a server-side **webhook secret** (minted on create). It is returned **once** in the create response as `webhook_secret` (Settings → API shows it in the same one-time modal as the API key). List/PATCH never re-expose it. To rotate without revoking the API key: `POST /api/developer/keys/{id}/rotate-webhook-secret` (new secret once). Store it to verify `X-Arckep-Signature`.

### Delivery

| Header | Value |
|--------|--------|
| `Content-Type` | `application/json` |
| `X-Arckep-Signature` | HMAC-SHA256 **hex** of the **raw body** using the key’s webhook secret |
| `X-Arckep-Event` | Event name |
| `X-Arckep-Delivery-Id` | Delivery id (retries keep identity for ops) |
| `User-Agent` | `Arckep-Webhooks/1.0` |

**Events:**

| Event | When |
|-------|------|
| `image.generation.completed` | Image job finished successfully |
| `image.generation.failed` | Image job failed (after refund) |
| `video.generation.completed` | Video job completed |
| `video.generation.failed` | Video job failed (refund path) |

Example payload (image completed):

```json
{
  "id": 123,
  "object": "image.generation",
  "status": "completed",
  "model": "gemini-3.1-flash-image",
  "cost_rub": "4.24",
  "error": null,
  "output": {
    "images": [{ "url": "https://arckep.ru/…" }]
  }
}
```

Example payload (video completed):

```json
{
  "id": 456,
  "object": "video.generation",
  "status": "completed",
  "model": "wan2.7-t2v",
  "cost_rub": "25.00",
  "error": null,
  "output": {
    "video_url": "https://…",
    "thumbnail_url": "https://…",
    "duration_seconds": 5
  }
}
```

### Verify signature (Python)

```python
import hmac, hashlib

def verify(secret: str, raw_body: bytes, signature_hex: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_hex or "")
```

### Retries & durability

- Immediate attempt + durable DB queue (`api_webhook_deliveries`).
- Retries with backoff (max attempts from server config, default **5**).
- Secret is **not** stored on delivery rows; loaded live from the key.
- Respond with **2xx** quickly; process asynchronously on your side.

### SSRF protection

Webhook URLs must be:

- **HTTPS only**
- Public DNS — **private / loopback / link-local / metadata** IPs blocked after DNS resolution
- Blocked hostnames: `localhost`, `*.local`, `*.internal`, cloud metadata hosts, etc.

Invalid webhook URL on request → validation error before job accept.

---

## 13. Idempotency

Send on **create** (`POST …/generations`):

```http
Idempotency-Key: <unique-client-key>
```

| Behavior | |
|----------|--|
| Same key + success within ~**120s** | Replays the **same 2xx body** (same job id) — no double charge |
| Same key while original in flight | `409` `REQUEST_IN_PROGRESS` |
| Missing key | Allowed (not required), but recommended for all paid creates |
| Max length | Keys that are too long → `400` |

Scope of the cache is per authenticated principal (API key user).

---

## 14. Rate limits

| Limit | Default |
|-------|---------|
| Requests per minute (RPM) | **60** per key |
| Requests per day (RPD) | **500** per key |
| Concurrent jobs | **5** per key (image accept+process slot / video accept+provider-start) |

- Per-key overrides may exist on the key row (`rate_limit_rpm` / `rate_limit_rpd`).
- **Redis unavailable → fail-closed** (requests denied). Paid traffic never becomes unlimited.
- Exceeded → `429` `RATE_LIMIT_EXCEEDED`.

---

## 15. Errors

All public API errors use one JSON envelope (English `message` for machines; plain-language notes below in Russian for humans):

```json
{
  "error_code": "INSUFFICIENT_BALANCE",
  "message": "Insufficient personal balance: need 15.00 ₽, available 5.00 ₽. Top up at https://arckep.ru/settings",
  "details": { "required": 15.0, "available": 5.0, "is_corporate": false, "currency": "RUB" }
}
```

**Always branch on `error_code`**, not on free-text `message` (wording can improve without a breaking change).

### Full catalog

| HTTP | `error_code` | What happened (EN) | По-человечески (RU) | What to do |
|------|--------------|--------------------|---------------------|------------|
| 400 | `INVALID_MODEL` | Model id not on public allowlist (subclass of validation; distinct code) | Модель недоступна через API (её нет в списке или опечатка в id) | `GET /models`, use an id from the list |
| 400 | `INVALID_PARAMETER` | One field has an illegal value (subclass of validation; distinct code) | Параметр не подходит (длина, enum, mode, Latin-only design_prompt…) | Fix `details.field` / `details.reason` |
| 400 | `VALIDATION_ERROR` | Body/schema failed (chat tools, web_search on OpenAI, limits…) | Запрос собран неправильно | Read `message` + `details`; for OpenAI `web_search` use Anthropic/Google/xAI or turn flag off |
| 400 | `SAFETY_BLOCKED` | Provider/safety policy blocked content | Промпт/медиа отклонены фильтрами безопасности | Soften prompt / change assets |
| 401 | `UNAUTHORIZED` | Missing/invalid/revoked `ark_live_` key | Ключ не принят (нет Bearer, отозван, неверный секрет) | Create a new key in **Settings → API**; secret is shown only once |
| 403 | `FORBIDDEN` | Missing scope or banned user | Нет нужного scope или аккаунт заблокирован | Recreate key with scope (`chat:write`, …) or contact support if banned |
| 402 | `INSUFFICIENT_BALANCE` | Not enough RUB (personal or corporate) | На балансе не хватает денег на операцию | Top up at `/settings`; `details.required` / `available` |
| 404 | `RESOURCE_NOT_FOUND` | Job id not found **or** not a public-api job | Задача не найдена (чужой id / студийная генерация) | Use id from create response; list only returns public API jobs |
| 409 | `REQUEST_IN_PROGRESS` / `DUPLICATE_REQUEST` | Same `Idempotency-Key` still running or already done | Повтор того же Idempotency-Key | Wait / reuse cached result; new key for a new job |
| 413 | `FILE_TOO_LARGE` | Upload exceeds size limit | Файл слишком большой | Compress / smaller asset; see `details.max_size_mb` |
| 400 | `VALIDATION_ERROR` (file type) | MIME not allowed | Тип файла не поддерживается | Use allowed image types from docs |
| 422 | `VALIDATION_ERROR` | FastAPI body schema invalid | JSON не совпал со схемой endpoint | Check required fields; `details.errors` has field paths |
| 429 | `RATE_LIMIT_EXCEEDED` | RPM, RPD, or concurrency | Слишком часто / слишком много параллельных задач | Back off; defaults 60/min, 500/day, 5 concurrent jobs / 5 chat streams; `details.limit` |
| 500 | `GENERATION_FAILED` / `INTERNAL_ERROR` | Provider or server failure | Внутренняя/провайдерская ошибка | Retry with backoff; do not assume charge without completed job / usage |
| 503 | `SERVICE_UNAVAILABLE` | Public API flag off or dependency fail-closed | API временно выключен | Retry later |

### Notes

- **Redis down** on paid public paths → fail-closed as `429` / service denial (never unlimited free spend).
- **Chat money-safe path:** successful provider answers can still charge even if balance goes slightly negative (`allow_overdraft` after success); preflight still blocks empty-balance starts.
- **Studio vs public jobs:** `GET …/generations/{id}` only returns rows created via public API (`public_api` marker). Studio history is separate.
- Idempotency middleware may wrap some conflicts; treat both `error_code` at top level and nested `detail` the same if present.

---

## 16. Polling vs webhooks

| Approach | Recommendation |
|----------|----------------|
| Webhooks | Preferred for production automation |
| Polling | `GET …/generations/{id}` every 2–5s for images; 5–15s for videos |
| Timeout | Client-side timeout recommended (images often &lt; 2 min; videos up to hours for heavy models; server video poller timeout refunds) |

Do not spin faster than rate limits allow across many keys.

---

## 17. Curl cookbook

```bash
export ARK_KEY=ark_live_…
export BASE=https://arckep.ru/api/v1

# Models
curl -sS -H "Authorization: Bearer $ARK_KEY" $BASE/models | jq .

# Balance
curl -sS -H "Authorization: Bearer $ARK_KEY" $BASE/balance | jq .

# Upload reference
UP=$(curl -sS -X POST -H "Authorization: Bearer $ARK_KEY" \
  -F "file=@./input.png" $BASE/uploads)
echo "$UP" | jq .
REF_URL=$(echo "$UP" | jq -r .url)

# Image with idempotency
JOB=$(curl -sS -X POST -H "Authorization: Bearer $ARK_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: demo-$(date +%s)" \
  -d "{\"model\":\"gemini-3.1-flash-image\",\"prompt\":\"cat astronaut\",\"num_images\":1}" \
  $BASE/images/generations)
echo "$JOB" | jq .
ID=$(echo "$JOB" | jq -r .id)

# Poll
while true; do
  S=$(curl -sS -H "Authorization: Bearer $ARK_KEY" $BASE/images/generations/$ID)
  echo "$S" | jq -c '{status,cost_rub,error}'
  st=$(echo "$S" | jq -r .status)
  [[ "$st" == "completed" || "$st" == "failed" ]] && break
  sleep 2
done

# Video T2V
curl -sS -X POST -H "Authorization: Bearer $ARK_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: vid-$(date +%s)" \
  -d '{"model":"veo-3.1-fast-generate-preview","prompt":"waves on a beach, golden hour","mode":"t2v","duration_seconds":5}' \
  $BASE/videos/generations | jq .
```

### Create API key (studio JWT)

```bash
# Requires studio login cookie / Authorization: Bearer <JWT>
curl -sS -X POST https://arckep.ru/api/developer/keys \
  -H "Authorization: Bearer $STUDIO_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "production-n8n",
    "scopes": ["images:write","videos:write","chat:write","balance:read","uploads:write"],
    "webhook_url": "https://hooks.example.com/arckep"
  }' | jq .
# Store response.key immediately — it is not shown again.
# Note: omit chat:write unless you need LLM completions.
```

---

## 18. Design notes for LLM agents

1. **Always** `GET /models` before picking a model id (`type`: `image` \| `video` \| `chat`).
2. Prefer `Idempotency-Key` (UUID) on paid **image/video** creates (not required for chat).
3. Treat image/video jobs as async: create → poll or webhook → read `output`. Chat is synchronous (or SSE stream).
4. Money is RUB strings (`cost_rub` / `usage.cost_rub`); never assume USD.
5. Webhooks: verify `X-Arckep-Signature` over raw body bytes (image/video only).
6. Do not send private IP webhook URLs — they are rejected.
7. Upload assets with `POST /uploads` instead of huge base64 in prompts.
8. Image OpenAI-compat clients may use `n` + `size`; native clients may use `num_images` + `aspect_ratio`.
9. Video `mode=i2v` without `image_url` fails validation.
10. Rate limits are per key; share keys carefully across workers.
11. Chat needs scope `chat:write`. For OpenAI SDKs set `base_url=https://arckep.ru/api/v1` and model ids from the chat catalog.

---

## 19. Related links

| Resource | URL |
|----------|-----|
| Product | https://arckep.ru |
| Human guide (RU) | https://arckep.ru/developer/docs |
| Alias | https://arckep.ru/docs/api.md |
| LLM site index | https://arckep.ru/llms.txt |
| Studio settings / keys | https://arckep.ru/settings |
| Support | googlmen1057@gmail.com (include job id + `error_code`) |

---

*Prices and model availability match the live studio configuration. When in doubt, call `GET /models` and `GET /balance`.*
