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>
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
parentIdmust be the immediately preceding enabled level in the partner's profile. Skipping a required level is422.companyIdandenvironmentare inherited from the parent and are immutable.externalReferenceis unique per(companyId, environment, level). Collision is409.pathis server-computed. Never accepted from the client.- A node cannot be re-parented. Ever. Move = disable + create new.
- 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
disabledto every descendant, atomically. - Suspending a node cascades
suspendedto descendants; un-suspending restores only descendants that were suspended by that same cascade (tracked by cascade id), never descendants suspended independently. disablednever 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
TESTkey can never address aLIVEnode, and vice versa — cross-environment access is403, not404. - Node ids never collide across environments and are never transferable.
externalReferenceuniqueness 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=...
hierarchyreturns the full tree with current statuses, one call, cursor-paginated over nodes when large.lookupis theexternalReferenceresolver. Returns404when 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:
- Key scope is
(partnerId, endpoint, key). Two partners may use the same key string without interference. - Same key + byte-identical request body → the original stored response is replayed, with the original status code. No new node.
- Same key + different body →
409 Conflict, error codeidempotency_key_reuse. Nothing is created or modified. - Retention: 24 hours from first use. After expiry the key is free again — partners must not rely on replay beyond 24h.
- A request that arrives while an identical key is still in flight returns
409withidempotency_request_in_progress. Partner retries after a short backoff. - 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
scopeNodeIdsubtree. Any request touching a node outside it is403. - 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. keyIdtravels 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
routingPathmust resolve to exactly one leaf node. Ambiguous or unresolvable → the payment is rejected at creation, never accepted and reconciled later.merchantReferenceis stored verbatim and echoed on every downstream event: payment status change, refund, settlement line, webhook.- A payment whose leaf node is
suspendedordisabledis rejected at creation. - 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 —
Payment,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:
NodeStatusvalues 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
- Two new entities:
Company,Project. Both are thin — id, name,externalReference, status, timestamps — and both sit aboveMarketplace. MarketplacegainscompanyId+projectId. Existing marketplaces need a backfill company and project.PaymentPointis new and is whatroutingPathterminates at (§7).- §7's contract amendments to Phases 1 and 7 do not depend on any of the above — start them now.