#Idempotency
Every POST, PUT, PATCH and DELETE requires an Idempotency-Key header (≤ 128 characters). The one public exception is POST /v1/waitlist.
#Rules
- Same key + same body → the stored response is returned again, with the header
Idempotent-Replayed: true. Nothing is written twice. - Same key + different body →
409 idempotency_conflict. - Keys are scoped to the owner and the actor (your agent) and expire after 24 hours.
- Retry a failed or timed-out write with the same key. Generate a new key only for a genuinely new action.
#MCP
Every write tool takes idempotency_key as a parameter, pass a stable value per intended action, e.g. reserve-ads-2026-09-tharan. The server forwards it as the header.
{
"idempotency_key": "reserve-ads-2026-09-tharan",
"reason": "Google Ads budget for September, as agreed in the plan",
"business_id": "<business_id>",
"amount_minor": 500000, "currency": "PLN",
"purpose": "Google Ads: September", "category": "ads",
"period_start": "2026-09-01", "period_end": "2026-09-30"
}
#Order of checks on a write
auth → idempotency → scope → limit → (approval?) → write → audit. A denied scope or an exceeded limit is recorded in the audit log with the reason, the owner sees 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". - 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.
- 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?}, §14.5)
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`)
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)
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))
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/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")
GET /v1/integrations/google_ads/callback (0.6.1; OAuth redirect; bez Bearer, state podpisany HMAC, 302 do panelu)
POST /v1/auth/magic-link POST /v1/auth/verify POST /v1/auth/logout (§2.19; magic-link PUBLICZNY)
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.