Stripe Link (headless)
@openrampkit/adapter-stripe-link uses the Stripe Embedded Components onramp to buy USDC in the US. The user signs in to Link. Then the purchase runs in your own screens. Stripe shows only small parts: the Link sign-in (a code and a consent screen), the Payment Element, the identity document check and 3DS.
The Stripe adapter is different: it shows Stripe's full onramp UI (embedded or hosted).
Unverified
This adapter is built from Stripe's docs only. It is not tested against a Stripe account yet. The API is in private preview: no call works, also in a sandbox, until Stripe finishes your onboarding. Some details are marked TO VERIFY (listed below).
import { stripeLink } from '@openrampkit/adapter-stripe-link'
stripeLink({
secretKey: process.env.STRIPE_SECRET_KEY!,
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY!,
oauthClientId: process.env.STRIPE_LINK_OAUTH_CLIENT_ID!,
webhookSecret: process.env.STRIPE_LINK_WEBHOOK_SECRET!,
tokenKey: process.env.STRIPE_LINK_TOKEN_KEY, // optional
})To get the keys, see Get provider keys.
Options
| Option | Type | Default | Description |
|---|---|---|---|
secretKey | string | required | Secret key of the onboarded account |
publishableKey | string | required | Passed to the client for the Stripe SDK (@stripe/crypto) |
oauthClientId | string | required | The Link OAuth client id from Stripe onboarding |
webhookSecret | string | required | Endpoint secret (whsec_...) for crypto.onramp_session.updated |
tokenKey | string | derived from secretKey | Secret (at least 32 characters) for the key that encrypts the Link access token in the session store. Set your own. |
requireSessionEmail | boolean | false | Only the session's own email may sign in to Link. A session with no email cannot start the leg, and the email FORM never shows. A session email is always locked, also when this is false. |
methods | string[] | all | Leg ids: card, apple_pay, google_pay, ach |
apiUrl | string | https://api.stripe.com | API base URL |
linkUrl | string | https://login.link.com | Link OAuth base URL |
stripeVersion | string | 2026-09-30.endive;crypto_onramp_beta=v2 | The Stripe-Version header |
env | 'sandbox' | 'production' | from the key prefix | sk_test_ is sandbox; sk_live_ is production. A value that does not agree with the key throws. |
Legs
| Leg | Fiat | Countries | Payment Element |
|---|---|---|---|
card | USD | US, not NY | payment_method_types: ['card'] |
apple_pay | USD | US, not NY | ['card'] with wallets.applePay: 'auto' |
google_pay | USD | US, not NY | ['card'] with wallets.googlePay: 'auto' |
ach | USD | US, not NY | ['us_bank_account'] |
- Delivers USDC on Base, Ethereum and Solana. Live mode adds Arbitrum, Polygon and Tempo (USDC.e). Stripe also lists Sui, World Chain and Celo, which the kit does not route yet. Another token or chain gets no quote (
NO_QUOTES). - Each leg declares the
PROVIDER_SDKsurface (the emailFORMis a fallback), and needsprovider_account,provider_kycandotp.
The flow
Each step is a PROVIDER_SDK surface with provider: 'stripe-link'. Its params hold publishableKey and a step. The client runs the Stripe SDK call for the step, then calls the transition. The server never sees the KYC data, the card or the bank account.
| Step | Phase | params | SDK call on the client | Then call the transition |
|---|---|---|---|---|
Email (a FORM, only when the session has no email) | AUTH | submit_email with { email } | ||
register_link_user (no Link account for the email) | AUTH | email, country | registerLinkUser(email, phone, country, fullName?) | link_registered |
authenticate | AUTH | linkAuthIntentId | authenticate(linkAuthIntentId, cb) | link_authenticated with { cryptoCustomerId, clientKey } (cryptoCustomerId from the callback) |
submit_kyc_info | KYC | submitKycInfo(kycInfo) with your own form | kyc_submitted with { clientKey } | |
verify_documents | KYC | verifyDocuments() | documents_verified with { clientKey } | |
register_wallet_address | PAYMENT | walletAddress, network | registerWalletAddress(walletAddress, network) | wallet_registered with { clientKey } |
collect_payment_method | PAYMENT | paymentMethodTypes, wallets | collectPaymentMethod(...) | payment_method_collected with { paymentToken, clientKey } (cpt_...) |
perform_checkout | PAYMENT | onrampSessionId, clientSecret | performCheckout(onrampSessionId, cb) | checkout with { clientKey } (and userAgent for ACH) from inside cb; return the new clientSecret |
clientKey (the STRIPE_LINK_CLIENT_KEY export) is a random value of 32 to 128 URL-safe characters (for example 32 random bytes in base64url). The client makes it before link_authenticated, keeps it in memory, and sends the same value with every later transition. See Security.
The server skips a step when Stripe says it is done: KYC that is already verified, or a wallet that is already on file. While a KYC review runs, the leg waits in processing with phase kyc, and each status check asks Stripe again.
The renderer
The web modal draws these steps with stripeLinkRenderer() from @openrampkit/web/stripe-link. It is a separate entry: the main @openrampkit/web entry never imports it. It uses Stripe's @stripe/crypto (an optional peer dependency, loaded with a dynamic import when the first step mounts).
npm install @stripe/cryptoimport { openDeposit } from '@openrampkit/web'
import { stripeLinkRenderer } from '@openrampkit/web/stripe-link'
openDeposit({
baseUrl: '/api/openramp',
clientSecret,
providerRenderers: { 'stripe-link': stripeLinkRenderer() },
})Use one renderer for each modal. It keeps the Stripe SDK instance and the client key for all the steps of one purchase. The options are in the web API.
| Step | What the renderer shows |
|---|---|
register_link_user | A form for the phone number (E.164; a 10 digit US number gets +1) and the full name. Or your collectRegistration. |
authenticate | Stripe's Link sign-in (one-time code and consent). A closed or declined sign-in shows "Try again". |
submit_kyc_info | A form for the name, SSN, date of birth and US address. Or your collectKyc. |
verify_documents | A "Verify your identity" button. Stripe's document check needs a user gesture. |
register_wallet_address | Nothing to do: it registers the server's address and network at once. |
collect_payment_method | Stripe's Payment Element. |
perform_checkout | A "Pay" button. Stripe runs 3DS. Each checkout attempt calls checkout. |
- The modal mounts the renderer again when
params.stepchanges. When a transition gives the same sub-step again (a new Link sign-in, or identity documents again), the renderer runs it again with the new params. - The modal shows no buttons for the
SUBMITtransitions of these steps: the renderer calls them with its inputs. checkoutreturns the step with the newclientSecret, and the renderer gives it to Stripe. When the answer has no client secret (the purchase is already processing, done or failed), the renderer asks the modal for a status check.- Your CSP must allow scripts and frames from
js.stripe.com(the SDK loadshttps://js.stripe.com/crypto-onramp/v1/crypto-onramp.jsas a module script) and the frames of Link and the Payment Element.
Without any provider renderer, the planner shows these methods as "Not available" in the modal (CLIENT_UPGRADE_REQUIRED). The planner checks the surface kind, not the provider: when you give a renderer for another provider (for example stripe) and not for stripe-link, also set methods or leave this adapter out. A headless app can drive the flow with client.transition().
The renderer and your data
- The client key. The renderer makes it with
crypto.getRandomValues(32 bytes, base64url) and sends it asclientKeywith every transition, only to your OpenRampKit server. It keeps the key in memory only: not inlocalStorageorsessionStorage, not in the DOM, not in a log. Each new Link sign-in makes a new key. A page reload loses it, and the server then asks for a new Link sign-in. Storage would not help here: the Stripe SDK keeps its Link sign-in in memory too. - KYC and the phone number. The form data goes straight to the Stripe SDK (
submitKycInfo,registerLinkUser). It is never sent to your server, never in a transition, a log or an event. The form is emptied when the user submits it. - Card and bank data. They stay inside Stripe's Payment Element. Only the payment token (
cpt_...) goes to your server. - The client secret of the checkout goes from the transition answer to the Stripe SDK only. It is never in the DOM.
- Stripe SDK errors are shown with their message (Stripe writes them for users). Server errors are shown by the modal.
Link tokens
- The OAuth access token stays on the server. It is never in a step, an event or a log.
- The adapter seals it (AES-256-GCM, a key from HKDF-SHA256 of
tokenKey; the additional data is the session id, the app user id, the leg ref and the checked crypto customer id) and keeps it in the session store for its lifetime minus 60 seconds (at most 1 hour). - The adapter never keeps the refresh token. When the access token expires, the user signs in to Link again.
- The adapter drops the token when the payment leaves the checkout, when the leg fails, and on cancel.
Security
The adapter binds the Link identity to the session and to the client that signed in.
- Sign-in needs the account owner. The server creates the LinkAuthIntent for the email. Only the owner of that Link account can approve it in the Stripe SDK (a one-time code and a consent screen). The server gets a token only from
POST /v1/link_auth_intent/{id}/tokens, which Link refuses (403) until the owner approves. The intent id comes from the server's record, never from the client. - The email. An email that the app sets on the session is locked:
submit_emailis refused. SetrequireSessionEmail: trueto allow only the app's email (use this when your app has verified the email). Without an app email, the user types one in theFORM. They can change it before the sign-in, but not after it. A person who types another person's email cannot finish the sign-in without that person's one-time code. - The crypto customer. The client sends
cryptoCustomerIdfrom the SDK callback. Before the server keeps the token or uses the id, it readsGET /v1/crypto/customers/{id}with the new token, and Stripe must answer with the same id. Otherwise the token is not stored and a new sign-in starts. - The client key.
link_authenticatedneeds aclientKey. The server keeps only its SHA-256. Every later transition (KYC, documents, wallet, payment method, checkout) must send the same key. The key is never in a step, so another holder of a session credential (a pay link, another device, a leaked client secret) cannot use the Link token. Before the onramp session exists, a missing or wrong key drops the token and starts a new sign-in. After it exists,checkoutwithout the key is refused (403), and the leg stays as it is. A page reload loses the key, so the user signs in to Link again. - The session and the user. The leg record holds the session id and the app user id, and every load checks them. The sealed token opens only for the same session, user, leg ref and crypto customer. The token key comes from
tokenKey, so the token still opens after a server restart, but never for another session or user. - The stages. Each transition runs only from its own stage (409 otherwise). A client cannot skip KYC, the wallet step or the payment method step.
- One change at a time. The leg record has a change counter. A transition first takes the lease of the leg at that counter (one atomic claim,
l:{ref}:{counter}, for at most 120 seconds), and adds 1 to the counter at its end. A second change that read the same record, or an older one, gets409and changes nothing. So:- Two
link_authenticatedcalls at the same time (each with its ownclientKey) cannot both bind a key. Exactly one runs the token exchange and binds its key; the other gets409. Before, the last writer won, so a second client could replace the key of the client that signed in. - Two
checkoutcalls at the same time send one checkout to Stripe; the other gets409. Each checkout also sends an idempotency key with the counter, so a later call (the SDK calls again after 3DS) is a new attempt, and two calls from the same counter (possible only where the lease is not atomic) are one attempt at Stripe. - A transition and a new sign-in from another client (a wrong key) cannot both run. The leg is either the owner's purchase or a new sign-in, never a mix of the two.
- A status poll that could move the leg (the
kyc_reviewcheck) takes the lease too. When a transition holds it, the poll changes nothing and the next poll checks again.
- Two
- Atomic on memory, Redis and Durable Object stores. These stores have
putIfAbsent. Workers KV has no atomic operation: there the lease writes, then reads back, which catches most races but not all. Use the Durable Object store (or Redis) in production. - The wallet. The delivery address is the leg's own address, set by the server at start. The wallet step and the onramp session use only that address. A
walletAddressinput is ignored. - The payment token. The client sends
paymentToken(cpt_...) from the Payment Element. Only the client with the key can send it. Stripe ties a payment token to the Link customer. TO VERIFY: that Stripe refuses a payment token of another customer. - Status polls.
status()runs on the server (polls and sweeps) without a client key. It reads the session and the KYC state, and it can create the onramp session after a document review. It never calls checkout, so it never takes a payment.
Quotes and start
- Quote:
GET /v1/crypto/onramp_quoteswithui_mode=headlessand the secret key only (no user token).source_total_amountis what the user pays. The fees aretransaction_fee_monetary(provider) andnetwork_fee_monetary(network), withincluded: true. The guarantee isestimate: Stripe sets the final amount when the session is created. Quotes expire after 5 minutes. - Start:
POST https://login.link.com/v1/link_auth_intent(JSON:email,oauth_client_id,oauth_scopes: 'kyc.status:read,crypto:ramp'). A 404 asks the client to register the user. A 409 (the user revoked the connection) fails the leg withPROVIDER_DECLINED. - Sign-in done:
POST https://login.link.com/v1/link_auth_intent/{id}/tokens. - KYC and wallets:
GET /v1/crypto/customers/{crc}andGET /v1/crypto/customers/{crc}/crypto_consumer_wallets. - Session:
POST /v1/crypto/onramp_sessions(form-encoded) withui_mode=headless,crypto_customer_id,payment_token, the source amount, USDC, the network,wallet_address,customer_ip_addressand metadata (ork_ref,ork_session,ork_leg). A 400crypto_onramp_missing_document_verificationasks for identity documents, then the adapter tries again. - Checkout:
POST /v1/crypto/onramp_sessions/{id}/checkout. - Every api.stripe.com call sends
Authorization: Bearer <secret key>, theStripe-Versionheader and, for a user call,Stripe-OAuth-Token. - Reference: our own
slk_...id is the legref. The onramp session id (cos_...) is theproviderRef. - Status:
GET /v1/crypto/onramp_sessions/{id}.
| Stripe status | Leg |
|---|---|
fulfillment_complete | succeeded, with transaction_id as the destination transaction when it is a chain hash |
fulfillment_processing | processing, detail code processing |
initialized, requires_payment | requires_action (the checkout step) |
rejected | failed with PROVIDER_DECLINED |
| other | no event: the leg keeps its current step |
Webhooks
Add a webhook endpoint in the Stripe dashboard for crypto.onramp_session.updated with the URL {baseUrl}/webhooks/stripe-link, and pass its secret as webhookSecret. When webhookSecret is empty, the adapter refuses every webhook.
- Verification:
Stripe-Signature: t=...,v1=..., hex HMAC-SHA256 of{t}.{body}, 5 minute tolerance. Any matchingv1passes. The adapter checks it withverifyTimestampedHmacfrom@openrampkit/adapter. - The adapter takes only sessions that it made. Its own index of session ids (written when it creates a session) comes first.
metadata.ork_refis used only when the index entry has expired (after 7 days). When both exist and do not agree, the event is ignored. Events of the Stripe adapter are ignored. - Replay key: the Stripe event id.
Verified vs TO VERIFY
Verified in Stripe's docs (the web integration guide and the API reference, 2026-10-16): the endpoints, headers, parameters and response fields above, the OAuth scopes, the session statuses, the webhook event, the sandbox networks, and the payment method types.
- TO VERIFY: the KYC status values. The guide reads a
verificationsentry (kyc_verified,not_started); the API reference showskyc_tierswithverification_status. The adapter reads both. - TO VERIFY: the shape of the consumer wallet list (
datawithwallet_addressandnetworkis assumed). - TO VERIFY: the
mandate_datakeys for ACH checkout (the guide names only the parameter). - TO VERIFY: whether
transaction_idholds the chain hash (the example shows acxt_...id). - TO VERIFY: whether a session retrieve needs
Stripe-OAuth-Token. - TO VERIFY: EUR and EU users (the guide says US only, but also shows EU sessions).
- TO VERIFY: the ACH delivery time.
The renderer: verified against the types of @stripe/crypto 1.1.3 (the npm package, 2026-10-16): loadCryptoOnrampAndInitialize(publishableKey, options) with theme stripe, night or flat; registerLinkUser(email, phone, country, fullName?) (positional; the guide's example passes one object); authenticate(id, cb) resolves to an element or null; collectPaymentMethod(options, cb) resolves to an element; verifyDocuments() resolves to { result } (the guide's example compares a string); performCheckout(id, checkout) resolves to { successful }.
- TO VERIFY: that Stripe's elements (Link sign-in, Payment Element) work inside the modal's Shadow DOM.
- TO VERIFY: whether
verifyDocuments()needs a user gesture (the renderer uses a button to be safe). - TO VERIFY: what
performCheckoutdoes when the checkout callback throws (the renderer throws when the server answer has no client secret, then asks for a status check). - TO VERIFY: whether a closed sign-in can call
authenticateagain with the same LinkAuthIntent ("Try again" does this).