Skip to content

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 trustline
const 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);
  1. Quote. POST /v1/quotes prices 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.
  2. Route and saga. POST /v1/withdrawals spends the quote once and plans the order on the Hub’s route planner (planRoute, with custody: "wallet" because the user, not Corridor, holds the funds). The plan runs as a durable saga on the Hub’s saga engine: its partner.payout leg 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.
  3. Sign. The user’s wallet signs it in the app, through the SDK’s signXdr. Ramp never sees the key.
  4. Submit. POST /v1/withdrawals/:id/submit checks that the hash matches the transaction Ramp built, wraps it in a sponsor fee bump, and submits. Nothing is booked yet.
  5. 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).
  6. Track. The anchor’s webhooks (or polling) report its side. The order is completed only when the anchor has paid out and the chain shows the funding. A completed from 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).

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.

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
  • Database. PGlite on disk for local runs and tests; set DATABASE_URL to 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_limited with Retry-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.
Terminal window
pnpm ramp:serve # the API on :8787, mock anchor unless CORRIDOR_ANCHOR_BASE_URL is set
pnpm ramp:demo # signup → sponsored account + USDC trustline → payout in testnet USDC → usage

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’s maxAmountRequired, 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.

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:

Terminal window
RAMP_API_KEY=rk_live_agent_… RAMP_API_URL=https://ramp.example pnpm ramp:mcp

It 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.

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.