Production checklist
Go through this list before real money moves.
Secrets
- [ ]
secretis at least 32 random characters (openssl rand -hex 32), unique per environment, and stored as a platform secret. - [ ]
webhooks.secretis a different random value, shared only with your backend. - [ ]
tasksToken(if you usePOST /tasks/sweeporGET /health?deep=1) is another random value.CRON_SECRETon Vercel too. - [ ]
admin.token(if you use the admin tools) is another random value of at least 32 characters, kept on the server only. - [ ] You got each provider key from the correct environment (sandbox or live). See Get provider keys.
- [ ] Provider keys are server-side only. No adapter or server import reaches your client bundle.
- [ ] You use live provider keys in production and sandbox keys elsewhere. Set
livemode: truein production, so events carrylivemodeand adapters such as Coinbase leave sandbox mode. Set each adapter'senvtoproduction: withlivemode: true, the server does not start when an adapter is insandbox. - [ ] You know how to rotate each secret. Rotating
secretbreaks start URLs made in the last 10 minutes and every open pay link; rotatingwebhooks.secretneeds your backend updated at the same time.
Sessions
- [ ] Sessions are created only by your backend (
sessions.create()), or through anauthorizehook that authenticates the caller. The browser never choosesuserIdordestination. - [ ] Destination addresses come from your database, per user.
- [ ] You pass
country(andregionin the US) so region rules apply. Providers still run their own checks. - [ ]
ttlMinutesfits your flow (default 30). - [ ]
amountBoundsis set where you need limits. The server enforces it when its currency matches what the user pays (a fiat code, or a token symbol such asUSDC).
Store
- [ ] You use a shared store with an atomic version check (
durableObjectStoreon Cloudflare,redisStore, or your own). Not the memory store. Not Workers KV for real traffic. See Session stores. - [ ] Sessions are kept for days (the built-in stores keep them 7 days), so late provider webhooks still find them.
- [ ] After an upgrade to session schema 3 (the adapter contract v2), you do not roll back to an older server while sessions are open. The new server migrates each record when it reads it, but an older server does not understand a schema 3 record. See Record schema.
- [ ] Third-party adapters use the adapter API version 2. The server refuses an adapter for version 1 at startup. See Upgrade from version 1.
CORS and hosting
- [ ]
baseUrlis the exact public URL where the handler is mounted, over HTTPS. - [ ] If the browser calls another origin,
cors.originslists your app origins only. Avoid'*'in production. - [ ] Your proxy sets a trusted client IP header (
cf-connecting-ip,x-real-iporx-forwarded-for). - [ ] Request timeouts are above the 9 second quote timeout.
- [ ] If you build your own client, it sends a request again after
409 CONFLICT(retryable: true). The server returns it when two requests change a session at once. - [ ] Uptime checks use
GET /health(quick, no provider calls).GET /health?deep=1calls provider APIs and needs the tasks token.
Provider webhooks
- [ ] Each provider with webhooks points to
{baseUrl}/webhooks/{adapterId}, and its signing secret is configured on the adapter (webhookTokenfor Xendit,webhookSecretfor Coinbase, Stripe, Meld, Onramper, Peer and Flashnet,webhookKeyfor MoonPay,hmacSecretfor Banxa,webhookSecretfor PayTrie,webhookPublicKeyfor Bridge,binancePublicKeyfor Binance). Swapped sets its callback URL per order. See Get provider keys. - [ ] You tested one webhook per provider in sandbox and saw the session move.
- [ ] Transak: the adapter verifies webhooks with its cached access token. Make sure at least one quote or start ran on the instance (or the token is in the shared store) before webhooks arrive.
Background sweep
- [ ]
openramp.sweep()runs every minute or so: a Cloudflare Cron Trigger, a Vercel Cron Job, or any scheduler callingPOST {baseUrl}/tasks/sweepwithtasksToken. Without it, failed webhooks are never retried, and sessions whose users left are not refreshed or expired. - [ ] The cron route refuses requests without the secret, and the secret is set in every environment that runs it.
- [ ] You watch the logs for
webhook moved to dead letter after retries(afterwebhooks.retryHours, default 24 hours) andsweep: session check failed. To send dead letters again, callopenramp.webhooks.replay(sessionId)or use "Replay webhooks" in the ops dashboard. - [ ] Your backend handles
session.late_payment: a payment the user left withrestartsucceeded after another payment (earlier_attempt), or a payment arrived after the session expired (after_expiry, followed bysession.succeeded). Do not treatsession.expiredas final for crediting. - [ ] Your backend handles
session.reversed: it takes back or freezes the credit of a payment that the provider refunded or charged back after it completed. - [ ] Each run has enough time: it retries up to
limit(default 50) webhooks and checks up tolimitopen sessions, with provider calls for each.
Admin and monitoring
- [ ] If you turn on the admin routes,
{baseUrl}/adminis behind your own auth (SSO, an auth proxy) or a VPN. The token is not the only lock. - [ ] Only operators who need it know the admin token. You rotate it when one of them leaves.
- [ ] Each
admin.resolvehas a clear note (who, why, a ticket). Your backend handles the webhook from a resolve like any other, and credits once per session id. - [ ]
telemetry.onMetricsends metrics to your monitoring. You alert onsweep.lag_ms,outbox.depth,webhook.dead_letterandwebhook.verify_failed. - [ ] You know the limits of the time index: it lists the last
admin.indexDaysdays only. For reports, use your own database.
Your backend
- [ ] Webhooks to your backend are verified with
openramp.webhooks.verify(),verifyWebhook()or a Standard Webhooks library, using the raw body. The secret is awhsec_secret from your secret store. - [ ] Your handler is idempotent: it drops an event id it already handled (webhooks are at-least-once), and credits once per session id (a unique constraint), only on
session.succeeded, after checking the session. See Webhooks to your backend. - [ ] You credit the full
session.result.outputonly whenoutputConfirmedistrueandresult.delivery.statusisok. Otherwise you check the amount on chain (thedestinationtransaction inresult.transactions), at the provider, or on your order. - [ ] You store transaction hashes with a unique constraint, so one transaction cannot complete two sessions (same-chain Relay moves check the receipt, not who sent it).
Geo and methods
- [ ]
policy.regionsblocks the countries you must not serve. - [ ]
policy.disabledMethodsor per-sessionallowedMethodshide methods you do not want. - [ ] You checked the "Not available" group for your main countries.
Withdrawals
- [ ]
screenAddresscalls your sanctions or blocklist check. It fails closed: an error refuses the address. - [ ]
allowedDestinationslists only the chains and currencies you support. - [ ] With
custody: 'app':treasury.send()checks and debits the user's balance once peridempotencyKey, checks the recipient and amount of each transaction, and throwsTreasuryRefusedErrorto refuse (any other error makes the failure final). The server does not know the user's balance. - [ ] With
custody: 'app'and Relay:treasury.addressis set. - [ ] For withdrawals, you handle
session.failed: checkresult.transactionsand the provider before you return funds to the user.session.payment_failedis not final: the user can try again. - [ ] For withdrawals, you handle
session.reversed: the payout did not reach the user. Check the provider, then return the funds to the user.
Adapters
- [ ] You read the TO VERIFY notes on each adapter page you use, and tested those paths in sandbox.
- [ ] Relay has an
apiKey. Relay requires an API key for quotes (POST /quote/v2) under its announced policy from 2 Oct 2026. Some requests without a key may still work today, but Relay can refuse them at any time. Always setRELAY_API_KEY. The/requests/v2status fallback retires on 2026-11-24. - [ ] Stripe: pass
providerRenderers: { stripe: stripeOnrampRenderer() }to the modal, or setsurface: 'redirect'on the adapter. - [ ] Stripe Link: install
@stripe/cryptoand passproviderRenderers: { 'stripe-link': stripeLinkRenderer() }(from@openrampkit/web/stripe-link) to the modal. See the renderer. - [ ] Coinbase users know they need a Coinbase account.
UI
- [ ] You tested the modal on desktop, Android and iPhone (the repo's Playwright projects cover all three).
- [ ] Popups work: hosted checkouts open from a click on "Continue to ...".
- [ ] The theme meets your contrast needs in light and dark mode.
Legal
OpenRampKit is software, not a payment service. The providers you configure are the regulated parties: they onboard and verify users, take payments, and deliver funds under their own licenses and terms. You are responsible for your contracts with them, for the countries you serve, and for any rules that apply to your own business (for example consumer protection, tax, and sanctions screening). Check with each provider and with your legal counsel before you go live.