# Partner Provisioning API Contract — Merchant Hierarchy, Credentials, Routing Cross-cutting contract. Partner-facing, **inbound**: external partners call us. Distinct from [Phase 4](PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md), which is outbound/ingest. Depends on [Phase 1](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) (payment state machine), [Phase 5](PHASE-5-SELLER-PORTAL-CONTRACT.md) (seller org), [Phase 9](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md) (marketplace registry), [Track S](TRACK-S-SECURITY-RBAC-CONTRACT.md) (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 ``` ```ts 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: ```ts interface CreateNodeRequest { externalReference: string; displayName: string; metadata?: Record; // 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: ``` 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. ```ts 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. ```ts 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 1](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) — `Payment`, `PaymentEvent`, checkout session - [Phase 7](PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md) — 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 ```ts interface PartnerProfile { partnerId: string; requiredLevels: NodeLevel[]; // subset; 'company' and the leaf are always required levelAliases: Record; // e.g. { project: 'Project', store: 'Store' } routingFieldNames: Record; // e.g. { store: 'storeId' } rateLimitTier: string; keyRotationOverlapDays: number; webhookFieldMap?: Record; } ``` 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](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md)) | 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. ```ts 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](PHASE-5-SELLER-PORTAL-CONTRACT.md)) 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](PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md) 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.