Skip to content
Kumquat
Esc
↑↓navigate↵open⌘Jpreview
On this page

Інтеграційний 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-схема про самі колекції, Воркфлоу задач про статуси, з яких пак потрапляє у фід, і Журнал аудиту про рядки, які лишає запис запуску.

Was this page helpful?