Files
marketplaces/docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md
sdarbinyan 71da5a8d80
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
docs: partner provisioning API contract, routing context, Track P
A partner integration request landed for programmatic merchant-hierarchy
management (Company/Project/Store/PaymentPoint). Built the answer generically:
partner-specific behaviour is a PartnerProfile config row, and no partner name
appears in any entity, field, endpoint or status value.

New:
- docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md - hierarchy, idempotency,
  node-scoped public-key credentials, TEST/LIVE partition, routing context
- docs/context/adrs/ADR-0003-generic-partner-provisioning-api.md

Amended, because the schema impact must land before Phase 1 is implemented:
- Phase 1 gains RoutingContext on CheckoutSession/PaymentIntent/Payment,
  frozen at checkout-session creation and immutable after
- Phase 7 gains routing on Refund/ReconciliationRecord, plus the rule that
  seller settlement splits happen after routing, never as a hierarchy level
- Phase 9 gains Company/Project above Marketplace and PaymentPoint below it,
  with a backfill sequence for existing marketplaces
- Track S gains partner credentials: public key only, node-scoped authority,
  rotation with overlap, immediate revoke, audit coverage

Also: Track P (P1-P10) in the delivery plan, and backend ownership closed as
answered across the contract set.

Card payment was checked, not added - qr and card both already ship in
cart.component.ts with separate create paths and status pollers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 11:22:24 +04:00

15 KiB

Partner Provisioning API Contract — Merchant Hierarchy, Credentials, Routing

Cross-cutting contract. Partner-facing, inbound: external partners call us. Distinct from Phase 4, which is outbound/ingest.

Depends on Phase 1 (payment state machine), Phase 5 (seller org), Phase 9 (marketplace registry), Track S (keys, audit, rate limiting).

Status: draft — level mapping decided (§10), two new entities required.

Origin: a partner integration request (2026-08-18). This contract is deliberately generic. No partner name appears in any entity, field, endpoint, or status value. Partner-specific behaviour lives entirely in a PartnerProfile config row (§8). A second partner asking for the same thing must require zero schema and zero endpoint change.


1. Why this exists

Partners need to provision and manage their own merchant hierarchy programmatically, then have payments route unambiguously back to the correct leaf. Today we have no partner-facing write API at all, no entity above Marketplace, and payments carry no store dimension — reconciliation cannot attribute a payment to a store.


2. Hierarchy model

Four levels, fixed. Middle levels are optional per partner, never free-form depth.

Company -> Project -> Store -> PaymentPoint
type NodeLevel = 'company' | 'project' | 'store' | 'payment_point';

interface ProvisioningNode {
  id: string;                  // stable, opaque, never reused
  level: NodeLevel;
  parentId: string | null;     // null only for level 'company'
  companyId: string;           // denormalized root, present on every node
  path: string[];              // ordered ancestor ids, root first, inclusive of self
  environment: Environment;
  status: NodeStatus;
  externalReference: string;   // partner's own id for this node
  displayName: string;
  createdAt: string;           // ISO 8601
  updatedAt: string;           // ISO 8601
}

type Environment = 'TEST' | 'LIVE';
type NodeStatus = 'active' | 'suspended' | 'disabled';

Invariants

  1. parentId must be the immediately preceding enabled level in the partner's profile. Skipping a required level is 422.
  2. companyId and environment are inherited from the parent and are immutable.
  3. externalReference is unique per (companyId, environment, level). Collision is 409.
  4. path is server-computed. Never accepted from the client.
  5. A node cannot be re-parented. Ever. Move = disable + create new.
  6. Creating any node never enables a financial capability, never creates a payment, never opens a settlement account. Financial enablement is a separate, explicitly approved flow outside this contract.

Status semantics

Status Meaning Accepts payments Reversible
active normal yes
suspended temporarily halted no yes, back to active
disabled terminal no no
  • Disabling a node cascades disabled to every descendant, atomically.
  • Suspending a node cascades suspended to descendants; un-suspending restores only descendants that were suspended by that same cascade (tracked by cascade id), never descendants suspended independently.
  • disabled never returns to any other status. Re-provisioning creates a new node with a new id.

3. Environments

TEST and LIVE are a hard partition:

  • Separate credentials. A TEST key can never address a LIVE node, and vice versa — cross-environment access is 403, not 404.
  • Node ids never collide across environments and are never transferable.
  • externalReference uniqueness is scoped per environment — the same partner reference may exist once in each.
  • No data, config, or hierarchy copy between environments in this API.

4. Endpoints

Namespace /api/partner/v1/. All timestamps ISO 8601 UTC.

4.1 Write

POST   /api/partner/v1/companies/{companyId}/projects
POST   /api/partner/v1/projects/{projectId}/stores
POST   /api/partner/v1/stores/{storeId}/payment-points

