Кредиты списывает один маршрут — POST /v1/generations. Он требует заголовок Idempotency-Key, и именно этот заголовок делает повтор после потерянного ответа безопасным: сеть оборвалась, клиент не узнал, дошёл ли запрос, отправил его ещё раз — и получил ту же генерацию, а не вторую оплаченную.
Маршруту нужна область generations:write, а аккаунту — активный платный тариф: без него новый запрос отвечает 402 с кодом payment_required, а точный повтор уже принятого ключа по-прежнему возвращает сохранённый ответ. Ни один другой маршрут ключа не требует и ничего не списывает.
Ключ обязателен
В отличие от многих API, здесь заголовок не опция: запрос без него отклоняется до того, как что-то будет создано или списано. Раньше него отвечают общие проверки — доступ, оплаченность аккаунта, лимит запросов и разбор тела, — так что отказ по ним придёт вместо этого. Повтор, пришедший без ключа, неотличим от второго запроса, а разница между ними — второе списание.
curl -s -i -X POST https://api.coremind.ru/v1/generations \
-H "Authorization: Bearer cmk_..." \
-H "Idempotency-Key: 8f3c1d2e-5a47-4b90-9a1e-2f6b0c7d4e11" \
-H "Content-Type: application/json" \
-d '{"type": "image", "model": "<model_id>", "prompt": "..."}'Без заголовка тот же запрос не доходит до работы:
HTTP/1.1 400 Bad Request
{
"error": {
"code": "invalid_request",
"message": "The request parameters are not valid.",
"user_message": "Параметры запроса некорректны.",
"request_id": "8f3c1d2e-5a47-4b90-9a1e-2f6b0c7d4e11",
"details": [
{
"field": "Idempotency-Key",
"reason": "required",
"message": "This header is required on every request that spends."
}
]
}
}Что идентифицирует ключ
Значение выбирает вызывающий — берите UUID. Ключ действует в пределах вашего аккаунта и одного type из тела запроса: один и тот же ключ на image и на video — два разных запроса.
Ключ привязан не только к себе, но и к телу, с которым пришёл первым. Отсюда три исхода:
- тот же ключ и то же тело — повтор первой отправки, списания нет. Одно исключение: отменённая до старта работы генерация освобождает свой ключ намеренно — тот же ключ после отмены создаёт новую генерацию и считается заново, ровно чтобы отменённое можно было отправить снова;
- тот же ключ и другое тело — отказ
409, ничего не создано; - новый ключ — новый запрос, который считает цену заново, даже если тело совпадает байт в байт: столько же кредитов, либо бесплатная квота или промо-режим, если они покрывают этот запрос. Второй дубль того же промпта получают именно так.
Что возвращает повтор
Ответ на принятую отправку — 202, никогда 201: строка создана, работа ещё нет. Location указывает на GET /v1/generations/{id} — маршрут, который отдаёт результат; о смене статуса сообщает поток событий. Поверхность, которую этот API не отправляет, отвечает 503 с кодом submit_unavailable, называет type и ничего не списывает.
HTTP/1.1 202 Accepted
Location: /v1/generations/<generation_id>
{
"id": "<generation_id>",
"type": "image",
"status": "PENDING",
"model_id": "<model_id>",
...
}Повтор с тем же ключом и тем же телом отвечает так же: тот же 202, тот же Location и та же самая запись — в том статусе, в котором она сейчас. Отдельной формы «уже сделано» здесь нет намеренно: клиент, которому на повтор отвечают иначе, учится не повторять.
Статус ответа при этом остаётся 202, каким бы ни был статус самой записи. Повтор по генерации, которая успела провалиться, — это 202 с записью в статусе FAILED и заполненным error.reason: причину нужно читать из тела, а не выводить из HTTP-статуса. Словарь причин — на странице кодов ошибок.
Конфликт ключа
Ключ, уже занятый другим телом, отвечает 409 с кодом duplicate_key_conflict. В details названы поле Idempotency-Key, причина in_use и генерация, за которой этот ключ закреплён. Чинится это не повтором, а новым ключом.
HTTP/1.1 409 Conflict
{
"error": {
"code": "duplicate_key_conflict",
"message": "This Idempotency-Key was already used with a different request body.",
"user_message": "Этот Idempotency-Key уже использован с другим телом запроса.",
"request_id": "8f3c1d2e-5a47-4b90-9a1e-2f6b0c7d4e11",
"details": [
{
"field": "Idempotency-Key",
"reason": "in_use",
"message": "This key already identifies generation <generation_id>."
}
]
}
}Повторы на остальных маршрутах
Ключ нужен только отправке, потому что списывает только она. У остальных записывающих маршрутов повтор устроен по-своему:
POST /v1/pricing/estimateничего не создаёт и не тратит кредитов, но расходует общий лимит запросов, как любой вызов/v1. Что он отвечает, описано в быстром старте.POST /v1/uploadsподписывает ровно один объект: каждый вызов выдаёт новый билет, а повторныйPUTпо уже записанному адресу отклоняет само хранилище. Повторять нужно с новым билетом, а не с тем же.POST /v1/generations/{id}/reportпринимаетclient_msg_id— UUID, по которому повтор возвращает уже заведённое обращение вместо второго. Подробности — на странице кодов ошибок.POST /v1/support/messagesтоже принимаетclient_msg_id— UUID: повтор возвращает уже отправленное сообщение с теми же картинками вместо второго, а тот же идентификатор с другим текстом или другим числом картинок отвечаетsupport_replay_mismatch. Картинки — этоreference-и изPOST /v1/uploads.DELETE /v1/generations/{id}отменяет генерацию, пока её ещё не забрали в работу. Повтор на уже удалённой записи — успех, а не конфликт. Ответ{ refunded }сообщает, вернулись ли кредиты: маршрут сообщает о движении, а не решает его. Запись, которую уже забрал исполнитель, отвечаетgeneration_in_flight, придержанная администратором —generation_frozen. Завершённая запись удаляется вместе с готовыми файлами, как в интерфейсе. Маршруту нужны та же областьgenerations:writeи тот же активный тариф, что и отправке: ключ, которому нельзя списывать, не может и возвращать.