Counterparty requirements

SkillCommerce & finance

Implement a ramp provider's pure validateCounterparty decision in @sdp/payments plus the API-side requirements event flow — status events out, advance submissions in, provider state as counterparty_provider_accounts rows. PII is JIT pass-through only.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Counterparty requirements skill

What this skill tells your AI

The instructions your AI receives, as published by solana-foundation/solana-developer-platform in .agents/skills/counterparty-requirements/SKILL.md and read by ahel’s review.

Before a quote, the platform asks your provider what a counterparty still needs — KYC, a payout account, or nothing at all. validateCounterparty answers that. It is pure and synchronous: it reads the counterparty + handler-resolved state and returns a CounterpartyRequirements. No HTTP, no DB — the actual provisioning happens later, in the advance flow.

Use this skill for every provider, including providers that always return ready. The dashboard calls the requirements GET and POST flow before requesting a quote, so both schemas and an advanceCounterpartyRequirements branch must admit the provider even when there is no KYC or provisioning work to perform.

Canonical examples live in packages/sdp-payments/src/ramps/providers/ — before writing yours, read the existing provider whose lifecycle is closest: inline ready decisions, collected-field KYC, customer-link → payout-tree provisioning, and hosted onboarding lifecycles are all represented.

The event model

The requirements flow is a set of event streams, each a closed union discriminated on (provider, status/kind). Adding a provider means declaring exactly which events it emits and accepts in each stream:

  • Status events out (GET /v1/counterparties/:counterpartyId/requirements): validateCounterparty emits one arm of CounterpartyRequirements (packages/sdp-types/src/ramp-requirements.ts).
  • Advance submissions in (POST …/requirements): one submitCounterpartyRequirementsSchema arm per provider (nested discriminatedUnion("direction", …) when payloads differ per direction). collectedData on the submission is the only PII channel in the platform.
  • Webhook auto-advance (optional): providers with async provisioning also move requirement state from typed webhook events — see integrate-webhook.

Provider state: counterparty_provider_accounts

All provider-side counterparty state is rows in this table — one row per provider resource. The row schema is counterpartyProviderAccountRowSchema in apps/sdp-api/src/db/repositories/counterparty-provider-account.repository.ts; every repo method is tenant-scoped and parent-scoped.

ColumnWhat it is and how to use it
idcounterparty_provider_account_<uuid>. Sent upstream as the platform-side account id on JIT creation where the provider accepts one, which makes creation idempotent (the provider rejects a duplicate id).
organization_id, project_id, counterparty_idTenant + parent scope. Every read and write takes all three — get/update/archive too, never just list.
providerThe ramp provider id; part of every lookup key.
kindWhat the row represents: customer_link (the provider-side customer; one active per (counterparty, provider), enforced by partial unique index), payout_account (off-ramp destination account), funding_wallet (on-ramp funding resource), merchant_wallet (merchant-owned provider wallet). Pick the kind that matches the resource; do not invent parallel storage.
provider_customer_referenceThe provider's customer id the resource belongs to. Required on every row.
external_account_referenceThe provider's own id for the resource (account/wallet id). null on customer_link rows. This is the value quote calls pass upstream.
fiat_currency, destination_country, payment_railThe corridor. Set on corridor-scoped kinds; null where not applicable. Multiple active rows per corridor — including per rail — are legal; there is no corridor uniqueness, so selection is explicit (providerAccountId), never assumed.
provider_statusThe provider-reported lifecycle status, stored verbatim. Gate on it with a provider-side predicate before using the row in a quote; treat unknown values as not-ready.
statusSDP soft delete: active / archived. Replacement = archive + create a new row; never hard-delete, never update in place to repoint a resource.
metadataKind-discriminated JSON, schema-validated on write against the per-kind metadata schemas in the repository file. Provider references and state only — never PII, never bank details.
created_at, updated_atRow timestamps; also the display timestamps when the provider reports none on the resource.

counterparties.provider_data is deprecated. Do not add a provider key to it; new provider state must be a row here under the right kind. PII lands in neither place — it flows through advance-submission collectedData, JIT to the provider, and is discarded.

Contract

validateCounterparty(counterparty: Counterparty, options: ValidateCounterpartyOptions): CounterpartyRequirements

ValidateCounterpartyOptions is direction-discriminated (packages/sdp-payments/src/ramps/types.ts): both arms carry providerData (legacy), providerCustomerReference (the handler-resolved customer_link reference), cryptoToken?, and fiatCurrency?; the offramp arm adds cryptoRail?, payoutAccounts? (handler-listed active corridor accounts), and destinationCountry? (the corridor the requirements GET is asked for). Trivial bodies (readyCounterparty(...), or an unsupported guard) stay inline in the client; non-trivial decisions delegate to providers/<id>/counterparty.ts.

