React Native
Open the hosted checkout in an in-app browser, and ask your own backend what happened. No SDK, no WebView.
Use the hosted checkout. Open it in an in-app browser, and when the customer comes back, ask your own backend for the payment's status.
The embedded checkout does not apply here. embed.js builds an iframe and listens
for postMessage; neither exists in React Native, and there is no port of it — see
Why not a WebView.
Create the payment on your server
Exactly as in the hosted flow. The secret key must never reach the app, so the app calls an endpoint of yours and gets back two things:
const payment = await cpay.payments.create({
amount: "12.50",
description: "Order #1041 — two plushies",
metadata: { order_id: "1041" },
successUrl: "https://shop.example.com/app/paid",
idempotencyKey: "order-1041",
})
return Response.json({ id: payment.id, checkout_url: payment.checkout_url })successUrl has to be https — a custom scheme like myapp://paid is refused when the payment is
created. That is deliberate, and it is also what makes the optional
App Link below possible.
If the app lets the customer pick an asset, ask the gateway what to offer
Serve the picker from GET /v1/assets through your own
endpoint — never from a list in the app. Which assets a store accepts is a merchant setting, and an
app is the worst place to keep a stale copy of one: the merchant switches USDT off, shipped builds go
on offering it, and the customer finds out as a 400 after choosing it. A store server can fix a
hardcoded list with a deploy; an app has to wait for review. Render nothing until the list arrives —
an asset row that appears and then loses an option is worse than one that appears a moment later.
Open it, then ask
import * as WebBrowser from 'expo-web-browser'
async function pay() {
const { id, checkout_url } = await fetch('https://shop.example.com/api/checkout', {
method: 'POST',
}).then((r) => r.json())
// Resolves when the customer dismisses the browser, however they dismiss it.
await WebBrowser.openBrowserAsync(checkout_url)
// Your own endpoint, which calls cpay.payments.get(id) with the secret key.
const { status } = await fetch(`https://shop.example.com/api/payments/${id}`).then((r) => r.json())
if (status === 'confirmed' || status === 'overpaid') showThanks()
else showStillWaiting(id)
}That is the whole integration. Note what it does not do: it never parses a return URL, so there are no deep links to register and nothing to configure per platform.
Do not read the query string with `new URL()`
If you do end up parsing a return URL, URL.searchParams is missing from React Native's URL
polyfill and will throw at runtime rather than at build time. Use Linking.parse(url).queryParams.
Without Expo, react-native-inappbrowser-reborn behaves the same way. Linking.openURL(checkout_url)
also works and sends the customer to Safari or Chrome proper — less tidy, and you lose the
resolves-on-dismiss signal, so you would poll on app foreground instead.
What the customer sees
Two ways to pay, and neither needs anything from your app.
Connect a wallet app. The checkout opens a WalletConnect session, which deep-links into MetaMask, Trust, Rainbow or whichever wallet the customer picks, and the transfer is approved there. No provider is injected into an in-app browser — none is injected into mobile Safari or Chrome either, only desktop extensions and wallet apps' own browsers do that — so the session is how a phone reaches a wallet at all.
On the Chromia rail from a phone, Chromia Pay holds that session itself instead of leaving it in the page. It has to: on that rail the payment is the signature, the customer approves it in their wallet app, and iOS suspends the backgrounded page and refuses it the socket the answer would arrive on — so a signature sent back to the page can be lost, and the payment with it. Held on the gateway the customer approves the connection, approves the signature, and is finished; the gateway builds and submits the transfer itself. The EVM rails never needed this: their answer is a transaction already on chain, and the gateway finds it whether anyone hears back or not.
Send manually. The deposit address, the exact amount, and on the Chromia rail the memo the
transfer must carry, updating live over the page's own stream the instant the money lands. This is the
path for a customer paying from an exchange or from Chromia Vault, where no session helps. On the EVM
rails it also offers an Open in a wallet app link that hands the address and amount straight to
whatever has claimed the ethereum: scheme.
Each approval is a trip to the wallet — and on Chromia, not back
A session is opened in one hop and every signature is another: the wallet comes to the front and the customer approves. That is how mobile web works everywhere, not something this checkout does differently. What differs by rail is whether they have to come back: on an EVM rail the transfer is only found once someone looks at the chain, so returning is how the customer sees it settle, while on the Chromia rail the gateway holds the connection and finishes the payment on its own — your webhook fires whether or not the customer ever reopens your app. If you want approvals to happen without leaving your app at all, that is a native wallet SDK in your own screens, not this page.
Two rules
Fulfil from the webhook, never from the return. The customer can kill the browser between paying and being redirected, and the payment is no less real for it. The status check above is for deciding what screen to show, not for releasing goods. See Webhooks.
A dismissed browser means unknown, not cancelled. They may have sent the funds and swiped away while it was still confirming. Show a pending state and let the webhook settle it — telling someone their payment was cancelled while it is on its way to you is worse than saying nothing.
Optional: close the browser automatically
Register https://shop.example.com/app/paid as a Universal Link (iOS) / App Link (Android) and the
system hands the redirect to your app, foregrounding it without the customer tapping Done. It needs an
apple-app-site-association and an assetlinks.json served from that domain, plus
ios.associatedDomains and android.intentFilters in your app config.
Treat it strictly as a nicety. It is fiddly to get right, iOS has historically been inconsistent about routing a redirect it was not given a user gesture for, and the flow above already works without it.
Why not a WebView
Two independent reasons, either one fatal.
embed.js cannot run: it is DOM code from top to bottom. And loading the page with ?embed=…
directly, to skip the script, fails silently — inside a WebView window.parent is window, so
the page posts its status messages to itself and your app hears nothing. You would ship a checkout
that looks embedded and reports nothing.
Teaching the page about window.ReactNativeWebView.postMessage is a handful of lines, and still not
worth it: a WebView has no wallet deep links and no password manager, Apple has opinions about
payment flows inside them, and you would gain nothing over the in-app browser.