---
title: Payment Provider Adapter Spec
tags: [adapters, payments, specification, architecture]
created: 2026-05-24
status: advisory
reviewers: [backend, security, product]
---

# Payment Provider Adapter Spec

This specification standardizes how payment providers are plugged in so platform logic
does not depend on SHKeeper or any single webhook implementation.

The contract below replaces provider-specific branching in domain services and should
be used by all pay-in, payout, release, and reconciliation logic.

> Canonical implementation note: this is an advisory ADR for tasks in `task 4.6`.
> It maps to [[Funds Ledger and Escrow State Machine Specification]] and [[Webhook Security Spec]].

## 1. Core provider contract

Implementations expose one typed adapter:

```ts
interface PaymentProviderAdapter {
  readonly provider: "shkeeper" | "request_network" | "manual_wallet" | "admin_wallet" | string;

  createPayInIntent(input: PayInIntentInput): Promise<PayInIntentResult>;
  getPayInStatus(input: PayInStatusInput): Promise<PayInStatusResult>;
  handleProviderWebhook(input: ProviderWebhookInput): Promise<ProviderWebhookResult>;
  createHostedPaymentLink(input: HostedLinkInput): Promise<HostedLinkResult>;
  createReleaseInstruction(input: ReleaseInstructionInput): Promise<ReleaseInstructionResult>;
  createRefundInstruction(input: RefundInstructionInput): Promise<RefundInstructionResult>;
  getPayoutStatus(input: PayoutStatusInput): Promise<PayoutStatusResult>;
  searchProviderPayments(input: ProviderSearchInput): Promise<ProviderPaymentRecord[]>;
}
```

All adapters must return a normalized result shape:

```ts
type NormalizedProviderStatus = "pending" | "processing" | "confirmed" | "completed" | "failed" | "cancelled" | "released" | "refunded";
type NormalizedProviderEvent = {
  providerPaymentId: string;
  purchaseRequestId?: string;
  requestId?: string;
  providerReference?: string;
  amount: string;      // decimal string
  currency: string;
  status: NormalizedProviderStatus;
  transactionHash?: string;
  providerEventType: string;
  receivedAt: string;  // ISO timestamp
  rawFingerprint: string; // provider payload hash
};
```

## 2. Method semantics

### 2.1 `createPayInIntent`

- Create a provider-specific payment intent from a canonical request.
- Must return:
  - `providerPaymentId` (source of truth for future reconciliation),
  - canonical `status`,
  - `payInUrl` when redirect/payment-page flow is used,
  - an expiry timestamp.
- Must persist provider metadata under `payment.providerData.<provider>`.

### 2.2 `getPayInStatus`

- Query provider status for an existing intent.
- Must map provider statuses into `NormalizedProviderStatus` and include a provider-specific raw snapshot.
- Must be idempotent and side-effect free.

### 2.3 `handleProviderWebhook`

- Input must include raw body bytes, headers, provider identifier, and parsed envelope.
- Must verify signatures before parsing business fields.
- On success, emit canonical domain events and return an idempotency decision:
  - `processed` for first apply,
  - `duplicate` for replay,
  - `ignored` for unknown payment / no-op transitions.

### 2.4 `createHostedPaymentLink`

- Return the user-visible payment URL + optional redirect/callback endpoints.
- Should support provider aliases (for migration aliasing, e.g., `request-network` vs `request_network`).

### 2.5 `createReleaseInstruction` and `createRefundInstruction`

- Produce signed/payload instructions and pre-check:
  - account/release eligibility,
  - dispute hold not active,
  - sufficient releasable balance (ledger-derived),
  - admin approval requirements if configured.
- Must never directly mutate release state.
- Must be idempotent by `(paymentId, actionType)` where action type is `release|refund`.

### 2.6 `getPayoutStatus`

- Return state of pending/processing payout tasks and chain/on-chain confirmation status.
- Return normalized status to domain services:
  - `processing` for queued/broadcast not-finalized,
  - `completed` for finalized payment,
  - `failed` with provider error code when rejected.

### 2.7 `searchProviderPayments`

- Used for reconciliation and manual verification.
- Must support:
  - `providerPaymentId`/`requestId` lookup,
  - time-window pagination,
  - optional min/max amount filtering.
- Must never be the primary source for state transitions without reconciliation checks.

## 3. Routing and selection

Provider selection follows environment-configured capability flags:

- `PAYMENT_ENABLED_PROVIDERS` (comma-separated allowlist),
- `PAYMENT_DEFAULT_PROVIDER` (read-first fallback),
- `PAYMENT_ROLLBACK_PROVIDER` (read-only fallback target for cutbacks),
- `PAYMENT_MODE`:
  - `standard`: normal provider routing,
  - `dry_run`: no writes, status-only,
  - `read_only`: no new pay-in/intent writes.

Selection rules:

1. Validate provider support and provider license/credential validity.
2. Route legacy requests to `shkeeper` when explicit migration window is active.
3. For unknown `provider`, return a `400 Bad Request` with explicit operator-visible error code.
4. If requested provider is disabled, return `409` with migration explanation and owner-visible hint for operator override.

## 4. Canonical metadata contract

Payment documents keep provider-specific data namespaced under:

- `metadata.providers.<provider>.rawPayload`
- `metadata.providers.<provider>.rawEvents[]`
- `metadata.providers.<provider>.providerPaymentId`
- `metadata.providers.<provider>.lastWebhookAt`

Domain services must never read `metadata.providers.*` as mutable funds state. They must use ledger-derived balances and canonical status fields only.

## 5. Error contract

All adapter methods return standard failure modes:

- `retryable: true` for transient provider errors (timeouts, 5xx, queue backpressure).
- `retryable: false` for invalid payloads, invalid signatures, and authorization failures.
- `errorCode` must be stable across retries for auditability.

## 6. Test coverage required

- Contract tests per adapter:
  - `createPayInIntent`, status polling, webhook handling
  - invalid/absent signature behavior
  - duplicate webhook idempotency
  - unknown payment reference behavior
  - rollback selection and read-only mode behavior.
- Reconciliation tests:
  - provider backfill for missing payment references,
  - status drift correction,
  - duplicate/missing event merge.

## Related

- [[Webhook Security Spec]]
- [[Funds Ledger and Escrow State Machine Specification]]
- [[Backend Core Stack Decision Record - 2026-05-24]]
- [[Backend Funds Migration and Operational Runbooks]]