Status events every provider gets:

  • { status: "ready" } — good to quote. The lightspark offramp arm is richer: providerAccountId (required — the corridor's resolved payout account, passed back for explicit quote selection) plus payout? (the tree, present on GET answers so the client can keep rendering the destination selector; advances omit it).
  • { status: "collect"; fields: RequirementField[] } — need input first.
  • { status: "unsupported"; reason } — this counterparty/corridor can't be served, and why.

The full status union, discriminated on (provider, status) (lightspark's ready also splits on direction):

ProviderStatuses
every providerready, collect(fields), unsupported(reason)
lightsparkonboarding_not_started, collect_counterparty(fields), collect_account(payout: PayoutRequirementTree), offramp ready(providerAccountId, payout?)
bvnkonboarding_not_started, customer_verification_required(verificationUrl), customer_verifying, customer_verification_failed, funding_account_provisioning, provisioning_failed
muralonboarding_not_started, terms_of_service_required(termsOfServiceUrl), customer_verification_required(verificationUrl), customer_verifying, customer_verification_failed, funding_account_provisioning

The authoritative union is CounterpartyRequirements in packages/sdp-types/src/ramp-requirements.ts — re-read it before editing; this table goes stale. Extend it only when generic ready / collect / unsupported cannot represent the provider.

RequirementField kinds (same module): text, select, country, date, and address (nested dotted-key parts). country is codes-only on the wire — the server never inlines a country option list; the client renders its own dropdown from COUNTRIES with flag labels; values are ISO 3166-1 alpha-2 both ways. Build fields with textField, selectField, countryField, dateField, and readyCounterparty from packages/sdp-payments/src/ramps/requirements.ts (readyCounterparty covers non-lightspark providers; lightspark readiness uses lightsparkOnrampReady / lightsparkOfframpReady from its provider file); don't hand-roll the shape.

The payout tree (offramp account reuse)

collect_account carries a PayoutRequirementTree — destination-first collection:

  • countryRails: destination country → rail options for the corridor.
  • railFields: rail → fields with true per-rail requiredness.
  • accounts: the counterparty's existing active accounts for the corridor, { id, destinationCountry, paymentRail, status, bankName?, accountNumberLast4? }.

Reuse is server-decided, not client-chosen: the requirements GET accepts an optional destinationCountry (lightspark offramp only — the offramp query arm discriminates on provider), and a corridor with exactly one active account answers ready(providerAccountId, payout) instead of collect_account. Multiple active corridor accounts are ambiguous and rejected, mirroring selectLightsparkPayoutAccount. The dashboard keys its requirements fetch on the collected destination country, so every country change re-asks the server; readiness is never client-derived from the tree.

Display info (bankName, accountNumberLast4) comes from JIT provider enrichment via the optional listExternalAccountDetails provider method — masked server-side, never persisted; absent fields stay absent (typed optional), never defaulted.

The decision (archetypes)

Simplest first — pick the closest and mirror its decision function:

  • Always readyCounterparty(...) — no KYC gating.
  • collect KYC fields, ready once the provider verifies the customer.
  • Customer link first (collect_counterparty), then per-corridor payout collection (collect_account with the payout tree; a corridor whose active account already exists answers ready straight from the GET), then ready.
  • Provider-hosted onboarding (ToS/verification URLs) advanced to ready by webhook events.

The advance / submit flow

Both requirement routes must admit the provider before its client can run:

  • GET /v1/counterparties/:counterpartyId/requirements: update the direction-specific provider lists in apps/sdp-api/src/routes/counterparties/schemas.ts.
  • POST /v1/counterparties/:counterpartyId/requirements: add a provider arm to submitCounterpartyRequirementsSchema in apps/sdp-api/src/routes/payments/schemas.ts. An offramp arm may also take providerAccountId? — an advance that selects an existing corridor account instead of collecting bank fields.

The POST handler re-runs validateCounterparty, validates submitted collectedData, then calls advanceCounterpartyRequirements in apps/sdp-api/src/routes/payments/handlers/ramps.ts, which dispatches to the DB-side ensure* helper. The helper's job: provider HTTP with the collected PII, then persist the result as a counterparty_provider_accounts row.

Hard rule: collected KYC is never persisted. collectedData (SSN, IBAN, CDD, tax id) flows into the provider API call only. What lands in the account row is metadata — provider references, status, corridor, rail. Raw secrets are transient.

Listing accounts

GET /v1/counterparties/:id/provider-accounts (own route module, apps/sdp-api/src/routes/counterparty-provider-accounts/) lists the rows with JIT enrichment (enrichCounterpartyProviderAccounts): providers implementing listExternalAccountDetails contribute bankName / accountNumberLast4 / paymentRails per request; nothing enriched is written back. The surface is provider-generic — never special-case a provider in the endpoint, response schema, or UI.

Gating

Quotes consume the provisioned state: an offramp quote resolves the payout account explicitly (providerAccountId) or from the corridor's active rows; a verification-gated provider needs its active customer_link and any required resource rows. If it's not there, the quote throws counterpartyNotProvisioned — it does not fall back to an ungated quote.

Rules + verify

Shared rules live in integrate-ramp-provider. Hot here:

  • validateCounterparty is pure — no HTTP, no DB; read only the counterparty + handler-resolved options.
  • No fallbacks — unsupported with a reason beats a silent empty requirement; never persist collected KYC.
  • Status + field types are discriminated unions — return exactly one arm; no any. No new counterparties.provider_data keys.
  • Verify @sdp/payments typecheck/lint/tests plus focused API GET/POST requirement tests. Test the pure decision table and prove raw collected KYC is not persisted.

Signals

GitHub stars
53
Forks
23
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
counterparty-requirements
Source
github.com/solana-foundation/solana-developer-platform