Errors
Every error has the shape { "error": { "code", "message", "details"?, "request_id" } }. Every response, success or error, carries X-Request-Id; quote it when reporting a problem. Codes are generated from KONTRAKT §9 (v1.0.0).
| HTTP | code | meaning |
|---|---|---|
| 400 | validation_error | Body or query failed validation, see `details`. |
missing_idempotency_key | Every write needs an `Idempotency-Key` header. | |
missing_reason | Agent writes need `reason` (min. 3 characters) in the body. | |
| 401 | unauthenticated | No or malformed `Authorization: Bearer` header. |
token_revoked | The agent token was revoked by the owner. | |
token_expired | The agent token expired, ask the owner for a new one. | |
session_expired | - | |
session_revoked | - | |
login_token_invalid | - | |
| 403 | scope_denied | No grant for this scope × resource. The denial is audited. |
limit_exceeded | Daily or monthly limit of the grant exceeded. | |
agent_suspended | The owner suspended this agent. | |
plan_limit_exceeded | The owner's Operlance plan limit was reached. | |
plan_expired | - | |
trial_expired | - | |
ip_not_allowed | - | |
csrf_rejected | - | |
| 404 | not_found | Resource does not exist or is outside your grants. |
| 409 | idempotency_conflict | Same `Idempotency-Key`, different body. |
state_conflict | The record is in a state that does not allow this action (e.g. approval already decided). | |
| 415 | unsupported_media_type | - |
| 422 | allocation_sum_invalid | `share_bps` of a subscription's allocations must sum to 10000. |
capacity_exceeded | More usage than the subscription's capacity. | |
currency_mismatch | Two amounts must share a currency here (see Currencies). | |
currency_unsupported | The rate source does not cover this currency. | |
fx_unavailable | No exchange rate available at all, nothing was written. | |
webhook_url_invalid | - | |
| 413 | payload_too_large | - |
| 426 | contract_too_old | - |
| 429 | rate_limited | Too many requests, wait for `Retry-After` seconds. |
| 500 | internal_error | Server error, retry with the same `Idempotency-Key`; quote `request_id` when reporting. |
Rate limits
120 requests per minute per token by default. Headers X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset; on 429 wait for Retry-After seconds and retry the same request with the same Idempotency-Key.
Write outcomes are not errors
202 with { "approval": { "id", "status": "pending" } } is a successful result: the action waits for the owner. Do not retry it.
Source: KONTRAKT §9 (REST conventions) (Polish, verbatim from the contract)
#9. Konwencje REST
- Base:
/v1. JSON UTF-8. Auth:Authorization: Bearer <token>, sesja Ownera albo token agentaagt_…(te same endpointy, inne uprawnienia). - Idempotency: każdy
POST/PUT/PATCH/DELETEwymaga nagłówkaIdempotency-Key(≤128 znaków). Ten sam klucz + ten sam body → zapisana odpowiedź; inny body →409 idempotency_conflict. Odpowiedź odtworzona z klucza ma nagłówekIdempotent-Replayed: true. - Write agenta wymaga w body
reason(string, min 3). Kolejność sprawdzeń: auth → idempotency → scope → limit → (approval?) → zapis → audit (spec §50). - Wynik write agenta:
201(wykonano) albo202z{ approval: {id, status:"pending"} }gdy poziomrecommendlub przekroczony limit „bez zgody". - Koperta, wyjątki (rc.34): każda odpowiedź to
{ data, ... }albo{ error }(KONTRAKT-API §1), bez wyjątków dla danych dla klienta. Jedyne dozwolone odpowiedzi bezdata:GET /health,/internal/*(sieć prywatna), odpowiedziPOST /v1/webhooks/*dla nadawcy oraz potwierdzenia202 {status:"accepted"}bez danych (magic-link,waitlist). Pilnujescripts/lint-envelope.mjs(check.sh i CI) z jawną listą wyjątków. - Paginacja: kursory.
?limit=(1-200, domyślnie 50)&cursor=. Odpowiedź listy:{ data: [...], page: { next_cursor: string|null, has_more: bool } }. Sort domyślny:created_at desc, id desc. - Filtry:
business_id, project_id, personal, type, state, from, to, source_type, currency. ?display_currency=<ISO>na każdym endpoincie agregującym: wynik przeliczony z base_currency po kursie bieżącym (UI biznesu podajeBusiness.currency). Domyślnie base_currency.- Błędy:
{ error: { code, message, details?, request_id } }.
| HTTP | code |
|---|---|
| 400 | validation_error, missing_idempotency_key, missing_reason |
| 401 | unauthenticated, token_revoked, token_expired, session_expired, session_revoked, login_token_invalid |
| 403 | scope_denied, limit_exceeded, agent_suspended, plan_limit_exceeded, plan_expired (alias trial_expired), ip_not_allowed, csrf_rejected (proxy panelu) |
| 404 | not_found |
| 409 | idempotency_conflict, state_conflict |
| 415 | unsupported_media_type (import: binarny XLS, nie-CSV) |
| 422 | allocation_sum_invalid, capacity_exceeded, currency_mismatch (tylko §14.7), currency_unsupported, fx_unavailable, webhook_url_invalid |
| 413 | payload_too_large |
| 426 | contract_too_old |
| 429 | rate_limited (nagłówek Retry-After) |
| 500 | internal_error |
- Referencja maszynowa (1.0.0-rc.4): źródłem maszynowym
/v1jestdocs/openapi.json(OpenAPI 3.1;docs/openapi.yaml= ta sama treść dla ludzi) generowany z tej sekcji, KONTRAKT-API §6,docs/CODES.mdi schematów zod@operlance/coreprzeznode scripts/gen-openapi.mjs; razem zdocs/API-REFERENCE.mdnie jest edytowany ręcznie;--checkw check.sh/CI = test driftu (każda ścieżka z tej listy ma operację). Landing/docs/apiczyta te pliki (Frontend,sync-docs). - Wersjonowanie i deprecacja (0.8.0, R-051): prefiks
/v1zmienia się tylko przy zmianie łamiącej; w/v1dozwolone są wyłącznie zmiany addytywne (nowe pola, ścieżki, kody, wartości enum; nigdy usunięcie/zmiana typu/znaczenia). Każda odpowiedź:X-Operlance-Contract: <wersja KONTRAKT>(np.0.8.0). Wycofywanie pola/ścieżki: minimum 90 dni z nagłówkamiDeprecation: trueiSunset: <RFC 1123 date>+Link: <docs>; rel="deprecation"na dotkniętych odpowiedziach oraz wpis w Changelog; poSunsetpole znika (to jest zmiana łamiąca → tylko z/v2). Pola oznaczone w KONTRAKT-API jakoexperimental(lista w KONTRAKT-API §6) mogą zmienić się bez okresu deprecacji; pozostałe sąstable. Klient może wysłaćX-Operlance-Contract-Min: 0.7.0; niższa wersja serwera →426 contract_too_old. - Promocja
experimental→stable(1.0.0-rc.6, R-051): pole/ścieżka przechodzi zexperimentalnastable(KONTRAKT-API §6) dopiero gdy nie zmieniła się (kształt, znaczenie) przez minimum jeden pełny cykl wydania (jeden bumpx.y.0) i ma pokrycie wdocs/E2E-SCENARIOS.md; promocja to wpis w KONTRAKT-API §6 + Changelog, nigdy zmiana kształtu w tym samym kroku. Odwrotny kierunek (stable → experimental) jest zabroniony: obniżenie gwarancji dla pola jużstablewymaga zamiast tego zwykłej ścieżki deprecacji powyżej. - Parytet z MCP (§11): narzędzia i pola MCP podlegają tej samej polityce co REST. Wycofywane narzędzie dostaje
[DEPRECATED, Sunset: <data>, use: <zamiennik>]na początku opisu (description) zwracanego wtools/listi wpis w tabeli „Wycofywane” niżej; usunięcie narzędzia dopiero poSunset, wyłącznie przy bumpie@operlance/mcpzgodnym z major/minor kontraktu (§12). Nowe narzędzia i pola wyników MCP startują jakoexperimental, chyba że Changelog mówi inaczej. - Rate limit domyślny: 120 req/min per token; nagłówki
X-RateLimit-Limit/Remaining/Reset. Każda odpowiedź:X-Request-Id. Nagłówki żądania opcjonalne:X-Operlance-Contract-Min(R-051),X-Operlance-Client: <name>/<version>(rc.36, tylko token agenta, zapisujeAgent.last_client, §2.7).
- Sesja Ownera: od 0.6.0 sesje
ses_…z magic-linka (§2.19). Statyczny tokenOPERLANCE_OWNER_TOKENtylko przyAUTH_MODE=static(dev/awaryjnie). Macierz uprawnień endpoint × rola: KONTRAKT-API §5.
#Ścieżki (MVP)
GET|PATCH /v1/me (PATCH tylko Owner: {name?, timezone?, base_currency?, locale?, onboarding_step?: {key:"agent"|"integrations", status:"skipped"}}, §15.5a; ręczne `done` oraz klucze `identity`/`billing` → `400 validation_error`)
GET /v1/fx/rates ?date&base"es (kursy użyte przez system; Owner i agenci z `finances:observe`)
GET|POST /v1/businesses GET|PATCH /v1/businesses/:id
GET|POST /v1/businesses/:id/projects GET|PATCH /v1/projects/:id
GET /v1/businesses/:id/financial-summary ?from&to
GET /v1/businesses/:id/subscriptions
GET /v1/businesses/:id/forecast ?horizon=7|30|90 | from&to
GET /v1/businesses/:id/ads GET /v1/ads (owner-level: ten sam kształt + `business_id` na każdym account)
GET /v1/businesses/:id/project-health
GET /v1/businesses/:id/saas-metrics ?from&to (1.0.0-rc.3: SaasMetrics z `subscription_change` klientów; Backend B7-23; scope finances:observe)
GET /v1/businesses/:id/context
GET /v1/personal/financial-summary | /v1/personal/forecast | /v1/personal/subscriptions
GET /v1/overview (metryki top-level, upcoming, alerty)
GET /v1/free-capital ?business_id|project_id|personal&horizon_days
GET /v1/forecast (cały owner)
GET /v1/coverage
GET /v1/daily-brief ?date
GET /v1/events GET /v1/events/:id
POST /v1/expenses → event expense/actual
POST /v1/recurring-expenses → Subscription + event
POST /v1/planned-expenses → event planned_expense/planned
POST /v1/revenues
GET|POST /v1/subscriptions GET|PATCH /v1/subscriptions/:id
PUT /v1/subscriptions/:id/allocations (pełna lista, Σ=10000 bps)
GET|PUT /v1/subscriptions/:id/capacity
GET|POST /v1/budget-reservations POST /v1/budget-reservations/:id/release
POST /v1/cash-snapshots (Owner; 0.7.1: także z integracji przez `EventSink.snapshot`, patrz §9 „Salda bankowe")
GET /v1/agents/economics GET /v1/agents/:id/economics GET /v1/agents/me/economics ?from&to&business_id (0.7.1, spec §32: Owner wszystkie, agent własne)
GET|POST /v1/agents GET|PATCH /v1/agents/:id POST /v1/agents/:id/rotate-token | /revoke
GET|PUT /v1/agents/:id/passport
GET /v1/approvals GET /v1/approvals/:id POST /v1/approvals/:id/approve | /reject | /dismiss (0.9.0, prośby) (tylko Owner; approve body: {decision_note?, params_override?}; `?kind=request|write` na liście)
POST /v1/agent-decisions → event agent_action
POST /v1/ad-budget-changes (= propose_ad_budget_change; scope `ads`, min. `recommend`; przy `execute` w limitach: zapis planu + Approval auto-approved z `approval_id` w `limit_checked`; rc.36: przy zamkniętej bramce zapisu albo niepodłączonej platformie zatwierdzenie zapisuje plan z `platform_applied=false` zamiast `409`, KONTRAKT-API §2)
GET /v1/goals ?business_id|project_id|personal (MVP: zawsze `[]`)
POST /v1/permission-requests → Approval z action="grant_scope"
GET /v1/audit
GET /v1/integrations POST /v1/integrations/:provider/connect | /sync (mock w MVP) GET /v1/integrations/catalog GET /v1/public/integrations (bez auth, 0.8.1)
(rc.32, R-185) `POST /v1/integrations/:provider/connect {business_id, project_id?, credentials}` dla `stripe` (`{api_key}`), `anthropic`/`openai` (`{admin_key}`), `paddle`/`lemonsqueezy`/`dodo` (`{api_key, webhook_secret?}`), `app_store` (`{issuer_id, key_id, private_key, vendor_number}`), `google_play` (`{service_account_json, bucket}`), czyli pola wariantu z §2.20 bez `kind`. Kolejność: (1) walidacja kształtu (brak pola, zły prefiks, PEM, JSON bez `type=service_account`, przekroczony rozmiar) → `400 validation_error {reason:"credentials_invalid", field}`; (2) weryfikacja u dostawcy przez `provider.connect()` (jedno lekkie wywołanie odczytu); dostawca odrzucił klucz (401/403, brak uprawnień admin/raportów) → `400 validation_error {reason:"credentials_rejected", field?, provider_status, missing_permissions?: string[]}` (`missing_permissions` rc.35, A-9: np. Stripe restricted key bez wymaganych uprawnień odczytu); dostawca nieosiągalny / 5xx / timeout → `502 upstream_error` (Retry); (3) dopiero po sukcesie zapis `Integration` i `CredentialStore.put`. **Przy każdym błędzie nic nie jest zapisywane** (ani wiersz integracji, ani credentials, ani zmiana istniejącej integracji). Sukces: jak dziś (`201`/`200` z `Integration` + `account_label`, `credentials: {set, last4, ...}`), dla `paddle`/`lemonsqueezy`/`dodo` (i `stripe`) dodatkowo `webhook_url = <API_URL>/v1/webhooks/<provider>?integration_id=<id>` do wklejenia u dostawcy (od rc.36 także w każdym wierszu listy integracji, §2.12). Ponowny connect tego samego providera i biznesu = upsert z nowymi credentials (te same reguły).
PATCH /v1/integrations/:id {credentials: {webhook_secret}} (rc.32; tylko Owner; jedyne pole credentials zmienialne bez ponownego connect, scalane z zapisanym wariantem; dozwolone dla `stripe`/`paddle`/`lemonsqueezy`/`dodo`, inny provider lub inne pole → `400 validation_error {reason:"credentials_invalid"}`; odpowiedź bez sekretu, `credentials.updated_at` zmienione)
POST /v1/webhooks/:provider (podpis weryfikowany; bez Idempotency-Key, dedup po external_id; provider ∈ stripe (Stripe-Signature) | meta_ads (X-Hub-Signature-256) | google_ads (relay HMAC) | paddle (Paddle-Signature) | lemonsqueezy (X-Signature, HMAC-SHA256) | dodo (Standard Webhooks: webhook-id, webhook-timestamp, webhook-signature))
POST /v1/webhooks/generic/:integration_id (1.0.0-rc.8, R-129; PUBLICZNY co do Bearer, autoryzacja przez podpis: `X-Operlance-Signature: sha256=<HMAC-SHA256(raw body, secret integracji)>` + `X-Operlance-Timestamp` (tolerancja 5 min, jak Google Ads relay); `integration_id` = integracja `provider="generic"` utworzona przez Ownera (`POST /v1/integrations/generic/connect` → `{integration_id, secret}`, sekret pokazany raz, przechowywany jak inne `credentials_enc`, §2.20); body `{ events?: NormalizedEvent[], signals?: IntegrationSignal[] }` (co najmniej jedno niepuste, oba zod z core, max 500 elementów łącznie); trafia przez `EventSink.ingest`/`.signal` jak każdy adapter, z wymuszonym `source.type=IMPORT` i `source.integration_id=:integration_id` niezależnie od tego, co przyśle klient; odpowiedź jak `ingest`/`signal` połączona: `{ inserted, deduped, rejected[], signals_accepted, signals_deduped }`; zły podpis lub nieznane `integration_id` → 401, bez zapisu)
POST /v1/waitlist (PUBLICZNY, patrz „Waitlist")
GET /v1/waitlist ?format=json|csv (tylko Owner; paginacja jak wszędzie, csv = całość)
GET /v1/notifications PATCH /v1/notifications/:id POST /v1/notifications/read-all (Owner; §2.15)
GET|PATCH /v1/me/notification-preferences PUT /v1/me/notification-webhook (Owner; §2.15)
GET /v1/plan GET /v1/plan/upgrade-preview?to= POST /v1/plan {plan} (Owner; §2.16; checkout/webhook billing, miejsce, bez implementacji)
GET|POST /v1/goals GET|PATCH|DELETE /v1/goals/:id POST /v1/goal-proposals GET /v1/decision-outcomes (§2.17-2.18)
GET /v1/agents/me ?resource&format=markdown GET /v1/passport-templates POST /v1/agents/:id/passport/from-template (0.6.1; §2.7-2.8)
POST /v1/simulations (0.7.0; bez Idempotency-Key, `finances:observe`+`forecast:observe`; body = SimulationInput; koperta z coverage)
POST /v1/ask (1.0.0-rc.11, R-158; tylko Owner, bez Idempotency-Key, czysty odczyt jak /v1/simulations; `AskInput = {question: string (1-500 znaków), business_id?: uuid}`; `AskResult = {answer_type: "cashflow_summary"|"forecast"|"category_breakdown"|"goal_status"|"subscriptions"|"ads_performance"|"personal_summary"|"activity"|"unsupported", matched_intent: string, suggestions?: string[] /* tylko przy unsupported, 1-5 przykładowych pytań */, ...pola takie jak w odpowiedzi endpointu, który `answer_type` reprezentuje}` w `data`; każdy `answer_type != unsupported` woła TEN SAM kod co odpowiadający endpoint (get_business_context/forecast/category breakdown z FinancialSummary/goals/subscriptions/`/ads`/get_personal_summary/audit-dla-Ownera), więc `coverage`/`provenance_summary`/`fx` w kopercie identyczne jak tam, zero nowej logiki liczącej. Klasyfikator (Agentowy, `packages/agents`, bez modelu językowego: słowa kluczowe/wzorce) zwraca `answer_type`; nierozpoznane pytanie → `unsupported` + `suggestions`, nigdy zgadywanie liczb. `experimental` do pierwszego pełnego cyklu bez zmian (§9 promocja). Personal: dziś zawsze per-Owner-człowiek (R-110 etap 1 nie zmienia zachowania), do rewizji przy R-110 etapie 2.
POST /v1/data-requests (0.7.0; agent; → Approval 202, action connect_integration|provide_data)
GET /v1/decisions/:id/review (0.7.0; DecisionOutcome; Owner i agent-autor)
POST /v1/imports GET /v1/imports/:id POST /v1/imports/:id/confirm GET /v1/imports/presets (0.6.1; §9 „Importy")
POST /v1/integrations/google_ads/connect (1.0.0-rc.17/rc.19, R-121, uzupełnienie 0.6.1: brakowało tej strony flow) `{business_id: uuid}` (**WYMAGANE od rc.28**, `400 validation_error` gdy brak lub biznes nie należy do Ownera) → `{url}`; Owner, bez Idempotency-Key. Wyjątek od generycznego `POST /integrations/:provider/connect {credentials}` (§9, linia wyżej): Google Ads to OAuth, klient nie ma czego wysłać poza opcjonalnym `business_id` (Owner łączy z poziomu konkretnego biznesu w panelu; od rc.28 `business_id` jest obowiązkowy, bo strona `/ads` listuje tylko integracje z `business_id is not null`, więc połączenie bez biznesu było niewidoczne). `url` = Google OAuth2 authorization endpoint: `client_id=GOOGLE_ADS_CLIENT_ID`, `redirect_uri=<API_BASE>/v1/integrations/google_ads/callback`, `response_type=code`, `scope=https://www.googleapis.com/auth/adwords`, `access_type=offline`, `prompt=consent` (wymusza `refresh_token` przy każdym połączeniu), `state=<base64url({owner_id, business_id, nonce, iat})>.<hex HMAC-SHA256 tego payloadu, klucz GOOGLE_ADS_CLIENT_SECRET>` (ten sam klucz co OAuth app, bez nowego sekretu; wzorzec podpisu jak relay w §9 „Webhooki”, tolerancja 10 min zamiast 5, bo obejmuje interakcję użytkownika z ekranem zgody Google). `business_id` jedzie w `state`, bo to jedyny kanał przenoszący dane przez cały round-trip do Google i z powrotem (żadnej sesji/ciasteczka po drodze, patrz callback).
DELETE /v1/integrations/:id (1.0.0-rc.27; tylko Owner; Idempotency-Key jak każdy zapis; dozwolone także w `pending_card`, §2.16). Odłączenie: `Integration.status=disconnected`, **kasacja credentials** (`CredentialStore.delete`, §2.20; po odpowiedzi nie ma żadnego szyfrogramu ani refresh tokenu tej integracji), zatrzymanie jobów syncu, `mapping` i już zaimportowane eventy ZOSTAJĄ (dane finansowe są append-only, §2.9). AuditEntry `integration.disconnected` (`{provider, integration_id}`, bez sekretów). Odpowiedź `200` z zaktualizowanym `Integration`. Już `disconnected` → `409 state_conflict {reason:"already_disconnected"}`; nieznane/cudze id → `404`. Ponowne podłączenie tego samego providera = `POST .../connect` (upsert, nowe credentials). Nie wywołuje revoke u dostawcy (Owner cofa dostęp po stronie Google/Meta/Stripe; UI powinien to napisać)
GET /v1/integrations/:id/accounts (1.0.0-rc.26; tylko Owner; `provider=google_ads`, od rc.36 także `meta_ads` (inny provider → `400 validation_error {reason:"provider_unsupported"}`); integracja musi mieć `status=connected` (inaczej `409 state_conflict {reason:"not_connected"}`). Wywołuje Google Ads `listAccessibleCustomers` na zapisanym `refresh_token` (bez cache, limit ogólny §9) i zwraca `{ data: [{ customer_id: string (10 cyfr bez myślników), name?: string }] }`; `name` = `descriptive_name` konta, pomijane gdy Google go nie zwróci. Służy pickerowi w panelu po OAuth, gdy callback zostawił `customer_id: null`; wybór zapisuje `PATCH /v1/integrations/:id {customer_id}` (Backend odrzuca `customer_id` spoza tej listy: `400 validation_error {reason:"customer_id_not_accessible"}`). Błąd Google/sieci → `502 upstream_error`; odwołany token → `409 state_conflict {reason:"reauth_required"}`. **`meta_ads` (rc.36, Integrator):** `{ data: [{ account_id: string /* same cyfry, bez prefiksu "act_" (pole account_id z GET /me/adaccounts; id z act_ zostaje wewnętrzne) */, name?: string, currency?: string }] }` na zapisanym `access_token`; `PATCH /v1/integrations/:id {ad_account_id}` przyjmuje obie formy (z `act_` i bez), zapisuje cyfry. Błędy jak dla Google: `409 not_connected`, `409 reauth_required` (Meta `OAuthException` code 190), `502 upstream_error` z `details` = `{stage, meta_status (HTTP), meta_error_code (np. 190), meta_error_subcode? (np. 463 token wygasł, 460 zmiana hasła), meta_message, meta_fbtrace_id}`, wszystkie opcjonalne, te same zasady co `google_*` z rc.31 (tylko gdy Meta odpowiedziała, przy błędzie sieci `{stage:"network"}`, zero tokenów))
(rc.31) **Błędy dostawcy Google Ads, `details`** (dotyczy `GET .../accounts`, syncu `google_ads` i zapisów Ads przez Google; kształt `GoogleErrorDetails` z `apps/api/src/google-ads-errors.ts`): przy `502 upstream_error` oraz `409 state_conflict {reason:"reauth_required"}`, gdy odpowiedź przyszła od Google, `details` zawiera: `stage` (`oauth_code_exchange`|`oauth_token_refresh`|`list_accessible_customers`|`customer_name`|`sync`|`network`|kolejne etapy dopisywane przez Backend), `google_status` (HTTP od Google, number), `google_reason` (string|null: kod Google Ads z `errorCode`, np. `DEVELOPER_TOKEN_PROHIBITED`, `DEVELOPER_TOKEN_NOT_APPROVED`, `USER_PERMISSION_DENIED`, `CUSTOMER_NOT_ENABLED`, albo status Google, np. `PERMISSION_DENIED`, `SERVICE_DISABLED`, albo błąd OAuth, np. `invalid_grant`), `google_message` (string|null: tekst Google, maks. 300 znaków, bez HTML), `google_request_id` (string|null, do zgłoszeń w Google). Wszystkie pola opcjonalne. **`google_*` tylko wtedy, gdy Google faktycznie odpowiedział**: przy błędzie sieci lub timeoucie `details = {stage:"network"}` bez `google_status` (żadnego `0`) i bez `google_message` (komunikat wyjątku tylko do logu), przy brakującej konfiguracji serwera (`GOOGLE_ADS_CLIENT_ID/SECRET`) bez `details`. Sync `google_ads`: `packages/integrations` rzuca już typowany `HttpError {provider, status, url, body}` (eksportowany z `@operlance/integrations`, body maks. 2000 znaków, URL redagowany) i `TransportError {code, last_status}`; API mapuje `HttpError` z `provider` `google_ads*` na `googleErrorDetails("sync", status, body)`, a `TransportError` z `code` `network`/`timeout` na `{stage:"network"}`, z `retries_exhausted` na `{stage:"sync", google_status: last_status}` (bez body). **Nigdy** w `details` ani w logu: tokeny (`access_token`, `refresh_token`, `code`), `developer-token`, `client_secret`, nagłówki żądania. Panel pokazuje `google_reason` i `google_message` Ownerowi jako diagnostykę (wyjątek od reguły §1, że UI nie pokazuje `message`: to tekst dostawcy, nie nasz) z przyciskiem Retry; `google_reason` nie jest kodem Operlance i nie trafia do `docs/CODES.md` ani do `en.json`.
GET /v1/integrations/google_ads/callback (0.6.1, rozszerzone 1.0.0-rc.17/rc.18/rc.19; OAuth redirect; bez Bearer, bez ciasteczka (API nigdy nie dotyka cookie panelu, §9). CSRF pilnuje WYŁĄCZNIE podpis `state`: powstaje tylko przy uwierzytelnionym `POST .../connect` (Bearer), więc `state.owner_id`/`state.business_id` SĄ już zweryfikowaną tożsamością i wyborem Ownera, żaden dodatkowy cross-check z sesją nie jest potrzebny ani możliwy. Weryfikacja `state`: podpis + `iat` w oknie 10 min; zły/wygasły `state` → 302 do panelu z `?integration=google_ads&status=error`; sukces: wymiana `code` na tokeny u Google (server-to-server, `GOOGLE_ADS_CLIENT_ID/SECRET`), następnie **jedno wywołanie Google Ads `listAccessibleCustomers`** tym `refresh_token`, **NIGDY fallback na env `GOOGLE_ADS_CUSTOMER_ID`** dla połączenia live (ten env jest wyłącznie dla `INTEGRATIONS_MODE=mock`/dev, §2.20; użycie go dla realnego Ownera czytałoby/zmieniało cudze konto Ads): dokładnie 1 dostępny customer → `customer_id` ustawiane automatycznie w `GoogleAdsCredential`; 0 lub >1 → `customer_id: null`, Owner wybiera przez `PATCH /v1/integrations/:id {customer_id}` (lista do wyboru: `GET /v1/integrations/:id/accounts`, rc.26; pole `available_customer_ids` NIE istnieje); zapis `GoogleAdsCredential` przez `CredentialStore.put` (§2.20), `Integration` upsert `owner_id=state.owner_id, business_id=state.business_id (może być null), provider=google_ads, status=connected`, 302 do panelu z `?integration=google_ads&status=connected`; każda próba odczytu/zapisu `/ads` na integracji z `customer_id=null` → `409 state_conflict` z `details.reason="customer_id_missing"` (wzorzec jak `write_disabled`, §2.12); `business_id` w `state` jest zawsze ustawiony (od rc.28 wymagany przy connect); wiersze historyczne z `business_id=null` (sprzed rc.28) działają dalej i są podpinane przez `PATCH /v1/integrations/:id {business_id}` (Owner only, biznes musi należeć do Ownera, inaczej `400 validation_error`; dozwolone także w `pending_card`))
POST /v1/integrations/meta_ads/connect (1.0.0-rc.23, R-122, ten sam wzorzec co `google_ads/connect` powyżej) `{business_id: uuid}` (WYMAGANE od rc.28, jak `google_ads/connect`: `400 validation_error` gdy brak lub cudzy biznes) → `{url}`; Owner, bez Idempotency-Key. `url` = Meta OAuth dialog: `https://www.facebook.com/<META_GRAPH_API_VERSION>/dialog/oauth?client_id=META_APP_ID&redirect_uri=<API_BASE>/v1/integrations/meta_ads/callback&response_type=code&scope=ads_read&state=<base64url({owner_id, business_id, nonce, iat})>.<hex HMAC-SHA256 tego payloadu, klucz META_APP_SECRET>` (nowe env, jeszcze nie było: dziś jest tylko `META_WEBHOOK_VERIFY_TOKEN`, inny sekret do innego celu, subskrypcja webhooków, nie OAuth app; `META_APP_ID`/`META_APP_SECRET` to sekrety naszej aplikacji Meta, jeden dla wszystkich Ownerów, jak `GOOGLE_ADS_CLIENT_ID/SECRET`. **Wersja Graph/Marketing API (rc.36, Integrator):** z env `META_GRAPH_API_VERSION`, domyślnie `v25.0` (wcześniej w kontrakcie `v21.0`, które wg tabeli wersji Meta wygasło 9 września 2025; `v24.0` wygasa 6 października 2026), ten sam wzorzec co wersja Google Ads API; dotyczy też wywołań `GET /me/adaccounts` i odczytu kampanii), tolerancja 10 min.
GET /v1/integrations/meta_ads/callback (1.0.0-rc.23, R-122; bez Bearer, bez ciasteczka, CSRF wyłącznie przez podpis `state`, dokładnie jak `google_ads/callback`, bez powtarzania błędu z rc.17: żadnego cross-checku z sesją panelu, bo API go nie widzi). Weryfikacja `state` (podpis + `iat` 10 min) → zły/wygasły → 302 `?integration=meta_ads&status=error`; sukces: wymiana `code` na **krótkotrwały token** (Graph API `/oauth/access_token`), potem wymiana na **długotrwały token** (`fb_exchange_token`, ważny ~60 dni; Meta, w przeciwieństwie do Google, NIE ma odpowiednika `refresh_token` bez wygaśnięcia; `expires_at` z odpowiedzi Meta idzie do `CredentialStore.put(integration_id, credentials, expires_at)`, §2.20, więc `Integration.health` dostaje `auth_expired` automatycznie po czasie jak każda inna integracja z `credentials_expires_at`), następnie **jedno wywołanie `GET /me/adaccounts`** tym `access_token`: dokładnie 1 dostępne konto → `ad_account_id` automatycznie w `MetaAdsCredential`; 0 lub >1 → `ad_account_id: null`, Owner wybiera przez `PATCH /v1/integrations/:id {ad_account_id}` (lista kont do wyboru: `GET /v1/integrations/:id/accounts`, od rc.36 także dla `meta_ads`; pole `available_ad_account_ids` nigdy nie istniało w kodzie); `Integration` upsert `owner_id=state.owner_id, business_id=state.business_id, provider=meta_ads, status=connected`, 302 `?integration=meta_ads&status=connected`; `ad_account_id=null` → `409 state_conflict {reason:"ad_account_id_missing"}` na odczyt/zapis `/ads` (ten sam wzorzec co `customer_id_missing`))
POST /v1/auth/magic-link POST /v1/auth/verify POST /v1/auth/logout (§2.19; magic-link PUBLICZNY)
POST /v1/auth/register POST /v1/auth/login (1.0.0-rc.39; §2.19 logowanie hasłem; PUBLICZNE, bez Idempotency-Key; register zawsze 202, login koperta jak verify)
PATCH /v1/me/password (1.0.0-rc.39; tylko Owner, bez Idempotency-Key; §2.19)
GET /v1/auth/google POST /v1/auth/google/callback (1.0.0-rc.24, R-169; §2.19a; PUBLICZNE, tożsamość Ownera, osobny flow od R-121/§2.20 danych reklamowych)
GET /v1/me/sessions POST /v1/me/sessions/:id/revoke POST /v1/me/sessions/revoke-others (Owner; §2.19)
#Waitlist
POST /v1/waitlist, bez auth i bezIdempotency-Key(jedyny wyjątek od reguły write). Body:{ email, source?: string ≤64, website?: string }.- Odpowiedź zawsze
202 { "status": "accepted" }, także gdy email już istnieje (dedup po lower-case email,on conflict do nothing) i gdy zadziałał honeypot; nigdy nie ujawniamy, czy email był w bazie. - Walidacja: email ≤254 znaków, format zod
email(), trim + lower-case → błąd400 validation_error. Honeypot: niepustewebsite→ 202 bez zapisu. - Rate limit per IP: 5/min i 20/dobę →
429 rate_limited.user_agentz nagłówka, przycięty do 256 znaków. - CORS dla tej trasy: wyłącznie origin landingu (
WAITLIST_CORS_ORIGIN), metodyPOST, OPTIONS. Bez AuditEntry per zapis (brak ownera);GETprzez Ownera → AuditEntrywaitlist.read/waitlist.export. - CSV: nagłówek
email,source,created_at,text/csv; charset=utf-8, wartości zaczynające się od= + - @poprzedzone'(CSV injection).
#Importy CSV/TSV (0.6.1, Integrator 45-46; spec §15 „bank")
POST /v1/imports(Owner; multipart:file≤ 10 MB CSV/TSV,business_id,project_id?,preset?(GET /v1/imports/presets: presety bankowe, np.mbank,pko,revolut,generic),mapping?JSON (kolumny → pola),own_accounts?JSON (IBAN-y własne, przelewy między nimi = pominięte),currency_default?,reconcile?: boolean = true) →200 { import_id, expires_at (+1 h), preview: { total_rows, rows: { row: int, status: new|already|reconciled|rejected, matched_event_id?: string, external_id, amount_minor, currency, occurred_on, description, category?, reject_reason? }[] /* pierwsze 500 + paginacja?offsetw GET /imports/:id */, would_insert, would_dedupe, rejected[], proposals: { name, counterparty, amount_minor, currency, billing_interval, next_charge_on, occurrences, confidence }[] /* 0.8.2: wykryte cykliczne obciążenia → kandydaci na Subscription */, unmapped_columns[] } }, bez zapisu eventów. XLSX konwertuje panel po swojej stronie do CSV; binarny XLS →415 unsupported_media_type.own_accounts[](IBAN-y własne) w body podglądu wyklucza przelewy własne.POST /v1/imports/:id/confirm(Idempotency-Key; body{ mapping?, exclude_external_ids?: string[], accept_proposals?: int[] /* indeksy z preview.proposals: tworzy Subscription (recurring_expense) przez sink z provenance IMPORT, confidence z propozycji */ }) →201 { inserted, deduped, rejected[], proposals[], subscriptions_created: string[] }przezEventSink.ingest(source_type=IMPORT,integration_id= integracjamanual_importOwnera (tworzona przy pierwszym imporcie,covers=["cash"]dla presetu bankowego),batch_id = import_idwpayload) + AuditEntryimport.confirmed.GET /v1/imports/:id= status/raport.external_idimportu:import:<sha256(kolumny kluczowe)>(deterministyczny, dedup przy ponownym imporcie tego samego wyciągu). Refund z importu bezrefunds_external_iddozwolony zconfidence ≤ 0.9.- Uzgadnianie (reconcile): sink honoruje
payload.fulfills_event_id(planned → spełniony,variance_minor) ipayload.reconciles_event_id: string | string[](import dopasowany do istniejącegoactualz integracji, np. Stripe payout ↔ wpływ na koncie): event importu zapisany jakostatus=confirmed,payload.reconciled=true, wykluczony z agregatów (core nie liczy go drugi raz), ale widoczny w/eventsz oznaczeniemreconciled. Niedopasowane po 3 dniach → sygnałunreconciled(warning) + alert. Jobreconcile:daily(PERFORMANCE §5) dopasowuje po kwocie/dacie ±3 dni/walucie. - Coverage §8: domena
cash=mostly_covered, gdy ostatni import bankowy (manual_importzcovers=["cash"]) < 24 h;incompletedo 7 dni; potemmissing.
#Salda bankowe z integracji (0.7.1, Integrator 48; spec §15)
BankProvider(tylko odczyt):listAccounts() → {account_id, name, currency, iban_last4}[],listTransactions(account_id, since) → NormalizedEvent[],getBalance(account_id) → {amount_minor, currency, as_of}; provideryrevolut_business(envREVOLUT_BUSINESS_ACCESS_TOKEN),wise_business(WISE_API_TOKEN,WISE_PROFILE_ID), obaavailability=coming_soondo decyzji Właściciela; sekrety per Owner wcredentials_enc(§2.20).CashSnapshotInput = { business_id|personal, amount_minor, currency, as_of, provenance: { source: { type: source_type, integration_id, external_id } }, payload: { account_id, iban_last4? } };POST /v1/cash-snapshots(Owner, manual) przyjmuje te same pola;external_id=<provider>:<account_id>:<YYYY-MM-DD>(dedup per rachunek i dzień: powtórka tego samego dnia nadpisuje snapshot tylko, gdyas_ofpóźniejszy).EventSink.snapshot(owner_id, CashSnapshotInput[]) → { inserted, deduped, rejected[] }; snapshot z integracji madata_class=real, coverage domenacash=accurate(< 24 h),mostly_covered(< 72 h).
#Ingest z integracji (wewnętrzny, NIE publiczne endpointy)
Eventy ad_spend/refund/revenue (i inne) pochodzące z integracji nie są zapisywane przez /v1/expenses, /v1/revenues itd., tylko przez wewnętrzny sink. packages/integrations normalizuje dane dostawcy do NormalizedEvent i woła sink; implementację sinka oraz trasy /v1/integrations*, /v1/webhooks/:provider, /v1/businesses/:id/ads dostarcza Backend w apps/api. Typy żyją w @operlance/core.
interface NormalizedEvent {
integration_id: string; external_id: string; // klucz deduplikacji (unikalny w DB)
type: "revenue"|"refund"|"ad_spend"|"expense"|"recurring_expense"|"subscription_change";
state: "actual"|"planned";
amount_minor: number; currency: string; occurred_on: string; // YYYY-MM-DD
business_id: string; project_id?: string; // z Integration.business_id / mapowania
category?: string; description?: string; subscription_id?: string;
source_type: "STRIPE_API"|"GOOGLE_ADS_API"|"META_API"|"IMPORT";
confidence?: number; // domyślnie 1.0 dla *_API
payload: Record<string, unknown>; // ad_spend: {platform, account_id, campaign_id?, ad_group_id?|ad_set_id?, ad_id?, metrics?}
}
interface EventSink {
ingest(owner_id: string, events: NormalizedEvent[]): Promise<{ inserted: number; deduped: number; rejected: { external_id: string; code: "currency_unsupported"|"fx_unavailable"|"validation_error"|"currency_mismatch"; message?: string }[] }>; // currency_mismatch: refund w innej walucie niż zwracany event (§14.7)
// 0.4.2: odrzucone pojedyncze eventy trafiają do rejected[] (partial success). Sink MOŻE zamiast tego rzucić błąd z `code` z tej samej listy (np. awaria FX dla całej paczki), runner integracji obsługuje obie ścieżki.
signal(owner_id: string, signals: IntegrationSignal[]): Promise<{ accepted: number; deduped: number }>; // 0.3.0, dedup po (integration_id, external_id)
}
- Dedup:
(integration_id, external_id), powtórka z tą samąamount_minor(i currency) =deduped, bez błędu. Restatement (0.5.1, metryki Ads/refundy dostawcy się zmieniły): ta sama para, inna kwota → sink sam tworzy nowy eventexternal_id="<id>:v<n>"zpayload.supersedes_external_id="<id>"→supersedes_event_id, staryvoided; liczone jakoinserted, niededuped. Adapter może też jawnie podać:v<n>+supersedes_external_id: string | string[](0.5.2: jedna korekta, np. miesięczna, zastępuje N eventów, wszystkie wskazane →voided, nowy event mapayload.supersedes_event_ids[]). - Refund: adapter podaje
payload.refunds_external_idipayload.direction: "in"|"out"(out = zwrot przychodu klientowi, in = zwrot kosztu); sink rozwiązujerefunds_external_id→payload.refunds_event_idw obrębie tej samej integracji (brak dopasowania → event zapisany,refunds_event_id=null,confidence≤ 0.9). - Sygnały niefinansowe: adapter zwraca obok eventów
signals: IntegrationSignal[]; sink przyjmuje je drugim wywołaniemEventSink.signal(owner_id, signals). Backend mapuje każdy sygnał na Alert (kodintegration.<code>) + AuditEntry (actor_type=integration,action="signal.<code>"). Sygnały nie tworzą FinancialEvent.
interface IntegrationSignal { integration_id: string; external_id: string; code: SignalCode; occurred_at: string;
business_id: string; severity?: "info"|"warning"|"critical"; amount_minor?: number; currency?: string; ref?: Record<string, string>; message?: string }
type SignalCode = "payment_failed" | "dispute_opened" | "payout_paid" | "payout_failed" | "subscription_past_due"
| "campaign_paused" | "campaign_removed" | "budget_limited" | "ad_disapproved" | "account_suspended" | "integration_auth_expired" | "subscription_churned"
| "budget_change_applied" | "budget_change_failed" | "usage_unmapped_key" // 0.5.3 (info | critical | warning)
| "unreconciled" // 0.6.1 (warning): import bankowy bez dopasowania do actual po 3 dniach
| "revenue_drop_source" | "spend_spike_source" | "balance_low" // 0.7.1 (warning|warning|critical): liczone u źródła przez adapter: przychód 14 d < 50 % poprzednich 14 d; wydatek dnia > 2× budżet dzienny; saldo < Σ upcoming 7 dni
// źródło prawdy listy: `SIGNAL_CODES` w @operlance/core (kontrakt i core muszą być zgodne)
// domyślne severity: critical = payout_failed, account_suspended, integration_auth_expired, dispute_opened; info = payout_paid; reszta warning (w tym subscription_churned)
// Alert z sygnału: `ref = {type:"audit_entry", id}` (wpis `signal.<code>`), zatwierdzone 0.4.2
- Meta: weryfikacja subskrypcji webhooka (
GET /v1/webhooks/meta_ads?hub.challenge) tokenemMETA_WEBHOOK_VERIFY_TOKEN(0.6.1). Google Ads OAuth:GET /v1/integrations/google_ads/callback(state HMAC, code → refresh token docredentials_enc). - Webhooki: Stripe, podpis
Stripe-Signature; Meta,X-Hub-Signature-256; Google Ads nie ma natywnych webhooków →POST /v1/webhooks/google_adsprzyjmuje relay (np. Ads Script/Pub/Sub → nasz relay) podpisanyX-Operlance-Signature: sha256=<HMAC-SHA256(body, GOOGLE_ADS_RELAY_SECRET)>+X-Operlance-Timestamp(tolerancja 5 min). Zły podpis → 401, bez zapisu. - Własność typów:
NormalizedEvent,EventSink,IntegrationSignal,SignalCode,PRA(już w core) orazAdMetrics,Pacing,AdImpact(Backend dostarcza w zadaniu 9c) oraz typy kształtów z KONTRAKT-API (FxInfo,FxSummary,FxUsed,Me,Overview,OverviewForecast,HealthParamKey,HealthParam,ProjectHealth,FinancialSummary,Forecast,UpcomingCost,Alert,StatusItem) są eksportowane z@operlance/core.packages/integrationsiapps/webimportują je stamtąd, nie definiują własnych kopii. - Sink nadaje
direction(dla refund zpayload.direction),provenance(§3),status=confirmed, pisze AuditEntry (actor_type=integration), aktualizujeIntegration.last_sync_at. Webhook: weryfikacja podpisu → adapter zpackages/integrations→ sink.
Source: KONTRAKT-API §1 (envelope and amounts) (Polish, verbatim from the contract)
#1. Koperta i kwoty
- Każda odpowiedź:
{ data, coverage?, provenance_summary?, fx?, request_id }; listy dodatkowopage. - Rekord (event, subskrypcja, rezerwacja, approval, cash snapshot, alokacja): oryginał
amount_minor+currencyorazbase_amount_minor,base_currency,fx: FxInfo. Dla eventówstate=actualfx.kind="snapshot"(utrwalony), dla resztyfx.kind="current". - Agregat (każdy obiekt z sumami: Overview, FinancialSummary, FreeCapital, Forecast, PRA, /ads, ProjectHealth): pole
currency(= base_currency albodisplay_currency) + w koperciefx: FxSummary. - Szczegół użytych kursów wewnątrz agregatu (Forecast, FreeCapital, FinancialSummary):
fx_used?: FxUsed[]={ from: string, to: string, rate: string, as_of: date, source, stale: boolean }[], nazwafxwewnątrzdatajest zarezerwowana dla rekordów (FxInfo), żeby nie kolidować z kopertowymFxSummary(0.4.3).
FxInfo = { rate: string /* dziesiętny, 8 miejsc */, as_of: date, source: "ecb"|"mock"|"identity", kind: "snapshot"|"current", stale: boolean }
FxSummary = { base_currency, display_currency, as_of: date /* najstarszy z użytych kursów bieżących */, source, stale: boolean,
currencies: string[] /* waluty oryginałów w agregacie */, estimated_share: number /* 0-1: część wyniku przeliczona kursem bieżącym */ }
Me = { id, email, name, base_currency, timezone, locale /* 0.8.1 */, plan, plan_status, has_password: boolean /* rc.39 */, full_name: string|null, phone: string|null, address: {line1, line2?, city, postal_code, country}|null, profile_complete: boolean /* rc.40 */, created_at,
onboarding: { base_currency_confirmed: boolean, demo_active: boolean, steps: OnboardingStep[], next_step: "identity"|"agent"|"integrations"|"billing"|null } /* 1.0.0-rc.22, R-168, KONTRAKT §15.5a */,
unread_notifications: int, actor: { type: "owner"|"agent", id } /* 0.6.1 */ } // agent: tylko {id, base_currency, timezone, plan, actor} (bez onboarding)
OnboardingStep = { key: "identity"|"agent"|"integrations"|"billing", status: "pending"|"done"|"skipped", required: boolean, completed_at?: string }
Notification = { id, code, severity, title, body, ref: {type,id}|null, read_at: string|null, delivered: {channel, at, status, error?}[], created_at }