PATCH  /api/partner/v1/nodes/{nodeId}/status        -- { status: 'active' | 'suspended', reason?: string }
POST   /api/partner/v1/nodes/{nodeId}/disable       -- terminal, cascading

Creation body:

interface CreateNodeRequest {
  externalReference: string;
  displayName: string;
  metadata?: Record<string, string>;   // opaque to us, echoed back, never interpreted
}

4.2 Read

GET /api/partner/v1/nodes/{nodeId}
GET /api/partner/v1/companies/{companyId}/hierarchy?environment=TEST|LIVE&status=...&depth=...
GET /api/partner/v1/nodes/lookup?externalReference=...&level=...&environment=...
GET /api/partner/v1/companies/{companyId}/audit?from=...&to=...&cursor=...
  • hierarchy returns the full tree with current statuses, one call, cursor-paginated over nodes when large.
  • lookup is the externalReference resolver. Returns 404 when unmatched — never a partial or fuzzy match.
  • Read endpoints are the partner's own verification surface for what was actually created. They read from the same store as writes — never a cache that can lag behind a create.

4.3 Not in this API

Company creation. A Company is created by us during commercial onboarding, out of band. Partners provision inside a company they already have.


5. Idempotency

Every POST requires an Idempotency-Key header. PATCH status changes accept one optionally.

Idempotency-Key: <partner-generated, opaque, <=255 chars>

Rules, in order:

  1. Key scope is (partnerId, endpoint, key). Two partners may use the same key string without interference.
  2. Same key + byte-identical request body → the original stored response is replayed, with the original status code. No new node.
  3. Same key + different body → 409 Conflict, error code idempotency_key_reuse. Nothing is created or modified.
  4. Retention: 24 hours from first use. After expiry the key is free again — partners must not rely on replay beyond 24h.
  5. A request that arrives while an identical key is still in flight returns 409 with idempotency_request_in_progress. Partner retries after a short backoff.
  6. No partial hierarchy. A creation request either commits its node fully or commits nothing. If a partner creates project → store → payment point in three calls and the third fails, the first two remain — that is three operations, each atomic. A single call is never partially applied.

Body comparison uses a canonical hash (sorted keys, normalized whitespace) so key ordering does not cause a false 409.


6. Credentials and key management

6.1 Model — answered generically

Partners asked whether a credential is per-company, per-project, or per-store. All three, one mechanism: a credential is bound to any single node, and its authority is that node's subtree.

interface PartnerCredential {
  partnerId: string;
  keyId: string;
  scopeNodeId: string;         // credential may act on this node and all descendants
  environment: Environment;
  algorithm: 'ed25519' | 'rsa-pss-sha256';
  publicKey: string;           // PEM or base64 raw, per algorithm
  status: 'active' | 'rotating' | 'revoked';
  createdAt: string;
  expiresAt?: string;
}
  • Partner generates the keypair. The private key never leaves the partner and is never transmitted to us, never logged, never accepted by any endpoint.
  • Partner registers the public key; we return partnerId + keyId.
  • Authority is strictly the scopeNodeId subtree. Any request touching a node outside it is 403.
  • A credential can never widen its own scope, register another credential at a wider scope, or create a node above its scope.

6.2 Endpoints

POST   /api/partner/v1/credentials                     -- register public key, returns partnerId + keyId
GET    /api/partner/v1/credentials
POST   /api/partner/v1/credentials/{keyId}/rotate      -- register successor public key, overlap window
DELETE /api/partner/v1/credentials/{keyId}             -- revoke, effective immediately
  • Rotation: the successor key is registered while the current key stays valid for a bounded overlap (default 7 days, configurable per profile). Both keys verify during overlap. The predecessor auto-revokes at window end.
  • Revocation is immediate and irreversible. In-flight requests signed with a revoked key fail. Revoking a credential does not touch any node it created.
  • Registration, rotation, and revocation each emit a Track S audit event. Key lifecycle actions are always attributable to a named actor.

6.3 Request authentication

Requests are signed, not bearer-token'd:

  • Signature covers: HTTP method, path, canonical body hash, Idempotency-Key (when present), and a timestamp.
  • Timestamp skew tolerance ±5 minutes. Outside that → 401.
  • Signature replay within the window is rejected by nonce tracking → 401.
  • keyId travels in the signature header so we select the right public key without trusting the body.

7. Payment routing

Every payment, callback, refund, and settlement row carries a routing context.

interface RoutingContext {
  companyId: string;
  routingPath: string[];        // ordered node ids, root -> leaf, resolves to exactly one leaf
  leafNodeId: string;           // convenience: last element of routingPath
  environment: Environment;
  merchantReference: string;    // partner-supplied, opaque to us, echoed on every related event
  providerPaymentId: string;    // our payment id, stable, unique
}

