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).

HTTPcodemeaning
400validation_errorBody or query failed validation, see `details`.
missing_idempotency_keyEvery write needs an `Idempotency-Key` header.
missing_reasonAgent writes need `reason` (min. 3 characters) in the body.
401unauthenticatedNo or malformed `Authorization: Bearer` header.
token_revokedThe agent token was revoked by the owner.
token_expiredThe agent token expired, ask the owner for a new one.
session_expired-
session_revoked-
login_token_invalid-
403scope_deniedNo grant for this scope × resource. The denial is audited.
limit_exceededDaily or monthly limit of the grant exceeded.
agent_suspendedThe owner suspended this agent.
plan_limit_exceededThe owner's Operlance plan limit was reached.
plan_expired-
trial_expired-
ip_not_allowed-
csrf_rejected-
404not_foundResource does not exist or is outside your grants.
409idempotency_conflictSame `Idempotency-Key`, different body.
state_conflictThe record is in a state that does not allow this action (e.g. approval already decided).
415unsupported_media_type-
422allocation_sum_invalid`share_bps` of a subscription's allocations must sum to 10000.
capacity_exceededMore usage than the subscription's capacity.
currency_mismatchTwo amounts must share a currency here (see Currencies).
currency_unsupportedThe rate source does not cover this currency.
fx_unavailableNo exchange rate available at all, nothing was written.
webhook_url_invalid-
413payload_too_large-
426contract_too_old-
429rate_limitedToo many requests, wait for `Retry-After` seconds.
500internal_errorServer 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 agenta agt_… (te same endpointy, inne uprawnienia).
  • Idempotency: każdy POST/PUT/PATCH/DELETE wymaga nagłówka Idempotency-Key (≤128 znaków). Ten sam klucz + ten sam body → zapisana odpowiedź; inny body → 409 idempotency_conflict. Odpowiedź odtworzona z klucza ma nagłówek Idempotent-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) albo 202 z { approval: {id, status:"pending"} } gdy poziom recommend lub 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 bez data: GET /health, /internal/* (sieć prywatna), odpowiedzi POST /v1/webhooks/* dla nadawcy oraz potwierdzenia 202 {status:"accepted"} bez danych (magic-link, waitlist). Pilnuje scripts/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 podaje Business.currency). Domyślnie base_currency.
  • Błędy: { error: { code, message, details?, request_id } }.
HTTPcode
400validation_error, missing_idempotency_key, missing_reason
401unauthenticated, token_revoked, token_expired, session_expired, session_revoked, login_token_invalid
403scope_denied, limit_exceeded, agent_suspended, plan_limit_exceeded, plan_expired (alias trial_expired), ip_not_allowed, csrf_rejected (proxy panelu)
404not_found
409idempotency_conflict, state_conflict
415unsupported_media_type (import: binarny XLS, nie-CSV)
422allocation_sum_invalid, capacity_exceeded, currency_mismatch (tylko §14.7), currency_unsupported, fx_unavailable, webhook_url_invalid
413payload_too_large
426contract_too_old
429rate_limited (nagłówek Retry-After)
500internal_error
  • Referencja maszynowa (1.0.0-rc.4): źródłem maszynowym /v1 jest docs/openapi.json (OpenAPI 3.1; docs/openapi.yaml = ta sama treść dla ludzi) generowany z tej sekcji, KONTRAKT-API §6, docs/CODES.md i schematów zod @operlance/core przez node scripts/gen-openapi.mjs; razem z docs/API-REFERENCE.md nie jest edytowany ręcznie; --check w check.sh/CI = test driftu (każda ścieżka z tej listy ma operację). Landing /docs/api czyta te pliki (Frontend, sync-docs).
  • Wersjonowanie i deprecacja (0.8.0, R-051): prefiks /v1 zmienia się tylko przy zmianie łamiącej; w /v1 dozwolone 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łówkami Deprecation: true i Sunset: <RFC 1123 date> + Link: <docs>; rel="deprecation" na dotkniętych odpowiedziach oraz wpis w Changelog; po Sunset pole znika (to jest zmiana łamiąca → tylko z /v2). Pola oznaczone w KONTRAKT-API jako experimental (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 z experimental na stable (KONTRAKT-API §6) dopiero gdy nie zmieniła się (kształt, znaczenie) przez minimum jeden pełny cykl wydania (jeden bump x.y.0) i ma pokrycie w docs/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ż stable wymaga 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 w tools/list i wpis w tabeli „Wycofywane” niżej; usunięcie narzędzia dopiero po Sunset, wyłącznie przy bumpie @operlance/mcp zgodnym z major/minor kontraktu (§12). Nowe narzędzia i pola wyników MCP startują jako experimental, 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, zapisuje Agent.last_client, §2.7).
  • Sesja Ownera: od 0.6.0 sesje ses_… z magic-linka (§2.19). Statyczny token OPERLANCE_OWNER_TOKEN tylko przy AUTH_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&quotes   (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 bez Idempotency-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łąd 400 validation_error. Honeypot: niepuste website → 202 bez zapisu.
  • Rate limit per IP: 5/min i 20/dobę → 429 rate_limited. user_agent z nagłówka, przycięty do 256 znaków.
  • CORS dla tej trasy: wyłącznie origin landingu (WAITLIST_CORS_ORIGIN), metody POST, OPTIONS. Bez AuditEntry per zapis (brak ownera); GET przez Ownera → AuditEntry waitlist.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 ?offset w 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[] } przez EventSink.ingest (source_type=IMPORT, integration_id = integracja manual_import Ownera (tworzona przy pierwszym imporcie, covers=["cash"] dla presetu bankowego), batch_id = import_id w payload) + AuditEntry import.confirmed. GET /v1/imports/:id = status/raport.
  • external_id importu: import:<sha256(kolumny kluczowe)> (deterministyczny, dedup przy ponownym imporcie tego samego wyciągu). Refund z importu bez refunds_external_id dozwolony z confidence ≤ 0.9.
  • Uzgadnianie (reconcile): sink honoruje payload.fulfills_event_id (planned → spełniony, variance_minor) i payload.reconciles_event_id: string | string[] (import dopasowany do istniejącego actual z integracji, np. Stripe payout ↔ wpływ na koncie): event importu zapisany jako status=confirmed, payload.reconciled=true, wykluczony z agregatów (core nie liczy go drugi raz), ale widoczny w /events z oznaczeniem reconciled. Niedopasowane po 3 dniach → sygnał unreconciled (warning) + alert. Job reconcile:daily (PERFORMANCE §5) dopasowuje po kwocie/dacie ±3 dni/walucie.
  • Coverage §8: domena cash = mostly_covered, gdy ostatni import bankowy (manual_import z covers=["cash"]) < 24 h; incomplete do 7 dni; potem missing.

#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}; providery revolut_business (env REVOLUT_BUSINESS_ACCESS_TOKEN), wise_business (WISE_API_TOKEN, WISE_PROFILE_ID), oba availability=coming_soon do decyzji Właściciela; sekrety per Owner w credentials_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, gdy as_of późniejszy).
  • EventSink.snapshot(owner_id, CashSnapshotInput[]) → { inserted, deduped, rejected[] }; snapshot z integracji ma data_class=real, coverage domena cash = 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 event external_id="<id>:v<n>" z payload.supersedes_external_id="<id>" → supersedes_event_id, stary voided; liczone jako inserted, nie deduped. 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 ma payload.supersedes_event_ids[]).
  • Refund: adapter podaje payload.refunds_external_id i payload.direction: "in"|"out" (out = zwrot przychodu klientowi, in = zwrot kosztu); sink rozwiązuje refunds_external_id → payload.refunds_event_id w 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łaniem EventSink.signal(owner_id, signals). Backend mapuje każdy sygnał na Alert (kod integration.<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) tokenem META_WEBHOOK_VERIFY_TOKEN (0.6.1). Google Ads OAuth: GET /v1/integrations/google_ads/callback (state HMAC, code → refresh token do credentials_enc).
  • Webhooki: Stripe, podpis Stripe-Signature; Meta, X-Hub-Signature-256; Google Ads nie ma natywnych webhooków → POST /v1/webhooks/google_ads przyjmuje relay (np. Ads Script/Pub/Sub → nasz relay) podpisany X-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) oraz AdMetrics, 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/integrations i apps/web importują je stamtąd, nie definiują własnych kopii.
  • Sink nadaje direction (dla refund z payload.direction), provenance (§3), status=confirmed, pisze AuditEntry (actor_type=integration), aktualizuje Integration.last_sync_at. Webhook: weryfikacja podpisu → adapter z packages/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 dodatkowo page.
  • Rekord (event, subskrypcja, rezerwacja, approval, cash snapshot, alokacja): oryginał amount_minor + currency oraz base_amount_minor, base_currency, fx: FxInfo. Dla eventów state=actual fx.kind="snapshot" (utrwalony), dla reszty fx.kind="current".
  • Agregat (każdy obiekt z sumami: Overview, FinancialSummary, FreeCapital, Forecast, PRA, /ads, ProjectHealth): pole currency (= base_currency albo display_currency) + w kopercie fx: 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 }[], nazwa fx wewnątrz data jest zarezerwowana dla rekordów (FxInfo), żeby nie kolidować z kopertowym FxSummary (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 }