Skip to content

HTTP routes ​

All paths are relative to baseUrl. For example, with baseUrl: 'https://app.example.com/api/openramp', GET /sessions/:id is https://app.example.com/api/openramp/sessions/ors_....

You rarely call these yourself: @openrampkit/client does. They are listed here for custom clients, debugging and proxies.

Summary ​

MethodPathAuthPurpose
POST/sessionsauthorize hook, Idempotency-KeyCreate a session (only when authorize is set)
GET/sessions/:idBearer client secretRead the session
GET/sessions/:id/stepBearerRead the session after a status check
GET/sessions/:id/sources?method=BearerDeposit: chains and tokens for the source picker
GET/sessions/:id/historyBearer client secretThe past sessions of the session's user (option history)
POST/sessions/:id/deposit-addressBearer, Idempotency-KeyDeposit: the user's standing deposit address (option depositAddresses.widget)
POST/sessions/:id/planBearerPlan pathways and methods
POST/sessions/:id/targetBearerWithdraw: set the target the user picked, and plan
POST/sessions/:id/quotesBearerQuote a method
POST/sessions/:id/selectBearer, Idempotency-KeyConfirm a quote and start the first leg
POST/sessions/:id/transitions/:nameBearer, Idempotency-KeyFire a transition
POST/sessions/:id/cancelBearer client secretCancel the session before money moved
POST/sessions/:id/pay-linkBearer client secretMake a signed pay link
POST/sessions/:id/pay-link/revokeBearer client secretMake one pay link stop working
GET/start/:tokenSigned tokenPopup-safe redirect to a provider
GET/pay/:credentialSigned credentialHosted pay page for one session
GET/returnnone"You can close this tab" page
POST/webhooks/:adapterIdAdapter verifiesProvider webhooks
any/adapters/:adapterId/*Adapter decidesAdapter routes
GET/healthnone (?deep=1: tasks token)Quick check; deep check of each adapter
POST/tasks/sweepBearer tasksTokenRun the background sweep
GET/adminnone (the page asks for the token)Ops dashboard page (only with admin.token)
GET, POST/admin/*Bearer admin.tokenAdmin routes: list, inspect, resolve, replay, stats
OPTIONSanynoneCORS preflight: 204

Every POST accepts an Idempotency-Key header (see Idempotency). Every response has the header openramp-version: 1.

Authentication ​

Session routes need the client secret:

Authorization: Bearer ors_6a1f0c2b9d8e7f6a5b4c3d2e.4b1f...

The session id before the dot must equal :id. A missing or wrong secret gets 401 UNAUTHORIZED. A pay link credential (ors_....pay_{exp}_{linkId}_{sig}) also works, until it expires or the app revokes it. When an open session is past its expiry, loading it moves it to EXPIRED first.

Session routes record the caller's IP (cf-connecting-ip, x-real-ip or the first x-forwarded-for) for adapters.

Errors ​

Every error is JSON:

json
{ "error": { "code": "QUOTE_EXPIRED", "message": "The quote expired. Get a new quote to continue.", "retryable": true } }
StatusWhen
400Body is not JSON (BAD_REQUEST), a field that is not valid (amount, address, source, target, Idempotency-Key), or an adapter refused the input
401Bad client secret, bad start URL signature, bad webhook signature, bad tasks token, or authorize returned null
403Withdraw target refused: DESTINATION_NOT_ALLOWED or ADDRESS_REJECTED
404Unknown route or adapter (NOT_FOUND)
409A payment is already in progress; a transition is not allowed now; nothing to continue; a withdrawal that can no longer change; a locked withdraw target (DESTINATION_LOCKED); or a concurrent change (CONFLICT)
410Quote expired (QUOTE_EXPIRED), session past its deadline (SESSION_EXPIRED), or start URL expired (plain text)
413The body is too large (BAD_REQUEST): more than 64 KiB for the browser routes, more than 1 MiB for provider webhooks
422No pathway for the method (NO_QUOTES), an amount outside amountBounds (AMOUNT_TOO_LOW, AMOUNT_TOO_HIGH), or an adapter error
429Provider rate limit, or the per-session limit (RATE_LIMITED)
500Unexpected error (INTERNAL); the details are logged, not returned
502, 503, 504Provider unavailable or timed out (PROVIDER_UNAVAILABLE). 503 also when screenAddress throws.

CONFLICT ​

Every save checks the session version. When two requests change the same session at the same time (for example a provider webhook and a browser action), one of them gets 409 with the code CONFLICT, the message "The session changed at the same time. Try again." and retryable: true. Send the request again. Provider webhooks retry a conflict on the server up to 3 times.

POST /sessions ​

Available only when authorize is set; otherwise 404.

  • Body: any JSON (default {}), passed to authorize(req, body).
  • The hook returns a CreateSessionInput or null (401).
  • country and region from geo headers are the defaults; the hook's values win.
  • Response 201: { "id": "ors_...", "clientSecret": "ors_....", "expiresAt": "2026-09-29T10:30:00.000Z" }
  • With an externalId in the input: a repeat by the same user with the same input, for a session that is not final, answers 200 with { id, expiresAt, existing: true } and no client secret. Another user, other input or a final session answers 409 EXTERNAL_ID_CONFLICT. See externalId.
  • An Idempotency-Key here is scoped to the userId that authorize returns.

GET /sessions/:id ​

Response 200: a PublicSession.

ts
type PublicSession = {
  id: string
  direction: 'deposit' | 'withdraw'
  destination?: Destination       // withdraw: absent until the user picks a target
  source?: WithdrawSource         // withdraw only
  allowedDestinations?: AllowedDestinations // withdraw only, when the app set them
  destinationLocked?: boolean          // withdraw only: true when the app set and locked the destination
  status: 'requires_payment_method' | 'requires_action' | 'processing' | 'succeeded' | 'failed' | 'canceled' | 'expired' | 'refunded' | 'reversed'
  country?: string
  currency?: string             // set after the first plan
  locale?: string               // only when the app set one
  amountBounds?: { min?: string; max?: string; currency: string }
  step: Step
  payment?: Payment             // once a payment started: the legs, provider refs and transactions
  result?: SessionResult        // once a payment started
  expiresAt: string
  livemode: boolean
}

See Payment and SessionResult.

GET /sessions/:id/step ​

Same response as GET /sessions/:id. Before answering, the server asks the active leg's adapter for status (status()), at most once every 2 seconds per leg. This is the route the client polls.

POST /sessions/:id/deposit-address ​

Body: { "chain": "eip155:42161", "token": "0xaf88..." }, the asset the user sends, or { "currency": "USD" } for the user's standing bank account (only a currency in the bank adapter's standingCurrencies, else 400). Only with the server option depositAddresses.widget, else 404. A withdrawal, a destination with settlement or calls, or a session whose allowedMethods or policy.disabledMethods leave out deposit_address, gets 409. A final session, or a session of another mode than the server (livemode), gets 409. After the session deadline it gets 410. Only with the client secret: a pay link gets 403, because a pay link can go to another person (its plan has no deposit_address). It counts against the per-session request limit.

Response 200: the session user's standing address for that origin and the session destination. It is the same on each call.

json
{ "id": "sda_...", "address": "0x...", "origin": { "chain": "eip155:42161", "token": "0xaf88..." }, "destination": { "chain": "eip155:8453", "token": "0x8335..." } }

For a currency, origin is { "currency": "USD" }, address is the main account identifier, and bank.fields has the bank details (label, value, copy).

GET /sessions/:id/history ​

Only with the server option history, else 404. Only with the client secret: a pay link gets 403, because a pay link can go to another person. A store queue with no range gets 501. The route counts against the per-session limit limits.providerCallsPerMinute: more requests get 429 RATE_LIMITED.

Response 200: the sessions of the session's user, newest first, at most 50. A session where no payment started is left out. A deposit to the user's standing deposit address has kind: "standing". See HistoryItem.

json
{
  "items": [
    {
      "id": "ors_...",
      "kind": "session",
      "direction": "deposit",
      "status": "succeeded",
      "createdAt": "2026-10-16T09:12:00.000Z",
      "amount": { "value": "25", "asset": { "kind": "fiat", "currency": "USD" } },
      "destination": { "type": "crypto", "chain": "eip155:8453", "token": "0x8335..." },
      "tx": { "chain": "eip155:8453", "hash": "0x...", "explorerUrl": "https://basescan.org/tx/0x..." }
    }
  ]
}

An item never has the client secret, the user id, the IP, provider ids or an address.

GET /sessions/:id/sources ​

Query: method is transfer (default), exchange_transfer or wallet. A withdrawal gets 409, because its source is fixed.

The server asks each adapter that offers the method and has sourceTokens(), and adds the default tokens of each chain (USDC, then the native token). It drops tokens with fields that are not valid, and it keeps a logo only when it is an https URL. It caches the list for an hour when every adapter answered. An adapter that fails or takes more than 5 seconds is left out.

Response 200:

json
{ "chains": [{ "chain": "eip155:42161", "name": "Arbitrum", "tokens": [{ "chain": "eip155:42161", "token": "0xaf88...", "symbol": "USDC", "decimals": 6, "name": "USD Coin", "logoUrl": "https://..." }] }] }

The list fills the picker only. It does not limit what the user can pay with: the quote decides that. A UI must show a fallback when a logo does not load (for example when the page's CSP blocks it).

POST /sessions/:id/plan ​

Body (all optional):

json
{ "walletConnected": true, "walletAddress": "0x...", "surfaces": ["REDIRECT", "IFRAME", "QR"] }

surfaces defaults to every kind except PROVIDER_SDK. The server runs each adapter's catalog() (or its static legs), plans, applies the session's allowedMethods, and stores the plan.

Response 200:

ts
type PlanResult = {
  pathways: Pathway[]
  methods: MethodOption[]   // sorted: connected, recommended, more, unavailable
  currency: string
}
type MethodOption = {
  method: string; name: string; kind: string
  group: 'connected' | 'recommended' | 'more' | 'unavailable'
  reason?: OpenRampError
  providers: string[]; pathwayIds: string[]
  eta: { min: number; max: number }
  limits?: { min?: string; max?: string; currency: string }
}

POST /sessions/:id/target ​

Withdraw sessions only. It sets where the funds go, then plans like /plan.

Body, "To wallet":

json
{ "type": "crypto", "chain": "eip155:42161", "token": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", "address": "0x2222...", "symbol": "USDC", "decimals": 6 }

Body, "To cash":

json
{ "type": "fiat", "currency": "PHP" }

Body, "To Cash App" (a $cashtag or a Lightning address):

json
{ "type": "lightning", "address": "$alice" }

Each can also carry the /plan fields walletConnected, walletAddress and surfaces.

The server:

  1. checks the format: a CAIP-2 chain, a token address or native, and an address that is valid for the chain; or an ISO 4217 currency (uppercased); or a $cashtag or Lightning address (see To Cash App),
  2. checks the session's allowedDestinations,
  3. calls screenAddress(address, chain) for a crypto target, or screenLightningAddress(address, { cashtag }) for a Lightning target,
  4. stores the target as the session destination, clears the stored quotes, and plans.

Response 200: a PlanResult.

StatusWhen
400The body is not valid (for example "Enter a valid address for this network.")
403DESTINATION_NOT_ALLOWED (not in allowedDestinations, or a Lightning target when only screenAddress is set) or ADDRESS_REJECTED (the screening hook returned something other than true)
409Not a withdraw session; the app locked the target (DESTINATION_LOCKED); a payment is in progress; the withdrawal is complete or expired
503The screening hook threw (PROVIDER_UNAVAILABLE, "We could not check this address. Try again.")

On a withdraw session, /plan and /quotes answer 409 ("Choose where to send the funds first.") until a target is set.

When the app created the session with destination and lockDestination: true, the target is already set. Then this route always answers 409 DESTINATION_LOCKED, also for the same target and also for a pay link credential. Call /plan to get the plan. The session shows destinationLocked: true. See Locked targets.

POST /sessions/:id/quotes ​

Body:

json
{ "method": "vietqr", "amount": "500000", "amountSide": "source", "source": { "chain": "eip155:42161", "token": "0xaf88..." } }
FieldRequiredDescription
methodyesA method from the plan
amountyesDecimal string, for example "25.50" (digits, then an optional dot and digits). "0" is allowed for transfer.
amountSideno'source' (default) or 'destination' (one-leg pathways only)
sourcenoFor wallet and transfer: the token the user pays with ({ chain, token }, a CAIP-2 chain and a token address or native)

The server plans first if needed. It quotes up to 5 available pathways for the method in parallel.

For a withdraw session, the source is the session's source, and body.source is ignored.

Response 200: { "quotes": PublicQuote[], "errors": OpenRampError[] }, ranked (see Quoting). A quote outside the session's amountBounds is dropped, and errors gets AMOUNT_TOO_LOW or AMOUNT_TOO_HIGH. Errors: 400 without method or amount, or when a field is not valid; 409 while a payment is in progress; 422 NO_QUOTES when the method has no available pathway.

Each PublicQuote has guarantee (firm, min_output or estimate), minOutput and slippageBps when they apply, typed fees (amount is an Amount or null, with included), and a required expiresAt. See Fee and Quote.

POST /sessions/:id/select ​

Headers: Idempotency-Key: <random> (recommended).

Body: { "quoteId": "q_...", "walletAddress": "0x...", "gasless": true } (walletAddress and gasless optional). gasless: true asks for gas abstraction on a wallet deposit: the adapter may return a WALLET_SIGN surface instead of WALLET_TX. Send it only when your UI renders WALLET_SIGN. A withdrawal ignores it. See Relay: Gas abstraction.

The server checks amountBounds again, starts the first leg and returns the PublicSession with the new step. A REDIRECT surface URL is replaced by a start URL. A surface URL that is not safe fails the leg (see Surface URLs). Errors: 410 QUOTE_EXPIRED when the quote is unknown or expired; 409 while a payment is in progress; 422 AMOUNT_TOO_LOW or AMOUNT_TOO_HIGH outside the bounds. If the first leg fails to start, the active pathway is rolled back.

POST /sessions/:id/transitions/:name ​

Headers: Idempotency-Key: <random> (recommended).

Body: { "inputs": { "txHash": "0x..." } } (inputs optional).

  • restart: back to SELECT_METHOD. Allowed when the status is requires_payment_method (no payment in progress, or the last attempt failed) and during PAYMENT. A session with a final status (succeeded, failed, canceled, expired, refunded, reversed) answers 409. Otherwise 409. The server keeps the left payment as an earlier attempt. When the provider later reports it as paid, the session completes with it, or sends session.late_payment when another payment is already in progress or complete.
  • Any other name must be a SUBMIT or SURFACE_RESULT transition of the current step, and the adapter must implement transition(). Otherwise 409.

Response 200: the PublicSession.

Idempotency ​

Every POST accepts Idempotency-Key: <random>. Send a new random key for each new request, and the same key when you send the same request again (for example after a timeout).

  • The server stores the response under (session, route, key) for 24 hours, with a hash of the request body. The route is the path after the session id, for example select, quotes or transitions/{name}. For POST /sessions, the scope is the user from authorize.
  • A repeat with the same body returns the stored status and body, with the header idempotent-replay: true.
  • A repeat with another body answers 422 IDEMPOTENCY_MISMATCH. Use a new key for a new request.
  • A repeat while the first request still runs answers 409 CONFLICT (retryable: true).
  • The same key on another route is another request.
  • A request that fails with an error before it has a response stores nothing, so the key is free again.
  • The key must have 1 to 255 printable ASCII characters (else 400).

Session deadline ​

After expiresAt, /plan, /target, /quotes, /select and the restart transition answer 410 SESSION_EXPIRED. A payment that started before the deadline can still finish: /step and the other transitions still work. The exception is a leg that still waits for the user (requires_action): the background sweep expires it after the deadline.

Limits ​

  • A JSON body can have at most 64 KiB, and a provider webhook body at most 1 MiB. A larger body gets 413.
  • walletAddress (in /plan, /target and /select) must be 8 to 128 printable characters.
  • /plan, /target, /quotes, /select and /transitions/* call provider APIs. Together they count against limits.providerCallsPerMinute per session (default 60). Over the limit: 429 RATE_LIMITED.

POST /sessions/:id/cancel ​

Cancels the session when no money of a payment can be on its way. The client secret only: a pay link gets 403.

  • requires_payment_method (nothing started, or the last attempt failed): allowed.
  • requires_action: allowed only before any money moved. The server refuses (409) when a leg reported a transaction that moves funds (any role but approval), the treasury sent, a leg is past requires_action, or a leg after the first started.
  • When the waiting leg's adapter has cancel(), the server asks the provider to void the order first. A provider error refuses the cancel: 409 PROVIDER_UNAVAILABLE, and the session does not change.
  • Then the step becomes CANCELED (with the error CANCELED), the status canceled, and canceled: { at, reason: 'requested_by_user' } is set. The server sends session.canceled.
  • Response 200: the PublicSession. A session that is already canceled is returned as it is.
  • 409 while the payment is processing, and after another final status.
  • The leg refs stay indexed. A payment that still arrives (a provider webhook, or the sweep's grace poll for an adapter with status()) goes into the timeline and the leg data, and the server sends session.late_payment with reason: 'after_cancel'. The session stays canceled: refund or credit the payment by hand.
  • The restart transition has the same rule while the user still has to pay: 409 when money may be on its way.

Your backend can also call openramp.sessions.cancel(id, { reason }).

GET /start/:sessionId.:token.:sig ​

Checks the HMAC signature (401 when wrong), then the token's expiry (10 minutes; 410 text when expired), then answers 302 to the provider URL with cache-control: no-store and referrer-policy: no-referrer (or strict-origin when the surface set keepReferrer).

POST /sessions/:id/pay-link ​

Makes a signed link to the pay page. Only the client secret can call it: a pay link credential gets 403.

  • Body (optional): { "ttlMinutes": 15 }. The default and the maximum is the session expiry plus 30 minutes.
  • Response 201: { "id": "9f2c4a1be07d5c36", "url": "https://.../pay/ors_....pay_tm5nzp_9f2c4a1be07d5c36_c7c8...", "expiresAt": "..." }
  • 404 when payPage is false.

id identifies the link. Keep it if you can revoke the link later. The backend API openramp.sessions.payLink(id, { ttlMinutes? }) returns the same object without a client secret.

POST /sessions/:id/pay-link/revoke ​

Makes one pay link of the session stop working. Only the client secret can call it: a pay link credential gets 403.

  • Body: { "id": "9f2c4a1be07d5c36" }, the id from /pay-link.
  • Response 200: { "revoked": true }. A repeat call gives the same answer.
  • 400 when id is not a pay link id, or when the session has 100 revoked links.

After this call, the pay page answers 410 and the session routes answer 401 ("This pay link no longer works.") for that link. Other links and the client secret keep working. The backend API is openramp.sessions.revokePayLink(id, linkId).

GET /pay/:credential ​

A small HTML page that mounts <openramp-modal> for the session in embedded mode (openDeposit or openWithdraw). A person opens it on a phone and pays. See Agents (MCP).

The credential is {sessionId}.pay_{exp}_{linkId}_{sig}: exp is the expiry (Unix seconds, base 36), linkId is a random link id (16 hex digits), and sig is an HMAC-SHA256 of the session id, exp and linkId with secret. The page passes the credential to the modal as the client secret.

StatusWhen
200HTML with cache-control: no-store, referrer-policy: no-referrer, and a content security policy with a script nonce and frame-ancestors 'none'
401The signature is wrong (plain text)
404The session does not exist, or payPage is false
410The link expired, or the app revoked it (plain text)

The page imports payPage.scriptUrl (default https://esm.sh/@openrampkit/web@0).

GET /return ​

A small HTML page: "You can close this tab and go back to the app." It tries window.close() after 800 ms.

POST /webhooks/:adapterId ​

For provider callbacks. The server reads the raw body, calls the adapter's webhook.verify() (401 when false) and webhook.parse(), and applies each event to the session that owns its ref. 404 when the adapter has no webhook handler.

AnswerWhen
200 { "received": true }Each event was applied, or is safe to ignore (for example a repeat of a final status that the leg already has).
503 with retry-after: 30 and the code PROVIDER_UNAVAILABLEAt least one event could not be applied: no session has its ref yet (the reference index can lag behind), the session is not in the store, or the session changed at the same time on 3 tries. The provider sends the event again. An event that is applied two times has no other effect.

An event for a payment that the user left with restart still applies to that payment. See Webhooks to your backend.

/adapters/:adapterId/* ​

Passed to the adapter's routes(req, subpath, ctx). 404 when the adapter has no route for it.

GET /health ​

By default, a quick check with no provider calls and no auth. Use it for uptime checks:

json
{ "ok": true, "adapters": ["relay", "swapped"] }

GET /health?deep=1 also calls each adapter's health() (adapters without it count as ok). These call provider APIs, so the deep check needs Authorization: Bearer {tasksToken}. Without the token, or when tasksToken is not set, it answers 401.

json
{ "ok": false, "adapters": [{ "id": "relay", "ok": true }, { "id": "swapped", "ok": false, "detail": "HTTP 401 from ..." }] }

Status 200 when all are ok, else 503.

POST /tasks/sweep ​

Runs openramp.sweep(): it retries failed webhooks, refreshes open payments and expires idle sessions.

  • Header: Authorization: Bearer {tasksToken}. A wrong or missing token gets 401.
  • Without tasksToken in the config, the route answers 404.
  • Query: ?limit=50 (optional) caps the work per run.

Response 200: a SweepResult.

json
{ "webhooks": { "retried": 1, "delivered": 1, "dropped": 0, "pending": 0 }, "sessions": { "checked": 3, "changed": 1, "expired": 1, "open": 1 } }

Admin routes ​

The routes for operators. See Admin and observability.

  • Header: Authorization: Bearer {admin.token}. A wrong or missing token gets 401. The server compares the token in constant time.
  • Without admin.token in the config, every /admin path answers 404.
  • Answers have cache-control: no-store. Admin routes never send CORS headers.
  • Put /admin behind your own auth or a VPN in production.
MethodPathAnswer
GET/adminThe dashboard page (HTML). 404 when admin.page is false.
GET/admin/sessions{ sessions, nextCursor?, scanned }, newest first. Query: direction, state, olderThan (minutes), stuck=1, limit (1 to 200), cursor. 400 for a bad value.
GET/admin/sessions/:idThe AdminSession view. 404 when the session does not exist.
POST/admin/sessions/:id/resolveBody { "state": "COMPLETED" | "FAILED" | "REFUNDED" | "EXPIRED", "note": "..." }. Answer: the AdminSession view. 400 without a note, 404 for an unknown session, 409 when the session already has that state.
POST/admin/sessions/:id/replay{ "queued": 1 }: the dead letters sent again. 404 for an unknown session.
GET/admin/statsAdminStats. Query: since (ISO 8601 or ms; default 24 hours ago).
GET/admin/providers{ providers: ProviderHealth[] }: counts, success rate, recent failure rate and circuit breaker state per adapter. See Provider health.
GET/admin/find{ sessions: [...] }. Query: provider and ref, or tx and an optional chain. tx finds a transaction of any role (approval, source, hop, destination, settlement, refund) in any payment attempt; with chain, the transaction must be on that chain.

The list, stats and tx search answer 501 when the store queue has no range (a custom store). See The time index.

GET /admin ​

A self-contained HTML page: inline CSS and script, no external requests. Its headers:

content-security-policy: default-src 'none'; script-src 'nonce-{random}'; style-src 'nonce-{random}'; connect-src 'self'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'
cache-control: no-store
referrer-policy: no-referrer
x-robots-tag: noindex

The page asks for the admin token and keeps it in sessionStorage. It then calls the routes above.

CORS ​

With cors: { origins: [...] }, requests from a listed origin get access-control-allow-origin, access-control-allow-headers: authorization, content-type, idempotency-key, access-control-allow-methods: GET, POST, OPTIONS and access-control-max-age: 600. With '*', any origin is allowed. Without cors, no CORS headers are sent (same origin only). The /admin routes never get CORS headers.

MIT licensed