Invariants

  1. routingPath must resolve to exactly one leaf node. Ambiguous or unresolvable → the payment is rejected at creation, never accepted and reconciled later.
  2. merchantReference is stored verbatim and echoed on every downstream event: payment status change, refund, settlement line, webhook.
  3. A payment whose leaf node is suspended or disabled is rejected at creation.
  4. Routing context is immutable for the life of the payment. Node status changes afterwards never rewrite it.

Contract amendments this requires

RoutingContext must be added to:

  • Phase 1Payment, PaymentEvent, checkout session
  • Phase 7 — refund, reconciliation row, settlement line

Do this before backend implements Phase 1. Retrofitting a routing dimension onto a live payments table is materially more expensive than adding it now.

Partner-facing serialization uses the partner's own field names (§8) — routingPath is emitted as projectId/storeId/paymentPointId for a partner using those terms, without the core model knowing those words.


8. Partner profile — the only partner-specific surface

interface PartnerProfile {
  partnerId: string;
  requiredLevels: NodeLevel[];                  // subset; 'company' and the leaf are always required
  levelAliases: Record<NodeLevel, string>;      // e.g. { project: 'Project', store: 'Store' }
  routingFieldNames: Record<NodeLevel, string>; // e.g. { store: 'storeId' }
  rateLimitTier: string;
  keyRotationOverlapDays: number;
  webhookFieldMap?: Record<string, string>;
}

A partner with no "project" concept omits it from requiredLevels; their stores hang directly off the company and the hierarchy still validates. A partner calling stores "branches" changes one alias. Adding a partner is a config row, not a deployment.

What is deliberately not configurable, because configurability here breaks reconciliation or safety:

  • NodeStatus values and their transition rules
  • Idempotency semantics
  • Environment partitioning
  • Signature scheme and skew tolerance
  • The four-level ceiling

9. Operational requirements

Requirement Contract
OpenAPI Machine-readable spec published per version, generated from the implementation, never hand-maintained
Sandbox TEST environment is the sandbox. Same code path as LIVE, isolated data, no real money
Error codes Stable string codes, documented, never renamed. HTTP status + code + human message + requestId
Rate limits Per partnerId, per tier. 429 with Retry-After. Limits published in the spec, per Track S
Audit Every write is an audit event: actor (keyId), action, target node, before/after status, requestId, timestamp. Immutable, queryable via §4.2
Idempotency observability Idempotency-Replayed: true response header when a stored response is replayed

Error codes

validation_failed                 422
parent_not_found                  404
level_skipped                     422
external_reference_conflict       409
idempotency_key_reuse             409
idempotency_request_in_progress   409
scope_forbidden                   403
environment_mismatch              403
node_disabled                     409
signature_invalid                 401
signature_expired                 401
rate_limited                      429

10. Mapping onto our model

Decided 2026-08-18.

Partner level Our entity State
company New. No entity above Marketplace exists today. Legal/commercial owner, created out of band (§4.3).
project New. A product line, e.g. marketplaces. One company runs several. Not the same thing as a Marketplace.
store Marketplace (Phase 9) Exists. Gains companyId, projectId, externalReference.
payment_point New. An acceptance channel: one payment method bound to one store. Many per store.

10.1 PaymentPoint = acceptance channel

A PaymentPoint is a payment method enabled on a store, not a physical location and not a settlement account.

interface PaymentPointConfig {
  method: PaymentMethod;        // 'qr' | 'card', extensible
  currencies: string[];         // ISO 4217 subset the channel accepts
  providerAccountRef?: string;  // opaque provider-side binding, set during financial enablement
}

Both current methods ship today — src/app/pages/cart/cart.component.ts (PaymentMethod = 'qr' | 'card', separate create + status-poll paths per method). A store accepting both has two payment points.

Creating a payment point registers the channel. It does not enable it for real money — §2 invariant 6 still holds. Financial enablement sets providerAccountRef through a separate approved flow.

10.2 Seller is orthogonal

Seller (Phase 5) is not a level in this hierarchy. A multi-seller marketplace is one store with many sellers underneath; seller-level settlement splitting happens in Phase 7 reconciliation, after the payment has already been routed to the store. Putting Seller in the partner hierarchy would force every partner to model our multi-seller concept, which most will not have.

10.3 Consequences

  1. Two new entities: Company, Project. Both are thin — id, name, externalReference, status, timestamps — and both sit above Marketplace.
  2. Marketplace gains companyId + projectId. Existing marketplaces need a backfill company and project.
  3. PaymentPoint is new and is what routingPath terminates at (§7).
  4. §7's contract amendments to Phases 1 and 7 do not depend on any of the above — start them now.