Ramp
Ramp is the hosted front door onto Corridor. An app gets an API key, onboards its users with sponsored Stellar accounts, and pays out to or collects from bank and mobile-money accounts through the anchors the Hub already integrates. It runs on the same packages as the self-hosted Hub (ADR 0013).
import { Ramp } from "@corridor/ramp-sdk";
const ramp = new Ramp({ apiKey: process.env.RAMP_KEY!, signXdr: (xdr) => wallet.signTransaction(xdr) });
await ramp.accounts.onboard(user.address); // sponsored account + USDC trustlineconst order = await ramp.withdrawals.send({ // quote → anchor order → user signs → submitted currency: "KES", amount: 100_000_000n, // 10 USDC, 7 dp minor units account: user.address, beneficiary: { bankCode: "01", accountNumber: "0123456789" }, idempotencyKey: invoice.id,});await ramp.orders.wait(order.id);How a withdrawal moves
Section titled “How a withdrawal moves”- Quote.
POST /v1/quotesprices the payout from the anchor’s rate, carried as a decimal string and applied in integer arithmetic. The Ramp fee is quoted next to it, and the quote expires after 5 minutes. - Route and saga.
POST /v1/withdrawalsspends the quote once and plans the order on the Hub’s route planner (planRoute, withcustody: "wallet"because the user, not Corridor, holds the funds). The plan runs as a durable saga on the Hub’s saga engine: itspartner.payoutleg opens the anchor’s order with a fresh Corridor Reference and builds the funding transaction, a payment to the anchor’s receiving account with the anchor’s memo, plus the Ramp fee. The transaction is returned unsigned. - Sign. The user’s wallet signs it in the app, through the SDK’s
signXdr. Ramp never sees the key. - Submit.
POST /v1/withdrawals/:id/submitchecks that the hash matches the transaction Ramp built, wraps it in a sponsor fee bump, and submits. Nothing is booked yet. - Reconcile from the chain. The reconciler reads the user’s account on Horizon through the Hub’s
StellarObserver, with persisted cursors. An order settles only when the chain shows a payment from the user to the anchor’s account, in the settlement asset, for the exact amount, with the anchor’s memo, and the fee payment in the same transaction. Each observed payment is claimed by one order. Then the fee is booked in the ledger against that payment (ADR 0008). - Track. The anchor’s webhooks (or polling) report its side. The order is
completedonly when the anchor has paid out and the chain shows the funding. Acompletedfrom the anchor with no transaction behind it settles nothing. Each change is delivered to the app’s webhook, signed.
Deposits run the same way in reverse on a partner.collect leg. POST /v1/deposits opens a collection with the anchor and returns the virtual account to pay into, plus a hosted collection link at /pay/:id that a merchant can send to a customer. The deposit settles when the chain shows the anchor’s delivery into the user’s account, by the anchor’s delivery hash or the order’s Corridor Reference.
All amounts are integer strings: settlement amounts in 7 dp minor units, fiat in the currency’s own minor units. Every POST accepts an Idempotency-Key. A replay with the same body returns the first response, and the same key with a different body returns 409 conflict.
The full contract is packages/ramp/openapi.json (OpenAPI 3.1). A test fails if a route the server serves is missing from it, or the other way round.
| Method | Path | |
|---|---|---|
POST |
/v1/signup |
Free testnet sandbox app (self-serve) |
POST |
/v1/tenants |
Paid-plan app (operator token) |
GET |
/v1/me |
The app and the calling key |
POST GET DELETE |
/v1/keys |
Secret keys and agent keys with spending policies, optional expiry |
POST |
/v1/keys/:id/rotate |
Issue a replacement key; the old one works until the grace period ends |
PUT |
/v1/webhook |
Set the app’s webhook URL |
GET POST |
/v1/webhook/dead-letters |
Events whose delivery gave up, and redelivery |
POST |
/v1/quotes |
Withdrawal or deposit quote |
POST |
/v1/accounts |
Sponsored onboarding transaction for a user |
POST |
/v1/accounts/:address/submit |
Submit the user-signed onboarding |
POST |
/v1/withdrawals |
Open a payout; returns the unsigned funding transaction |
POST |
/v1/withdrawals/:id/submit |
Submit the user-signed funding |
POST |
/v1/deposits |
Open a collection; returns the account to pay and a link |
GET |
/v1/orders, /v1/withdrawals/:id, /v1/deposits/:id |
Status, with the on-chain evidence once settled |
GET |
/v1/usage |
Volume, orders and fees for the period |
GET |
/v1/events |
Webhook events and their delivery state |
Errors are { "error": { "code", "message", "details" } } with stable codes: unauthorized, forbidden, not_found, invalid_request, quote_expired, quote_used, spending_limit, unsupported, conflict, transaction_mismatch, partner_error, chain_error, rate_limited (with Retry-After).
Webhooks
Section titled “Webhooks”Each event is POSTed with x-ramp-timestamp and x-ramp-signature: v1=<hex HMAC-SHA256 of "timestamp.body">, signed with the app’s webhook secret. verifyWebhook(secret, rawBody, headers) in the SDK checks the signature and the age. A failed delivery is retried with exponential backoff (30 s, doubling, capped at 6 h). After the last attempt the event is dead-lettered: GET /v1/webhook/dead-letters lists it and POST /v1/webhook/dead-letters/:id/redeliver puts it back in the queue.
Agents
Section titled “Agents”An agent key can quote, pay out and read status, and cannot mint keys. Its policy caps each payout, the day’s total, and the currencies it may use. A refusal is a spending_limit error that names the limit, what remains and what was asked. agentTools(ramp, signXdr) exports ramp_quote_payout, ramp_send_payout and ramp_payout_status with JSON schemas, ready to register with an MCP server or a function-calling model.
| Plan | Price | Network |
|---|---|---|
| Sandbox | Free | Testnet only |
| Growth | $300 / month + 0.35% of routed volume | Mainnet |
| Merchant | 0.40% of collected volume | Mainnet |
| Agent | 0.50% + 0.05 USDC per payout | Mainnet |
Operating it
Section titled “Operating it”- Database. PGlite on disk for local runs and tests; set
DATABASE_URLto run on Postgres (openPostgresDb). The same schema and tests run on both. - Rate limits. A token bucket per API key (
RAMP_RATE_LIMIT, requests per minute); over it,429 rate_limitedwithRetry-After. - Keys. Keys can be created with an expiry and rotated with a grace period. An expired key is refused like a revoked one.
- Logs. One JSON line per request: request id, method, route template, status, duration, app and key id. Never the body, headers, query string, raw path, signed XDR, bank details or keys.
Run it
Section titled “Run it”pnpm ramp:serve # the API on :8787, mock anchor unless CORRIDOR_ANCHOR_BASE_URL is setpnpm ramp:demo # signup → sponsored account + USDC trustline → payout in testnet USDC → usagePaying per call: x402
Section titled “Paying per call: x402”An agent can be charged for a Ramp call. Corridor is the resource server: a priced route answers an unpaid request with 402 and a PAYMENT-REQUIRED header, the agent signs Soroban authorization entries for a SEP-41 transfer, and a facilitator verifies and settles them.
rampServer(ramp, { x402: { payTo: "GCORRIDOR…", // the account each call pays network: "mainnet", facilitator: new HTTPFacilitatorClient({ url: "https://channels.openzeppelin.com/x402" }), resourceBase: "https://ramp.corridor.example", prices: { "POST /v1/withdrawals": 10_000n }, // 0.001 USDC, at the ledger's 7 dp },});Three things worth knowing:
- Corridor never parses the payment. The payload crosses this code opaquely and goes to the facilitator; the wire formats come from x402’s own encoders rather than from anything written here. The requirements are x402 v2: the figure is
amount, not v1’smaxAmountRequired, and the resource is carried once per response rather than per requirement. - Verify before the work, settle after it succeeds. A payout that fails is not charged for, and a payment that cannot settle never produces a payout. The settlement receipt comes back in
PAYMENT-RESPONSE. - A route with no price is free. The payment header is not even read on an unpriced route, so adding x402 changes nothing for existing callers.
This is separate from agent keys, which carry Corridor’s own spending policy. A key limits what an agent may pay out; x402 charges it for asking.
As MCP tools
Section titled “As MCP tools”pnpm ramp:mcp serves the same payouts as an MCP server, so an agent holds a Corridor payout as a tool rather than an HTTP call:
RAMP_API_KEY=rk_live_agent_… RAMP_API_URL=https://ramp.example pnpm ramp:mcpIt exposes ramp_quote_payout, ramp_send_payout and ramp_payout_status. The tools are the SDK’s own agentTools, so there is one source of schemas for MCP and for any other agent runtime and a tool cannot drift between them.
Spending limits are not enforced in the MCP layer. They belong to the agent key the server checks, so an agent holding these tools can only ever do what its key allows, whatever it asks for — and a refusal comes back as a result rather than a crash, naming the limit it hit and what is still allowed, so the agent can retry within it.
Declared and not built
Section titled “Declared and not built”A bundled wallet, SEP-6/24 collections against a real anchor, invoices and a dashboard. The Tron USDT deposit lane is built and exposed as POST /v1/deposits/tron, but has not been run with real money and needs an app willing to delegate a Solana signer. See ADR 0013.
MVP keeps the wallet out of Ramp: an app brings its own signer through signXdr, and Ramp sponsors the reserve, the trustline and the fee. Passkey login and Soroban smart accounts are a milestone after MVP, not a shipped feature.