---
name: integrate-onramp
description: Implement a ramp provider's fiat→crypto on-ramp quote — createOnrampQuote → PaymentRampQuote — and wire the handler dispatch that resolves DB state. Use when opening a PR against apps/sdp-api to add on-ramp support for a ramp provider.
---

# Integrate on-ramp

On-ramp = a counterparty buys crypto with fiat, delivered to an SDP-known wallet. The active flow is the **quote**: you implement `createOnrampQuote` on your provider and add a `case` to the quote dispatch. (`executeOnramp` exists on the interface but isn't currently exercised — skip it for now; the quote returns the payment instructions the user acts on.)

`createOnrampQuote` is **optional** on `RampProvider` — implement it only if your provider has a lockable quote step.

Canonical example: `createOnrampQuote` in `lib/ramps/providers/lightspark/client.ts` + the DB helpers in `routes/payments/handlers/ramps/lightspark.ts`.

## Contract

Input `RampOnrampQuoteInput` (`lib/ramps/types.ts`): `{ cryptoToken, fiatCurrency?, fiatAmount, destinationWalletAddress, externalCustomerId, customerId?, redirectUrl?, bvnkCompliance? }`. The handler pre-resolves `customerId` / `externalCustomerId` from the DB — your method never reads the database.

`PaymentRampQuote` is a **discriminated union on `deliveryMode`** (`packages/sdp-types/src/payments.ts`) — return the arm that matches your product:

- `deliveryMode: "manual_instructions"` — return `paymentInstructions` (bank/wire or on-chain funding details). Lightspark, BVNK.
- `deliveryMode: "hosted"` — return a `hostedUrl` the client renders (widget/redirect). MoonPay.

Use `rampId("ramp")` for ids; set `id` to the upstream quote id so the webhook can match the transfer later (`integrate-webhook`).

## Handler wiring (the DB side)

Add a `case "<id>"` to the on-ramp quote dispatch in `routes/payments/handlers/ramps.ts`. The handler owns all DB work:

- resolves the counterparty + destination wallet,
- ensures any provider-side customer/account exists (DB-touching `ensure*` helpers live in `routes/payments/handlers/ramps/<id>.ts`, like `ensureLightsparkCustomer`),
- calls your HTTP-only `createOnrampQuote` with pre-resolved inputs,
- persists the transfer via `persistRampQuoteTransfer` (dedups by `(provider, providerReference)`; `rampQuoteTransferStatus` maps a `manual_instructions` + `pending` quote to `awaiting_payment`).

Route: `POST /v1/ramps/onramp/quote`, gated by `assertRampProviderAvailable` + `payments:write` / `wallets:read`.

## Variety

| Provider | deliveryMode | On-ramp quote shape |
|---|---|---|
| Lightspark | `manual_instructions` | `REALTIME_FUNDING` quote; funding `paymentInstructions` |
| BVNK | `manual_instructions` | bank pay-in instructions built from the provisioned rule |
| MoonPay | `hosted` | signed `buy.moonpay.com` widget `hostedUrl` |

## Gating — throw, don't fallback

A provider that needs provisioning must fail loud when it's missing: Lightspark throws if `customerId` is absent; BVNK throws `counterpartyNotProvisioned` if the customer isn't verified or the rule isn't ready. Getting the counterparty to a ready state is `counterparty-requirements` — never substitute a default.

## Rules + verify

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

- No fallbacks — missing customer/account/instructions throws; never default them.
- HTTP in the provider; DB (counterparty, wallet, customer, transfer row) in the handler.
- `deliveryMode` arms are a real discriminated union — return exactly one arm's fields; no `any`.
- Verify with `tsc --noEmit` + `biome check`; mock fetch in `providers/<id>/client.test.ts` (provider calls 503 without creds in the environment).
