Інтеграційний API
Публічний контракт для сторонніх платформ — фід завершених паків, звіт про запуск (покреативний або цілим паком) і пошук креативів за іменем чи id кампанії разом із їхнім брифом.
Сторонні платформи (наприклад маркетингова, що заливає креативи в Meta, TikTok чи Google)
читають Kumquat через окремий набір ендпоінтів під /api/v1/integrations/creative-tests.
На їхньому боці живе SDK, згенерований з нашого OpenAPI, тому все під цим префіксом —
опублікований контракт, а не внутрішня схема.
Реалізація — kumquat_api/routes/integrations.py (роутер), kumquat_api/schemas/integrations.py
(контракт і проєкція на нього), kumquat_auth/integrations.py (авторизація). Запити до даних —
TasksRepository.find_completed_page і CreativesRepository.with_media_for_tasks; записи запуску —
CreativesService.mark_launched і TasksService.mark_pack_launched; пошук за хендлами —
CreativesService.lookup_ad_refs плюс TasksService.briefs_by_ids для брифа пака і
UsersService.users_by_ids для task owner / assignee.
Чому окремий контракт, а не внутрішні моделі
Task і Creative змінюються щотижня разом із дошкою; SDK на іншому боці — ні. Тому назовні
віддаються власні моделі KumquatPack / KumquatCreative / KumquatAsset, які говорять
словником того, хто заливає рекламу:
- пак має
locale(усередині цеlanguage) — на пак будується одна кампанія, іnameстає її назвою; media_typeкреатива — це лишеstaticабоvideo, замість шести значень нашої таксономії;product_name— це слаг продукту (products._id), той самий, що в пакуproduct_id.
Поле, яке з’явиться на внутрішній моделі, у цей контракт не протікає — проєкція перелічує кожне поле явно.
Авторизація
Не Clerk: викликає машина, тож немає ні сесії, ні користувача, ні ролі. Замість цього —
статичний bearer-токен зі INTEGRATIONS__TOKENS: список пар client:token, розділених комами.
INTEGRATIONS__TOKENS=marketing:<token>,partner:<token>
- Ім’я клієнта — це те, що потрапляє в
audit__logsякactor_id(integration:marketing,actor_kind = system), тому по журналу видно, яка саме платформа зробила запис. - Токени порівнюються через
secrets.compare_digestз усіма налаштованими, а не шукаються в словнику: відповідь не має залежати від того, наскільки токен вгаданий. - Порожня змінна означає, що інтеграційний API вимкнений — усі запити отримують 503, а не 401. Це різні відповіді свідомо: 401 сказав би клієнту, що проблема в його токені.
- Залежність змонтована на роутері, а не на кожному хендлері, тому новий ендпоінт під цим префіксом не може виїхати без авторизації.
- У
kumquat-cli show-settingsзначення замасковане автоматично —tokensпідпадає підSECRET_HINTS.
Як видати токен
Сам токен генерується будь-чим криптографічно стійким, але має лишитися в алфавіті, який
переживе і .env, і розбір значення:
uv run python -c "import secrets; print(secrets.token_urlsafe(32))"
token_urlsafe дає тільки [A-Za-z0-9_-]. Це не косметика: кома розділяє клієнтів у списку,
тож токен із openssl rand -base64 (+/=) або з комою розпадеться на два непрацездатні
записи. Двокрапка в самому токені безпечна — ім’я клієнта відрізається по першій.
Ім’я клієнта вибирається тут і ніде більше не реєструється: воно існує лише для журналу
аудиту, тому має читатися в ньому як назва платформи, а не як client1.
Куди вписати:
- Локально — рядок у
backend/.env(заготовка є вbackend/.env.dist). Його читає іuvicornнапряму, боDevAppSettings.env_file— це.env, і контейнериapi/worker, боdocker-compose.yamlпідключає той самий файл черезenv_file. - У кластері — звичайна змінна оточення поду API, зі секрета, з якого він бере решту
конфігу.
.env.prodу прод-образі немає.
Перевірка, що змінна доїхала до процесу:
uv run kumquat-cli show-settings | grep INTEGRATIONS
# INTEGRATIONS__TOKENS *** set
set у колонці джерела означає, що значення прийшло з оточення. Далі — живий запит; у
прод-налаштуваннях docs_url і openapi_url вимкнені, тому перевіряти зручніше в dev. У
docker compose API слухає 8081, а не 8000 (docker-compose.yaml мапить 8081:8000):
curl -s -D- -H "Authorization: Bearer <token>" \
"http://localhost:8081/api/v1/integrations/creative-tests/packs?limit=1"
Тіло відповіді на 401 називає причину, і це єдиний швидкий спосіб її звузити:
Missing bearer token— хедера в запиті не було. У Swagger UI під цим префіксом працює тільки схемаIntegrationToken: токен, вставлений у полеHTTPBearer(це Clerk), до/integrations/*не додається взагалі.Invalid integration token— хедер дійшов, значення не збіглося. Найчастіше в нього скопійовано всю паруclient:tokenзамість самого токена, або процес ще тримає попереднє оточення.Invalid token: …— це вже Clerk, тобто запит пішов не на/integrations/*, а на звичайний ендпоінт, який інтеграційний токен не відкриває.503замість 401 — змінної в процесі немає взагалі..envчитається при старті, аenv_fileзастосовується при створенні контейнера, тому після правки потрібенdocker compose up -d --force-recreate api:--reloadперечитує код, а не оточення.
200 з порожнім [] — не проблема авторизації: у базі просто немає завершених паків із
засинхроненими файлами.
Ротація
Токени живуть у налаштуваннях, тому заміна — це зміна секрета й редеплой; колекції ключів із
хешами й revoked_at немає. Щоб платформа не втратила доступ на час перемикання, старий і
новий токен якийсь час співіснують під різними іменами:
INTEGRATIONS__TOKENS=marketing:<new>,marketing-old:<old>
Після того, як у журналі перестав з’являтися integration:marketing-old, старий рядок
прибирається. Імена в журналі — єдиний спосіб побачити, що SDK уже перемкнувся.
GET /integrations/creative-tests/packs
Віддає паки, робота над якими завершена — статуси done і archived — від найновішого
завершення до найстарішого, кожен разом із креативами, у яких є файл.
Фільтри: project (продукт), source, geo, locale, media_type (static / video),
created_after. Пагінація — limit (до 200) і cursor; наступний курсор приходить у хедері
X-Next-Cursor, його відсутність означає кінець стріму.
Що варто знати про поведінку фіда:
created_afterфільтрує поcompleted_at, а не поcreated_at. Це курсор для полінгу: платформа запитує «що завершилося після того, що я вже бачив».completed_atставить хендлер переходу в Done (kumquat_tasks/workflow/handlers.py), тому пак, який ніколи не проходив воркфлоу, його не має — такі паки приходять у хвіст keyset-сортування замість того, щоб зникнути з фіда.media_typeзастосовується до паку, а не до файлів усередині. На паку це оголошена таксономія, томуvideo— цеVID+ANI, аstatic—STA. У самого креативаmedia_typeнатомість виводиться з реального файла (media.kind), тожVID-картка, яка виявилася картинкою, віддається якstatic.- Креатив без блока
mediaне віддається — Drive-файл ще не засинхронізовано, заливати нічого. Якщо в паку не лишилося жодного такого креатива, пак не віддається взагалі: порожній пак у відповіді неможливо відрізнити від паку, який втратив креативи. - Пак без
localeне віддається.language— обов’язкове для автоматизації поле (REQUIRED_FOR_AUTOMATION), а без нього SDK не має на що націлити кампанію.geo— необов’язкове і в нашій моделі, і в контракті. KumquatAsset.url— цеmedia.original_url, оригінал повної якості, а неmedia.url(для відео там транскодованийproxy.mp4для плеєра).file_name= ім’я креатива плюс розширення з ключа в S3, бо наші імена зберігаються без розширення, а Meta та S3 його вимагають.- Відповідь — голий
list[KumquatPack], без загальної обгорткиResponse: контракт, з якого генерується SDK, не має змушувати клієнта розпаковувати ще один шар.
POST /integrations/creative-tests/creatives/launched
Платформа звітує, які креативи вона запустила — пачкою (до 500 за виклик), кожен за id або
за канонічним name. Тіло — {"creatives": [{"id": "...", "launched_at": "...", "ad_id": "..."}]};
launched_at необов’язковий і за замовчуванням — час обробки запиту, ad_id теж необов’язковий.
Кожному креативу ставиться launched_at і ad_id, і якщо його ще ніхто не тестував,
test_status переходить not_tested → in_test.
- Ідемпотентно по
launched_at. Креатив, який уже має цю дату, її зберігає і повертається зі статусомalready_launched. Це не помилка: d7-вікно метрик прив’язане до першого запуску, і повторно надіслана пачка не має його зсувати. ad_id— це id кампанії на боці рекламного кабінету, який Kumquat не генерує і не розбирає, а лише зберігає (creatives.ad_id), щоб від картки можна було дійти до її кампанії. Перемагає останній звіт, що його несе. Це єдине, що повтор пачки ще може змінити — картку запустили тоді, коли id кампанії ще не був відомий, і наступний звіт його дописує, або її перезапустили під іншим оголошенням, і звіт переписує старий id.launched_atпри цьому не зсувається. Звіт безad_idзбережений id не чіпає. Статус лишаєтьсяalready_launched, бо він називає запуск, а в поліad_idвідповіді завжди те, що зараз збережено.- Креатив зі статусом
testedабоpausedцей статус зберігає — він уже проходив тест, тожin_testбув би кроком назад. - Ніколи не 404. Пачка звітується цілком, тому невідомий креатив отримує
not_foundу своєму рядку, а решта записується. Некоректнийid— це 422 на весь запит, бо це баг клієнта, а не стан даних. - Кожен запис проходить через
@audited("creative.launched"), тому в журналі видно і платформу, і попереднє значення. Запис самого лишеad_id— це окрема діяcreative.ad_id_recorded, щоб у історії картки не з’являвся другий «запуск».
POST /integrations/creative-tests/packs/launched
Те саме, але цілим паком — для платформи, яка запускає кампанію разом, а не картку за карткою.
Тіло — {"packs": [{"id": "...", "launched_at": "...", "ad_id": "..."}]}, пак називається за
id або за іменем. Ліміт — 100 паків за виклик, менший за 500 креативів у попередньому, бо
кожен пак розгортається у всі свої картки.
Записується в обидва боки:
- паку —
launched_at(нове полеtasks.launched_at),ad_idіtest_status: not_tested → in_test; - кожному креативу пака із засинхроненим файлом — той самий
launched_at, той самийad_idі те саме правило дляtest_status.
Охоплюються саме ті картки, які фід віддав під цим паком. Креатив без блока media заливати не
було чого, тож він лишається незайманим — і в рядку відповіді його немає.
Статус на кожен пак:
| Статус | Що сталося |
|---|---|
launched |
пак і його картки заштамповані; creatives перелічує кожну |
already_launched |
у пака вже є launched_at — крім ad_id зі звіту, не записано нічого; creatives порожній |
not_found |
такого пака немає (або він видалений) |
not_launchable |
фід цього пака ніколи не віддавав (див. нижче) |
ambiguous |
ім’я збігається більш ніж з одним живим паком; не записано нічого |
Що варто знати:
- Запуск пака перезаписує
launched_atкреатива, на відміну від покреативного звіту, де перемагає перший. Реклама пака виходить разом, тож усі його картки штампуються одним моментом, і звіт по паку вважається авторитетнішим із двох. Ціна цього рішення: якщо картку до того запустили окремо, її d7-вікно зсувається. - Повтор пачки не рухає запуск. Пак із
launched_atне тільки зберігає його, а й не дає перештампувати свої картки: другий звіт до них навіть не доходить. Пак пишеться першим саме тому, що він тут сторож, і пишеться через compare-and-set (launched_at: Noneу фільтріTasksRepository.mark_launched) — інакше дві паралельні копії однієї пачки пройшли б перевірку обидві й перештампували картки. - Виняток —
ad_id. Якщо звіт його несе і він відрізняється від збереженого, він записується паку і всім його карткам із засинхроненим файлом — перемагає останній звіт. Так пак, запущений до того, як id кампанії став відомий, його отримує, а перезапущений під іншою кампанією — переприв’язується. Звіт безad_idзбережений id не чіпає. У відповідіcreativesпри цьому лишається порожнім: запуск, який називає цей рядок, не цього виклику. В аудиті цеtask.ad_id_recordedна пак і одинcreative.bulk_ad_idна каскад. - Запуск пака перезаписує
ad_idкартки — з тієї ж причини, що йlaunched_at: один пак це одна кампанія, тож id пака важливіший за той, який картка встигла повідомити сама. ambiguousіснує, бо імена паків не унікальні — унікальний індекс наtasks.nameсвідомо знято (_drop_legacy_unique_name). Пошук іде і поgenerated_name, і поname, бо клієнт тримає те ім’я, яке віддавdisplay_name, і зворотно вивести з рядка, котре з двох це було, неможливо. Якщо збігів більше одного, не записується нічого: змусити платформу назвати пак заidдешевше, ніж вгадати не той пак і перештампувати чужі картки.- Креатив зі статусом
testedабоpausedцей статус зберігає — те саме правило, що й у покреативному звіті. not_launchable— це рівно те, що ховає фід, а не лише «не вdone». Умова одна на двох і живе вTask.offered_to_integrations: завершений статус, єlocale, неis_legacy, неmanual_launch. Найгостріший тут останній — пак ізmanual_launchнавмисно прибраний із фіда, а по Slack хтось отримав «Done — needs a manual launch», тож звіт платформи про його запуск суперечив би самому змісту прапорця.TasksRepository.find_completed_page— та сама умова у вигляді запиту, і послаблювати їх можна тільки разом.- Ніколи не 404, як і в попередньому ендпоінті:
not_found,not_launchableтаambiguous— це стан даних, тому вони приїжджають у рядку свого пака з кодом 200, а решта пачки записується. 422 лишається за клієнтськими багами — некоректнийid, порожня пачка, пак без жодного імені. - В аудиті це два різні записи:
task.launchedна сам пак і одинcreative.bulk_launched(entity_type = task) на весь каскад, а не рядок на кожну картку.
POST /integrations/creative-tests/creatives/lookup
Зворотний бік двох попередніх ендпоінтів: за canonical-іменем креатива віддає ad_id, за ad_id —
креативи, які під ним вийшли, і в обидва боки — бриф, за яким кампанію називають і звітують.
Тіло — {"names": [...], "ad_ids": [...]}, обидва списки необов’язкові поодинці, але хоча б один
має бути непорожній; по 500 значень на список.
POST, хоча це читання: пачка з 500 імен в URL не поміститься, а половина по ad_id коштує один
запит на виклик замість одного на значення.
Відповідь — рядок на кожен переданий хендл, у тому ж порядку (спершу names, потім ad_ids):
[{"query": "CNI_0001_Done_Vid_9x16_v1", "handle": "name",
"creatives": [{
"id": "...", "name": "CNI_0001_Done_Vid_9x16_v1", "ad_id": "camp-77",
"locale": "EN", "geo": "US",
"subject": "sleep", "topic": "bedtime", "subtopics": ["wind-down"],
"primary_anchor": "desire_led", "audience": "new parents",
"awareness_stage": "problem_aware", "messaging_angle": "trust_driven",
"hook_trigger": "curiosity_gap", "hook_type": "confession", "ad_format": "asmr",
"tags": ["hook-a"],
"traffic_flow": "W2A", "landing": null, "funnel": null, "cpp": null,
"varied_elements": ["hook"],
"task_owner": {"name": "Polina Tarasenko", "initials": "TP"},
"assignee": {"name": "Tetiana Rozhko", "initials": "RT"},
"created_at": "2026-07-20T09:30:00Z"
}]}]
creatives— список в обидва боки, бо напрямки не симетричні: ім’я унікальне й дає щонайбільше одну картку, аad_idпісля запуску пака лежить на всьому паку й дає всі його картки. Поле, що означало б різне залежно від рядка, читалося б гірше, ніж список із одного елемента.- Бриф ширший, ніж у фіда, і це навмисно.
KumquatCreativeописує те, що збираються запускати, а цей рядок — те, що вже живе: звіт групується за більшим, ніж потрібно знати запуску. Сюди входятьlocale/geo, product stack (subject,topic,subtopics), creative stack (primary_anchor,audience,awareness_stage,messaging_angle,hook_trigger,hook_type,ad_format,tags), marketing funnel (traffic_flow,landing,funnel,cpp) іvaried_elements. Вільного тексту немає:notesі бриф варіації — це те, чим команда говорить сама з собою, а не те, за чим рекламний кабінет може групувати. landing,funnelіcppпоки що завждиnull. Їхні енами оголошені, але порожні («options TBD» на моделях), тож жодне значення в них не зберігається — фід віддає ці самі три на тих самих умовах. Поля в контракті є, щоб клієнт не ламався в день, коли словник заповнять.varied_elements— це поле пака, не картки. НаCreativeтакого поля немає взагалі: що варіює конкретна картка, лежить у їїtags(rollup зTaskVariant.tag_slugs).- Решта блока падає на пак, якщо картка свого не має — той самий rollup-фолбек, що робить
_to_creativeдля фіда: бриф записується на картку при створенні, тож картка, старша за поле, свого значення не несе. Виняток —tags: воно per-variation, і на паку відповідника немає. Паки читаються один раз на всю пачку, а не на рядок. task_ownerіassignee— власні поля картки, без фолбеку на пак: assignee ставиться на картку окремо, на Tech/Creative Check, і в пака може бути інший. Кожен —nameіinitialsз дзеркалаusers, включно з видаленими (звіт про те, хто робив тоді).null, якщо на картці нікого немає або id не знайдено. Користувачі читаються одним запитом на всю пачку.created_at— дата створення картки.- Ніколи не 404. Хендл, якого ніхто не несе, отримує порожній
creativesу своєму рядку — так само, як невідомий креатив у звіті про запуск. 422 лишається за баги клієнта: порожній хендл, пачка понад ліміт, тіло без жодного зі списків. - Пошук за
ad_id— це скан колекції.creativesстоїть рівно на межі 64 індексів (63 оголошених +_id, див.kumquat_creatives/indexes.py), тож слота підad_idнемає, і звільнити його можна лише ціною іншого індексу. Саме тому ендпоінт пачковий: 500 id — це один скан, а не 500. Половина поnameдо цього не входить — вона йде окремим запитом по унікальному індексу, бо одна неіндексована гілка в$orзабрала б індекс і в другої (та сама пастка, що описана вsearch.py).
Python SDK
sdk/kumquat_sdk.py — референсний клієнт одним файлом, без залежностей поза стандартною
бібліотекою (потрібен Python 3.11+). Він не пакет: його копіюють у ту платформу, що заливає
креативи, тому нема ні install-кроку, ні версії, яку треба тримати синхронною з нашою.
from kumquat_sdk import KumquatClient, Launch
kumquat = KumquatClient("https://kumquat.example.com", token="...")
for pack in kumquat.iter_packs(project="CoinIn", media_type="video"):
for creative in pack.creatives:
path = kumquat.download(creative.asset, "/tmp")
... # залити path в рекламний акаунт
kumquat.mark_launched([Launch(id=c.id) for c in pack.creatives])
iter_packs сам ходить по X-Next-Cursor до кінця стріму, get_packs — одна сторінка під ним.
mark_launched ріже список на батчі по 500 і повертає результат на кожен рядок; result.ok
хибний лише для not_found, бо повторний звіт — це успіх.
Дві речі в клієнті існують саме через граблі, на які легко наступити:
- конструктор відмовляє токену з двокрапкою, бо
client:token— це формат конфігу сервера, а не значення хедера; - імена хедерів згортаються в нижній регістр, бо
X-Next-Cursorприходить від Starlette малими літерами, і чутливий до регістру пошук тихо обриває пагінацію на першій сторінці.
Клієнт покритий tests/sdk/test_kumquat_sdk.py — тести піднімають справжній застосунок на
локальному порті й ходять у нього через urllib, тобто рівно так, як це робитиме платформа.
Це єдине, що ловить розсинхрон SDK з API: перейменоване поле не падає, а просто віддає порожнечу.
Відомі межі
- Токени живуть у налаштуваннях: відкликати один із них можна лише через редеплой (див.
ротацію вище), і немає ні окремого власника ключа, ні
last_used_at, ні скоупів — кожен токен відкриває всі чотири ендпоінти повністю. - Немає ні rate limiting, ні квот на цих ендпоінтах — вони покриваються тим самим ingress, що й решта API.
- Фід не має зворотного зв’язку про метрики: платформа звітує лише про запуск, а результати
тесту приходять до Kumquat з ClickHouse через скоркард (
kumquat-creative-metrics).
Суміжні документи — Mongo-схема про самі колекції, Воркфлоу задач про статуси, з яких пак потрапляє у фід, і Журнал аудиту про рядки, які лишає запис запуску.