#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 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".
  • 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 experimentalstable (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.
  • 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?}, §14.5)
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`)
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 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_idpayload.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ówPOST /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.