# Yodl Pay
Best-in-class QR payments with a 15-minute integration.
# Yodl SDK
Best-in-class QR payments with a 15-minute integration.
Your users scan an everyday local QR code — PIX in Brazil, VietQR in Vietnam, GCash in the Philippines — and pay the merchant from their own wallet. The merchant receives their own currency, never crypto. Funds stay self-custodied until the moment of payment.
:::tip[Start here]
**Building it** — the [React Native quickstart](/sdk/react-native/quickstart) goes from install to a completed payment.
**Seeing it first** — watch the [demo](/demo).
**Deciding** — check [supported countries](/supported-countries) and [fees](/pricing).
:::
## Why Yodl
QR payments are messy and hard to get right. Yodl has spent a year's worth of work integrating QR providers. They all come with endless quirks, failures, and constant fixes.
Payment UX looks simple on the surface. But every single payment is like Demo Day: countless things can go wrong, leaving users unable to pay. Traumatized. Churn.
Yodl SDK gives you best-in-class QR payments with a 15-minute integration:
* **UI/UX included** — the SDK ships with a complete payment flow out of the box, guaranteeing consistent and fast payments.
* **Processor aggregation** — we aggregate multiple processors, so you get the best rates, reliability, and feature set.
* **Wallet-agnostic** — any EIP-1193 provider works: Privy, MetaMask, Coinbase Wallet, Rainbow, WalletConnect.
* **Self-custody** — no deposits, no custodian. Funds leave the wallet at the moment of payment.
## The devil is in the detail
Many "stablecoin orchestrators" claim to support a country's QR codes. In practice:
* **QR payments are slow and not optimized for paying in a shop.** Churn.
* **Not every QR code "flavor" is supported.** Users can't tell the difference, and 5% of QRs won't work. Churn.
* **Failure and refund flows are an afterthought.** Payment failures go unhandled. Churn.
* **Nobody tests on slow mobile networks.** Travel eSIM, slow SSL handshakes, servers far away. The loader spins 15 seconds just to resolve a QR. Churn.
For most, QR code payments are just a ✅ on a feature table. For Yodl Pay QR codes are priority #1.
## What you actually integrate
A provider and a component. The [React Native SDK](/sdk/react-native) ships the whole payment flow — localized per country, with no backend of yours involved.
If you want payment records on your own backend, that is a separate, optional layer: see [backend integration](/integrations/overview). The payment flow works without it.
# Supported countries
Three tiers, and the difference matters for planning: **live** works today with no action from you, **on request** needs a conversation before your users can pay, and **coming soon** has no date attached.
## Live
Available now. Nothing to enable — the SDK routes to these automatically.
| Country | Payment methods | Processors |
| --- | --- | --- |
| Brazil | PIX | 2 live |
| Argentina | Transferencias, Mercado Pago | 2 live |
| Vietnam | VietQR, MoMo, VNPAY | 2 live |
| Philippines | GCash, Maya (merchant), QR Ph | 1 live |
More than one processor per country is deliberate — it is what lets us re-quote each payment and route to the cheapest all-in fee. See [fees & pricing](/pricing).
:::note
These four are live **without KYC**. KYC unlocks more countries, better rates and higher limits.
:::
## On request
Supported, but enabled per integration rather than by default.
* Peru
* Colombia
* Bolivia
* Nigeria (PalmPay)
:::tip
If your users are in one of these, raise it during [onboarding](/integrations/onboarding) — enabling a country is an operator change on our side, not development work on yours.
:::
## Coming soon
On the roadmap, without committed dates. Worth telling us if one of these is material to you, since demand affects ordering.
* Hong Kong
* Taiwan
* Japan
* Korea
* Singapore
* Cambodia
* Nepal
* India
* Macau
:::warning
Don't ship a country from this list to users before we confirm it is live. `isPaymentQr` returns `false` for a QR we can't route, so the payment screen simply won't open — see [troubleshooting](/sdk/react-native/troubleshooting).
:::
# Fees & pricing
Yodl aggregates multiple processors per country and routes every payment to the one with the best **all-in fee**. There is no static fee table: you get the best available quote at the moment of payment.
## The all-in fee
The all-in fee is what a payment actually costs, made up of:
| Fee | Charged by | Details |
| --- | --- | --- |
| Processor fee | Processor | Percentage fee per payment |
| Minimum / fixed service fee | Processor | Fee floor and/or flat fee, varies by processor |
| FX quote | Processor | Exchange rate applied to convert into local currency |
| Protocol fee | Yodl | 0.10%, currently waived |
| Convenience fee | Wallet | Customizable by the wallet (30% cut to Yodl) |
| Gas fee | Network | Bundled into the payment on L1, sponsored by Yodl on L2 |
Yodl compares the all-in fee across processors for every single payment and picks the cheapest.
:::note
Two of those rows are yours to think about rather than ours. The **convenience fee** is your revenue lever — you set it, and we take 30% of it. The **protocol fee** is 0.10% and currently waived, so don't model it as zero indefinitely.
:::
## Example: Vietnam
### In an ideal world
| | Processor fee | Minimum fee | FX quote |
| ----------- | ------------- | ----------- | --------------- |
| Processor 1 | 0.80% | $0.15 | $1 = 18,000 VND |
| Processor 2 | 0.90% | none | $1 = 18,000 VND |
With identical FX quotes, a $20 payment is cheaper on Processor 1 ($0.16 vs $0.18). On a $10 payment the minimum fee kicks in and Processor 2 wins ($0.09 vs $0.15).
### In reality
FX quotes are never identical. They vary a lot, hour by hour, and the swings routinely dwarf the static fees.
| | Processor fee | Minimum fee | FX quote |
| ----------- | ------------- | ----------- | --------------- |
| Processor 1 | 0.80% | $0.15 | $1 = 17,900 VND |
| Processor 2 | 0.90% | none | $1 = 18,050 VND |
For a 1,800,000 VND payment, Processor 1 costs $101.36 all-in, Processor 2 costs $100.62. The better FX quote beats the lower percentage fee. An hour later, the quotes may have flipped.
That is why Yodl re-quotes every payment on the all-in fee instead of publishing a static fee table.
:::tip
The practical consequence: **don't hardcode a fee in your own UI.** Quote it from the payment flow at the moment of payment, or state a range. Any number you publish will be wrong within the hour.
:::
## Better rates with KYC
Non-KYC payments are live in Brazil, Vietnam, Philippines, and Argentina — see [supported countries](/supported-countries). KYC unlocks more countries, better rates, and higher limits.
# Demo
See the Yodl SDK payment flow in action — scan, confirm, paid. This is the flow that ships with the SDK; you render one component.
[Watch the Yodl SDK payment flow demo (MP4 video)](https://kuezuu6pdfndtxdd.public.blob.vercel-storage.com/docs/sdk-demo-h264.mp4)
Ready to build it? The [React Native quickstart](/sdk/react-native/quickstart) gets you to a completed payment.
# AI assistants
These docs are built to be read by machines as well as people. If you are integrating the SDK with an AI coding assistant, point it here rather than pasting snippets — everything below is available without an API key or an account.
## On every page
The **Ask AI** button at the bottom of each page opens the current page in ChatGPT or Claude, copies it as Markdown, links the raw Markdown, and — when you want the whole set — gives you the MCP server URL.
## Markdown routes
Append `.md` to any page URL to get its source rather than the rendered HTML:
```txt
https://docs.yodl.me/sdk/react-native/quickstart.md
```
Pages are also content-negotiated: a request without a browser `User-Agent` receives Markdown from the normal URL. So this returns Markdown, while your browser gets the rendered page:
```bash
curl https://docs.yodl.me/sdk/react-native
```
## llms.txt
Two files, both generated on every build:
| File | What it is |
| --- | --- |
| /llms.txt | An index — every page with its title, path and one-line description. ~4 KB |
| /llms-full.txt | The entire documentation concatenated into one file. ~80 KB |
Use the index to let a model choose what to read; use the full file when you want the whole corpus in context at once.
## MCP server
The docs are also served over the [Model Context Protocol](https://modelcontextprotocol.io), so an assistant can search and read them on demand instead of holding everything in context.
```txt
https://docs.yodl.me/api/mcp
```
```bash [Claude Code]
claude mcp add --transport http yodl-docs https://docs.yodl.me/api/mcp
```
```json [Client config]
{
"mcpServers": {
"yodl-docs": {
"type": "http",
"url": "https://docs.yodl.me/api/mcp"
}
}
}
```
Three tools are exposed:
| Tool | What it does |
| --- | --- |
| `list_pages` | Lists every documentation page with its title and path |
| `read_page` | Reads one page, by `pagePath` |
| `search_docs` | Searches the docs for a query string |
### What it can and cannot reach
:::note
This server exposes **documentation only**. It is read-only, needs no credentials, and can reach no account, wallet, payment or key material. There is nothing to scope and no token to leak.
:::
That is worth stating plainly, because many vendor MCP servers do the opposite — they connect an assistant to live project data, which is why their docs open with token-scoping and "don't point this at production" warnings. This one is closer to a search index over public pages.
It also does not serve the SDK's source code. The packages are published on npm ([`@yodlpay/react-native`](https://www.npmjs.com/package/@yodlpay/react-native), [`@yodlpay/react-native-eip-7702-provider`](https://www.npmjs.com/package/@yodlpay/react-native-eip-7702-provider)) and their types travel with them, which is the better source for an assistant writing integration code.
## One caveat
An assistant that has read these docs still guesses. The SDK's config example is type-checked against the real package on every build, so what you see compiles — but a generated integration is not. Check anything an assistant writes against the [API reference](/sdk/react-native/api), and let the compiler decide.
# React Native SDK
[`@yodlpay/react-native`](https://www.npmjs.com/package/@yodlpay/react-native) adds QR payments to a React Native wallet app. The complete payment flow ships with the SDK — localized per country, with no backend of yours involved. Payments work with any token on any supported chain, so users don't have to hold a particular balance.
Three drop-in components, all rendered inside a provider:
| Component | What it does |
| --- | --- |
| `` | The payment flow for a scanned QR code |
| `` | Hosted onboarding — SIWE, email capture, verification |
| `` | The Yodl account view |
## What a payment looks like
Your app scans and renders; the hosted UI resolves and quotes; the wallet signs. You never touch RPC or payment plumbing.
```mermaid
sequenceDiagram
autonumber
actor U as User
participant App as Your app
participant Y as Yodl UI
participant W as Wallet
U->>App: Scans a merchant QR
App->>App: isPaymentQr(qrData)
App->>Y: Renders YodlPayment
Y->>Y: Resolves across processors, quotes
Y->>W: Signature or wallet_sendCalls
W-->>Y: Signed
Y-->>App: onTransactionSent(txHash)
Y-->>App: onPaymentDetails(status)
```
The dashed returns are your callbacks — the SDK bridges messages from the UI, it does not fetch payment state itself.
## Requirements
| Requirement | Version |
| --- | --- |
| React | `>= 18` |
| React Native | `>= 0.72` |
| Hermes engine | Enabled (default) |
:::warning
**Not Expo Go.** The SDK uses the native `react-native-webview` module, so it needs a [development build](https://docs.expo.dev/develop/development-builds/introduction/) (`npx expo run:ios` / `run:android`) or bare React Native. Expo Go will crash or render a blank screen.
:::
Hermes is required because the SDK uses `BigInt`. It is on by default from React Native `0.70`. If you disabled it, re-enable it or add a `BigInt` polyfill.
## Install
Install the SDK and its `react-native-webview` peer dependency, then rebuild the native app.
:::code-group
```bash [Expo]
npx expo install @yodlpay/react-native react-native-webview
npx expo run:ios # or: npx expo run:android
```
```bash [Bare React Native]
npm install @yodlpay/react-native react-native-webview
npx pod-install # iOS
```
:::
Expo's installer resolves the WebView version against your Expo SDK, which is why it is preferred over a plain `npm install` there. After installing, rebuild the app — that is the whole install.
## Where to go next
* **[Quickstart](/sdk/react-native/quickstart)** — from install to a completed payment.
* **[Signup flow](/sdk/react-native/signup)** — onboard users who don't have a Yodl account yet.
* **[Theming](/sdk/react-native/theming)** — apply your palette to the hosted UI.
* **[API reference](/sdk/react-native/api)** — every export, prop and callback.
* **[Security model](/sdk/react-native/security)** — origin locking and what the bridge does and doesn't do.
* **[Troubleshooting](/sdk/react-native/troubleshooting)** — symptoms and fixes.
Wallets that can't batch calls natively may also want the [EIP-7702 provider](/sdk/eip-7702-provider) — read that page before installing it, since most integrations don't need it.
# Quickstart
By the end of this page a user can scan a merchant QR code in your app and complete a payment.
**Before you start:** finish the [install](/sdk/react-native#install) and have a wallet SDK in place that gives you an EIP-1193 provider, the connected address, and the current chain id — Privy, MetaMask, Coinbase Wallet, Rainbow and WalletConnect all qualify.
:::steps
##### Create the client once
`createSdk` is called once for the whole app, not per screen. Put it in its own module and import the instance where you need it.
```ts twoslash [sdk.ts]
import { createSdk } from '@yodlpay/react-native';
export const sdk = createSdk({
// Your registered integration address — this attributes payments to you.
integrationAddress: '0x000000000000000000000000000000000000dEaD',
// Chains the wallet is allowed to switch to.
supportedChainIds: [1, 8453],
});
```
`integrationAddress` must be a non-empty `0x` address. If you don't have one yet, see [the integration address](/integrations/integration-address).
##### Guard the scanned QR
`isPaymentQr` is a standalone helper — it needs no SDK instance, so you can call it before mounting anything.
```tsx
import { isPaymentQr } from '@yodlpay/react-native';
function handleScan(scanned: string) {
if (!isPaymentQr(scanned)) return; // not a Yodl QR — keep scanning
setQrData(scanned);
}
```
Skipping this check is the most common cause of a blank payment screen: `` renders nothing when `qrData` isn't a supported payment QR.
##### Render the payment flow
Wrap the screen in `` and render `` inside it.
```tsx [PaymentScreen.tsx]
import { YodlPayment, YodlProvider } from '@yodlpay/react-native';
import { sdk } from './sdk';
import { useWallet } from './wallet';
export function PaymentScreen({ qrData }: { qrData: string }) {
const { provider, address, chainId } = useWallet();
return (
console.log('submitted:', txHash)}
onError={(err) => console.error(err)}
/>
);
}
```
:::
That's a working payment.
:::warning
`address` and `chainId` must be the wallet's **live** values, passed straight from your wallet SDK's hooks — not fixed constants. The SDK pushes `accountsChanged` and `chainChanged` into the hosted UI as they change, so a stale `chainId` leaves the UI on the wrong chain. The `provider` is used only as the signing and RPC transport.
:::
## A fuller example
Scan → pay → back, plus the dashboard and payment lifecycle events.
```tsx [PaymentScreen.tsx]
import { useState } from 'react';
import { Button, Text, View } from 'react-native';
import { isPaymentQr, YodlDashboard, YodlPayment, YodlProvider } from '@yodlpay/react-native';
import { sdk } from './sdk';
import { useWallet } from './wallet';
import { QrScanner } from './QrScanner'; // your camera component, e.g. expo-camera
type Screen = 'scan' | 'pay' | 'dashboard';
export function PaymentScreen() {
const { provider, address, chainId, isConnected } = useWallet();
const [screen, setScreen] = useState('scan');
const [qrData, setQrData] = useState(null);
if (!isConnected) return Connect a wallet to continue.;
function handleScan(scanned: string) {
if (!isPaymentQr(scanned)) return;
setQrData(scanned);
setScreen('pay');
}
return (
{screen === 'scan' && (
)}
{screen === 'pay' && qrData && (
console.log('submitted:', txHash)}
onPaymentDetails={(details) => {
// 'submitted' | 'processing' | 'success' | 'failure'
if (details.status === 'success') setScreen('scan');
}}
onError={(err) => {
console.error(err);
setScreen('scan');
}}
/>
)}
{screen === 'dashboard' && }
);
}
```
## Next
* Users without a Yodl account can register up front with the [signup flow](/sdk/react-native/signup), or inside the payment itself — `` asks a wallet with no account to register before it pays. Either way, [email attestation](/integrations/email-attestation) lets that registration skip Yodl's OTP.
* To match your brand, see [theming](/sdk/react-native/theming).
* Every prop and callback is in the [API reference](/sdk/react-native/api).
# Signup flow
Render `` when a user needs a Yodl account. The hosted UI owns the whole flow: the status check, SIWE, email capture, verification code entry, and the final signed-up screen. You render one component and wait for the callback.
```tsx [SignupScreen.tsx]
import { useState } from 'react';
import { Text } from 'react-native';
import { YodlProvider, YodlSignup } from '@yodlpay/react-native';
import { sdk } from './sdk';
import { useWallet } from './wallet';
export function SignupScreen() {
const { provider, address, chainId, isConnected } = useWallet();
const [showSignup, setShowSignup] = useState(true);
if (!isConnected) return Connect a wallet first.;
if (!showSignup) return null;
return (
console.log('signup status:', status)}
onSignedUp={() => {
// Store completion however your app tracks user state.
setShowSignup(false);
}}
onDismiss={() => setShowSignup(false)}
onError={console.error}
/>
);
}
```
`` is a full-screen component and must be rendered inside ``.
## Tracking completion yourself
:::warning
**The SDK exposes no account details from the hosted flow.** `onSignedUp` tells you the user finished — nothing more. If you need to remember that, persist it in your own app state, storage or backend. There is no API to query it back from the SDK.
:::
## Status values
`onStatusChange` reports the hosted flow's progress:
| Status | Meaning |
| --- | --- |
| `checking` | Determining whether the wallet already has an account |
| `signed_out` | No account — the flow will start signup |
| `signing_up` | The user is partway through |
| `signed_up` | Complete |
`onDismiss` fires when the user closes the success screen, which is separate from `onSignedUp` — a user can complete signup and leave the screen up.
## Skipping email verification
If your app has already verified the user's email address, you can assert that with a short-lived JWT your backend signs, and the user skips Yodl's own OTP step.
Pass `fetchEmailAttestation`. The hosted flow calls it at the moment it creates the account — after the wallet signature, once it knows which wallet to bind:
```tsx [SignupScreen.tsx]
{
// Your backend authenticates the user itself and signs for the email it
// has already verified. Never sign on the device.
const response = await fetch('https://api.yourwallet.com/yodl/email-attestation', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session}` },
body: JSON.stringify({ walletAddress: address, email }),
signal,
});
if (!response.ok) return null;
const { token } = await response.json();
return { token };
}}
onSignedUp={() => setShowSignup(false)}
/>
```
Return `null` and the user verifies by code as usual — a normal outcome, not an error, and it never reaches `onError`. The same is true of a throw or a timeout. Omitting the prop entirely leaves the flow exactly as it is today.
`` and `` take the same prop on the same terms. A wallet with no Yodl account is asked to register inside those flows too, so an app with no separate signup screen still gets the OTP skip — see [the API reference](/sdk/react-native/api#fetchemailattestation).
:::warning
**Mint on demand, never cache.** The token is single use and valid for at most 5 minutes. Yodl calls your function fresh for every attempt precisely so each one gets an unspent token — returning a stored one fails, and burns it for good.
:::
The claim set your backend must produce, the JWKS URL you register, and the verification order are in [email attestation](/integrations/email-attestation). It is granted per integration and off by default, so **until we enable it for you a valid token changes nothing and the user still gets the OTP**.
Full prop tables are in the [API reference](/sdk/react-native/api#yodlsignup).
# Theming
Theming is **opt-in**. Set `theme`, `themeMode`, or both on `` — or on any hosted component — to style the Yodl UI. Omit them and the hosted UI keeps its own appearance. Every field is optional.
```tsx
import type { YodlTheme } from '@yodlpay/react-native';
import { YodlPayment, YodlProvider } from '@yodlpay/react-native';
const brandTheme: YodlTheme = {
name: 'acme',
light: { bg: '#ffffff', surface: '#f4f4f5', text: '#18181b', accent: '#6d4fc0' },
dark: { bg: '#18181b', surface: '#27272a', text: '#fafafa', accent: '#9662ff' },
images: {
light: { loading: 'https://cdn.example.com/yodl-loading-light.mp4' },
dark: { loading: 'https://cdn.example.com/yodl-loading-dark.mp4' },
},
};
;
```
## Modes
`themeMode` is `'system'` (follow the device), `'light'`, or `'dark'`. The two props combine as:
| `theme` | `themeMode` | Result |
| --- | --- | --- |
| Omitted | Omitted | Hosted UI keeps its own appearance |
| Set | Omitted | Your palette, following the device |
| Omitted | Set | Stock Yodl palette, pinned to that mode |
| Set | Set | Your palette, pinned to that mode |
## Defaults and overrides
`theme` and `themeMode` on `` are defaults for every hosted component beneath it. The same props on ``, `` and `` override the provider **per prop** — set one and the other still inherits.
```tsx
// Provider follows the device; this one screen pins dark but keeps the palette.
;
```
Remove both props from the component *and* its provider to hand appearance control back to the hosted UI.
## Tokens
`name` is an optional label, 64 characters or fewer. Colours go per mode under `light` and `dark`; omitted tokens keep their stock value.
* **Core** — `bg` `surface` `surfaceSecondary` `text` `textSecondary` `textMuted` `border` `success` `error` `warning` `accent`
* **Fine-tune**, derived from core when omitted — `bgDeep` `surfaceHover` `skeleton` `accentStrong` `accentDeep`
* **Brand** — `brandSurface` `brandSheet` `brandText` `brandButton` `brandButtonText`
:::tip
[The theme builder](/sdk/react-native/theme-builder) generates a link that opens the hosted UI with your palette applied, so you can see all of this without rebuilding an app.
:::
## Brand surfaces
These behave differently from the rest of the palette, in two ways worth knowing before you set them.
| Token | What it styles |
| --- | --- |
| `brandSurface` | The **Pay shell** — the payment screen itself |
| `brandSheet` | Sheets and onboarding |
| `brandText` | Text on both of the above |
| `brandButton` | The pay button on the brand shell |
| `brandButtonText` | Text on that button |
**They are mode-invariant.** A brand value set in only one mode mirrors into the other, so you do not need to repeat them under both `light` and `dark`. Setting them differently per mode is legal, but the default is one shared appearance.
**The last three derive themselves.** As soon as you set a custom `brandSurface` (or `brandSheet`), the runtime fills in whatever you left out, in a chain:
* `brandText` → white or near-black, whichever contrasts better against the brand surface
* `brandButton` → the same contrast colour
* `brandButtonText` → whichever contrasts better against **the button**, not the surface
So a single `brandSurface` gives you a readable shell for free, and a *light* brand surface correctly gets near-black text rather than the stock white.
:::warning
That is a reason to leave them unset. Overriding `brandText` by hand can only lower the contrast the runtime would have chosen — it does not re-derive around your value. Set the surface, check it, and only override if you genuinely need a specific colour.
:::
## Images
Image slots go under `images.light` and `images.dark`, each supporting `payBackground`, `loading` and `processing`. `processing` is only used when `loading` is unset. Media travels by reference — you host the asset, the theme carries the URL.
An `.mp4` URL plays as a looping muted video. **Everything else renders as an ``** — that is the only branch.
:::danger
So `.webm` does not work, and fails quietly: it is not detected as video, falls through to ``, and shows as a broken image with no error. The same is true of any URL that is not actually an image — a `.pdf` or a plain page URL passes validation and then renders broken, because the schema checks the URL's shape rather than what it points at.
:::
## Validation
* Colours must be `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb(…)` or `rgba(…)`.
* Image URLs must be `https://`, at most 2048 characters, with no whitespace, quotes or backslashes.
:::tip
An invalid `theme` or `themeMode` **never crashes**. It falls back to stock Yodl and logs the error when `__DEV__` is true — so check your dev console if a palette silently doesn't apply.
:::
## Live updates
Changing `theme` or `themeMode` on a mounted component applies over the bridge **without reloading**, so the active flow keeps its state.
:::warning
`yodlUiBaseUrl` must point at a current hosted UI build that supports correlated readiness. Against an older build the WebView never reveals and the loading spinner stays up forever. See [troubleshooting](/sdk/react-native/troubleshooting).
:::
# Theme builder
Start from a preset, adjust what you care about, open the link. Nothing to install and no app build — the webview accepts a theme straight from the URL.
Already have a theme? Paste it into **Import an existing theme** below to keep editing it — a JSON payload, a base64url string, or a whole `?theme=` link all work.
This page hosts an interactive theme builder. Pick colours for the light and dark modes and it produces a link that opens the hosted Yodl UI with that palette applied, of the form:
```txt
https://sdk-webview.yodl.me/?theme=
```
The same JSON can be passed to YodlProvider as the theme prop. Use ?theme=default to clear a stored theme.
## How the link works
The hosted UI reads `?theme=` on load, validates it against the theme schema, applies it, persists it, and strips the parameter from the URL so it can't leak into navigation:
```txt
https://sdk-webview.yodl.me/?theme=
```
The payload is the same `YodlTheme` object you would pass to `` — so once a palette looks right here, [copy the JSON](/sdk/react-native/theming) into your app and it renders identically.
Raw URI-encoded JSON works too, which is handy for hand-editing:
```txt
?theme=%7B%22dark%22%3A%7B%22accent%22%3A%22%232dd4bf%22%7D%7D
```
:::tip
`?theme=default` (or `?theme=reset`) clears a stored theme and returns the UI to stock Yodl. Worth keeping to hand — a theme persists across reloads once applied, so this is how you get back.
:::
## What this covers
| Section | Tokens |
| --- | --- |
| Core colours, per mode | `bg` `surface` `surfaceSecondary` `text` `textSecondary` `textMuted` `border` `accent` `success` `error` `warning` |
| Brand surfaces | `brandSurface` `brandSheet` `brandText` `brandButton` `brandButtonText` |
| Images, per mode | `payBackground` `loading` `processing` |
The **fine-tune** tokens — `bgDeep`, `surfaceHover`, `skeleton`, `accentStrong`, `accentDeep` — are deliberately not exposed. They derive from the core colours when omitted, which is what makes a short palette come out coherent. Set them by hand in the JSON if you need to.
### Brand surfaces
These carry the most visible parts of the flow, so they are worth setting:
| Token | What it styles | Stock |
| --- | --- | --- |
| `brandSurface` | The **Pay shell** — the payment screen itself | `#5b21b6` |
| `brandSheet` | Sheets and onboarding | `#1c1530` |
They are **mode-invariant**: a value set in one mode mirrors into the other, so the builder offers one shared pair rather than two.
The other three — `brandText`, `brandButton`, `brandButtonText` — **derive from the Pay shell's luminance at runtime**, picking whichever of white or near-black contrasts better against it. That is why the builder leaves them off by default and shows you the derived value: a hand-picked colour can only lower the contrast the runtime would have chosen for you. Tick one to override it anyway.
### Images
Media travels **by reference**, so the theme JSON stays small: you host the asset, the theme carries the URL. Each mode has three slots, and `processing` is only consulted when `loading` is empty.
A `.mp4` URL renders as an autoplaying, muted, looping video. **Everything else renders as an ``** — that is the only branch, so the file has to genuinely be an image or an mp4. The builder tells you which one it detected before you generate the link.
:::danger
**`.webm` does not work**, and fails quietly. Only `.mp4` is detected as video, so a WebM URL falls through to `` and renders as a broken image with no error. The builder rejects it, along with `.mov`, `.m4v` and friends. If you need alpha-channel video, raise it with us rather than working around it.
:::
:::warning
URLs must be `https://`, at most 2048 characters, and free of whitespace, quotes and backslashes — that last part is not fussiness, it is what stops a URL escaping the CSS `url("…")` or `` it gets emitted into.
Note the schema itself only checks the URL *shape*. A `.pdf` or a plain page URL passes validation and then renders as a broken image, which is why the builder checks the extension too.
:::
:::warning
An invalid theme never breaks the UI — it falls back to stock Yodl and logs the reason in development. So if a palette silently doesn't apply, the payload failed validation rather than the link failing to arrive.
:::
## Contrast
The builder flags text-on-background below the WCAG AA threshold of 4.5:1, and a low-contrast accent. It checks the pairs that matter most, not every combination — treat a clean result as "no obvious problem" rather than an audit.
# API reference
## `createSdk(config)`
Returns a `Yodl` instance. Call it once for the whole app.
| Option | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `integrationAddress` | `string` | Yes | — | Your integration wallet address. Identifies you to Yodl and attributes payments on chain |
| `supportedChainIds` | `number[]` | Yes | — | Chain IDs the wallet can switch to |
| `yodlUiBaseUrl` | `string` | No | `https://sdk-webview.yodl.me` | Base URL for the hosted Yodl UI |
`integrationAddress` must be a non-empty `0x` address. The SDK sends it alongside package metadata on Yodl-owned requests:
```txt
X-Yodl-Integration-Id: 0x000000000000000000000000000000000000dEaD
X-Yodl-SDK-Name: @yodlpay/react-native
X-Yodl-SDK-Version:
```
:::note
Don't confuse this with the `X-Yodl-Integration` header on [payment notifications](/integrations/notifications). That one travels the other way — Yodl signing a webhook to you. This one is your app identifying itself to Yodl.
:::
Instance members:
* **`sdk.config`** — the resolved config, read-only.
## `isPaymentQr(qrData)`
`string | string[]` → `boolean`. Returns `true` if any entry is a Yodl-supported payment QR.
Pure and standalone: it takes no SDK instance, so you can guard a render before `` is mounted.
```tsx
import { isPaymentQr } from '@yodlpay/react-native';
if (!isPaymentQr(scannedQr)) return null;
```
## ``
Supplies the SDK instance and wallet state to the components. Render it once, high in your tree.
| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `sdk` | `Yodl` | Yes | Instance from `createSdk()` |
| `provider` | `EIP1193Provider` | Yes | EIP-1193 provider from any wallet SDK. Prefer the wallet's native EIP-5792 support; only wrap with `withYodl7702(...)` if it lacks it — see [EIP-7702 provider](/sdk/eip-7702-provider) |
| `address` | `string` | Yes | Connected wallet address |
| `chainId` | `number` | Yes | Chain ID the wallet is currently on |
| `theme` | `YodlTheme` | No | Default hosted-UI palette; all fields optional |
| `themeMode` | `'system' \| 'light' \| 'dark'` | No | Default host-controlled mode |
`address` and `chainId` are the wallet's **live** account and chain — pass them straight from your wallet SDK's hooks (`useWallets()`, `useAccount()`, …), not as fixed values. They tell the hosted UI who is connected and on which chain, and the SDK pushes `accountsChanged` / `chainChanged` into the UI whenever they change. The `provider` is used only as the RPC and signing transport.
## Shared visual props
``, `` and `` all accept these, in addition to their own props below.
| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `theme` | `YodlTheme` | No | Palette override; inherits the provider `theme` when omitted |
| `themeMode` | `'system' \| 'light' \| 'dark'` | No | Mode override; inherits the provider `themeMode` when omitted |
| `backgroundColor` | `string` | No | Colour behind the WebView before first paint; overrides the theme background |
| `style` | `ViewStyle` | No | Container style |
See [theming](/sdk/react-native/theming) for how overrides resolve.
## ``
Renders the hosted signup flow as a full-screen component inside ``. Also accepts the [shared visual props](#shared-visual-props).
| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `onStatusChange` | `(status: 'checking' \| 'signed_out' \| 'signing_up' \| 'signed_up') => void` | No | Called when the hosted flow reports signup status |
| `onSignedUp` | `() => void` | No | Called when signup completes |
| `onDismiss` | `() => void` | No | Called when the hosted success screen is dismissed |
| `onError` | `(error: Error) => void` | No | Called on SIWE, registration, verification or RPC errors |
| `fetchEmailAttestation` | `(request: YodlEmailAttestationRequest) => Promise` | No | Supplies an [email attestation](/integrations/email-attestation) so the user skips Yodl's OTP |
### `fetchEmailAttestation`
Called once per registration attempt, at the moment the hosted flow is about to create the account — after the wallet signature, before the email is submitted. Return `null` to let the user verify by code as usual.
```ts
interface YodlEmailAttestationRequest {
// The wallet the hosted flow proved with SIWE. Mint `sub` for exactly this.
address: `0x${string}`;
// The email the user entered, when it has been collected.
email?: string;
// Aborted on timeout, unmount, or session change. Forward it into your fetch.
signal: AbortSignal;
}
interface YodlEmailAttestation {
token: string; // the compact JWS your backend signed
email?: string; // the token's `email` claim; defaults to `request.email`
}
```
| Behaviour | Result |
| --- | --- |
| Resolves an attestation | Sent to Yodl; the OTP is skipped if it verifies |
| Resolves `null` | Normal OTP flow. **Not** reported through `onError` |
| Throws | Normal OTP flow |
| Takes longer than 30 seconds | Aborted via `signal`, normal OTP flow |
| Prop omitted | Normal OTP flow, with no added latency |
:::warning
Mint a fresh token on every call. The token is single use and lives at most 5 minutes, so a cached one fails — and once its `jti` is spent, replaying it fails permanently rather than falling back cleanly on the next attempt.
:::
Yodl calls this at most 3 times per hosted session, and only when a flow actually registers an account. `` and `` take the prop as well: a wallet with no Yodl account is asked to register inside the payment flow, and that registration skips the OTP on the same terms. Omit the prop on a surface and a registration there falls back to the code flow.
The 30-second budget is the same one the SDK gives its own network calls. It is deliberately generous: a mint that is cut short costs the user the wait *and* the OTP, and because attestation failures are silent it would not show up as an error anywhere. Signing itself takes milliseconds, so the budget is really covering the round trip to your backend on a bad mobile connection.
## ``
Renders the payment flow for a scanned QR code. Renders nothing if `qrData` is empty or unsupported. Also accepts the [shared visual props](#shared-visual-props).
| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `qrData` | `string` | Yes | Raw scanned QR data string |
| `onTransactionSent` | `(txHash: string) => void` | No | Called after a transaction is sent |
| `onPaymentDetails` | `(details: YodlPaymentDetails) => void` | No | Called with payment lifecycle details from the UI |
| `onError` | `(error: Error) => void` | No | Called on RPC or payment errors |
| `fetchEmailAttestation` | `(request: YodlEmailAttestationRequest) => Promise` | No | Supplies an [email attestation](#fetchemailattestation) if the flow asks a wallet without a Yodl account to register |
## ``
Renders the full Yodl account dashboard. Also accepts the [shared visual props](#shared-visual-props).
| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `onTransactionSent` | `(txHash: string) => void` | No | Called after a transaction is sent |
| `onPaymentDetails` | `(details: YodlPaymentDetails) => void` | No | Called with payment lifecycle details from the UI |
| `onError` | `(error: Error) => void` | No | Called on RPC errors |
| `fetchEmailAttestation` | `(request: YodlEmailAttestationRequest) => Promise` | No | Supplies an [email attestation](#fetchemailattestation) if the flow asks a wallet without a Yodl account to register |
## Payment lifecycle events
`onPaymentDetails` is optional and additive — it does not change `onTransactionSent` or `onError` behaviour.
The hosted UI emits generalized payment lifecycle details once it has payment status, and the SDK forwards them. Normalized fields cover transaction hash, chain, lifecycle status, recipient, invoice, token amounts, refund state and rewards where available. The original payload may be included under `raw`.
Status moves through `'submitted'` → `'processing'` → `'success' | 'failure'`.
:::note
The SDK never fetches transaction details itself — it only bridges messages from the UI. If you need authoritative payment records on your own backend, use [payment notifications](/integrations/notifications) or the [Payments API](/integrations/payments-api).
:::
# Security model
The hosted components inject an EIP-1193 provider bound to the user's wallet into a WebView. Two properties matter for review.
## The UI is locked to the Yodl origin
``, `` and `` lock the WebView to the configured Yodl origin, and off-origin navigation is blocked via `onShouldStartLoadWithRequest`.
This prevents origin spoofing and mid-request navigation that could otherwise intercept a signed response. The injected provider also enforces a 30-second pending-request timeout.
No configuration is required — this is the default and cannot be disabled.
## The bridge owns no keys and no accounts
The bridge forwards EIP-5792 methods — `wallet_sendCalls`, `wallet_getCallsStatus`, `wallet_showCallsStatus`, `wallet_getCapabilities` — and all signing methods straight through to the wallet provider you supplied.
It holds **no smart-account stack** and makes **no RPC calls of its own**. The embedded UI reads chain state through its own provider. Every signature and transaction is authorised by the user's wallet, exactly as if your app had made the request.
For wallets without native EIP-5792 support, atomic batching is available via the [EIP-7702 provider](/sdk/eip-7702-provider) — an opt-in wrapper around the same provider, which likewise never holds keys.
## What this means for your review
* Signing authority stays with the user's wallet at all times.
* The SDK's network surface is the Yodl origin plus whatever your wallet provider talks to.
* Your `integrationAddress` is sent on Yodl-owned requests as an identifier. It is **not** an authentication secret — see the caveat on [the integration address](/integrations/integration-address#a-declared-address-is-not-an-authenticated-one).
# Troubleshooting
| Symptom | Cause and fix |
| --- | --- |
| `Can't find variable: BigInt` | Hermes is disabled. Re-enable it or add a `BigInt` polyfill — see [requirements](/sdk/react-native#requirements). |
| App crashes or shows a blank screen in Expo Go | Native modules aren't available in Expo Go. Use a [development build](https://docs.expo.dev/develop/development-builds/introduction/). |
| `Yodl components must be rendered inside ` | A component is mounted outside the provider. Wrap it in ``. |
| Payment screen renders blank | `qrData` isn't a Yodl payment QR. Guard with `isPaymentQr(qrData)` before rendering ``. |
| `webview-action providers are not supported yet` | The scanned QR maps to a provider whose hosted webview flow isn't supported yet. Catch it via `onError` or an error boundary and prompt a retry. |
| Loading overlay never lifts | The hosted UI at `yodlUiBaseUrl` doesn't support correlated readiness. Point it at a current hosted build. |
| Theme silently doesn't apply | An invalid `theme` or `themeMode` falls back to stock Yodl rather than crashing, and logs the reason when `__DEV__` is true. Check the dev console. |
## The two blank-screen cases
They look identical and have different causes, so check in this order:
1. **Running in Expo Go** — see [requirements](/sdk/react-native#requirements). No JS change fixes this.
2. **A valid dev build** — then `qrData` isn't a supported payment QR. `` deliberately renders nothing rather than erroring, which is why the guard in the [quickstart](/sdk/react-native/quickstart) matters.
Still stuck? Payment-side issues that aren't in this table are usually visible in `onPaymentDetails` — log the whole object, including `raw`, before filing anything.
# EIP-7702 provider
[`@yodlpay/react-native-eip-7702-provider`](https://www.npmjs.com/package/@yodlpay/react-native-eip-7702-provider) adds atomic batching and gas sponsorship to wallets that can't do it themselves.
## Do you need this?
Probably not. **Native wallet support always wins** — if the wallet handles the EIP-5792 `wallet_sendCalls` flow, `@yodlpay/react-native` forwards those calls straight through and this package does nothing for you.
Add it only when all three are true:
1. The wallet exposes an EIP-1193 provider, **and**
2. it can sign EIP-7702 authorizations (`wallet_signAuthorization` or compatible secp256k1 signing), **but**
3. it does **not** natively support the EIP-5792 `wallet_sendCalls` flow your integration needs.
:::warning
Adding it is a real decision, not a free upgrade. It pulls in a smart-account stack, needs a native rebuild, and routes submission through a bundler. If you don't need batching, skipping it is strictly less work.
:::
Wiring it up is not the hard part. Against Yodl's hosted UI the whole configuration is one callback — `signAuthorization`, because [no wallet signs EIP-7702 authorizations the same way](/sdk/eip-7702-provider/setup#authorization-signing). The bundler, the chain list, the gas pricing and the chain reads all have defaults. Pass `chains` explicitly and none of your traffic touches Yodl; see [using Yodl's bundler](/sdk/eip-7702-provider/setup#using-yodls-bundler) for exactly what the default delegates.
Without it, a wallet lacking native EIP-5792 simply can't batch. The base SDK forwards the request and returns the wallet's own response; it performs no sequential fallback of its own.
## How the three standards relate
Easy to conflate, so worth stating plainly:
| Standard | What it is |
| --- | --- |
| [EIP-1193](https://eips.ethereum.org/EIPS/eip-1193) | The JS provider interface. Both the SDK and this add-on talk to the wallet via `provider.request({ method, params })` |
| [EIP-5792](https://eips.ethereum.org/EIPS/eip-5792) | The wallet call API the Yodl UI uses — `wallet_getCapabilities`, `wallet_sendCalls`, `wallet_getCallsStatus`, `wallet_showCallsStatus` |
| [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) | Not a provider API. It lets an EOA delegate execution to smart-account code — the path this add-on uses to implement EIP-5792-style atomic batching when the wallet can't |
So: the UI speaks 5792, the wallet speaks 1193, and 7702 is the mechanism this package uses to bridge the gap.
## Requirements
The same platform requirements as the [React Native SDK](/sdk/react-native#requirements) — React Native `>= 0.72` and Hermes.
One addition: this package bundles the native `react-native-get-random-values` module, so it needs a development build for that reason too.
## Install
```bash
npm install @yodlpay/react-native-eip-7702-provider
npx pod-install # iOS
```
Rebuild the native app afterwards. The package owns its `viem`, `permissionless` and `react-native-get-random-values` dependencies, which keeps the base `@yodlpay/react-native` package wallet-provider agnostic.
Next: **[setup](/sdk/eip-7702-provider/setup)** to wire it up, or the **[reference](/sdk/eip-7702-provider/reference)** for options and per-method behaviour.
# Setup
Wrap your wallet provider with `withYodl7702` and pass the result wherever you'd pass the wallet provider.
```tsx
import { signAuthorizationViaProvider, withYodl7702 } from '@yodlpay/react-native-eip-7702-provider';
import { YodlProvider, YodlPayment } from '@yodlpay/react-native';
const provider = withYodl7702(walletProvider, {
// The only thing you must supply — no wallet signs 7702 authorizations the same way.
signAuthorization: signAuthorizationViaProvider(walletProvider),
});
;
```
That is the whole configuration for an integration against Yodl's hosted UI. `chains` defaults to Yodl's own bundler on the chains Yodl sponsors gas for, gas pricing defaults to asking that bundler, and chain reads go over the wallet you already passed in.
The wrapper reads the account and chain id from each `wallet_sendCalls` bundle, both supplied by the Yodl UI. The account implementation is the canonical SimpleAccount on EntryPoint 0.8 — used as both the delegated account logic and the authorization's delegate target, so the two can't drift. Sponsorship uses the standard [ERC-7677](https://eips.ethereum.org/EIPS/eip-7677) paymaster carried in the bundle's capabilities.
## Using Yodl's bundler
The default is `yodlHostedChains()`, which names the host that already sponsors the payment — the same URL the Yodl UI attaches to every bundle as `capabilities.paymasterService.url`. There is no bundler account, no API key and no per-op cost, because Yodl's pricing sponsors L2 gas.
Pass it explicitly when you'd rather see the choice at the call site, or narrow it:
```tsx
import { withYodl7702, yodlHostedChains } from '@yodlpay/react-native-eip-7702-provider';
const provider = withYodl7702(walletProvider, {
chains: yodlHostedChains([8453]), // Base only; omit the argument for every sponsored chain
signAuthorization: signAuthorizationViaProvider(walletProvider),
});
```
A chain Yodl does not sponsor throws rather than being handed an invented endpoint. Mainnet is deliberately excluded — on L1 gas is bundled into the payment rather than sponsored, so a mainnet bundle fails loudly with EIP-5792 `5710` instead of quietly building a UserOperation the user has to fund.
:::note
**What the default delegates.** The bundler receives the signed UserOperation and the signed EIP-7702 authorization, decides whether and when to submit, and is the only source of the settlement status reported back through `wallet_getCallsStatus`. On the default, that host is Yodl's. The sponsored-chain list also ships inside the package, so Yodl adding a corridor reaches you as a package upgrade rather than a config edit.
:::
## Bringing your own bundler
Pass `chains` entries directly and none of your traffic touches Yodl:
```tsx
const provider = withYodl7702(walletProvider, {
// List only the chains you can actually serve the fallback on.
chains: [
{
id: 8453,
bundlerUrl: 'https://api.pimlico.io/v2/8453/rpc?apikey=YOUR_KEY',
// rpcUrl is optional — see "Chain reads" below.
},
],
signAuthorization: signAuthorizationViaProvider(walletProvider),
});
```
:::note
`chains` here is independent of the SDK's `supportedChainIds`. This list is where the *fallback* can run — it should cover only chains you have a bundler for. A bundle on a chain outside this list fails with EIP-5792 code `5710`. An explicitly empty array is rejected rather than treated as an omission.
:::
## Gas pricing
[ERC-7769](https://eips.ethereum.org/EIPS/eip-7769) standardises the ERC-4337 JSON-RPC surface and deliberately leaves gas price out of it, so the right value is **bundler policy, not chain state** — and there is no vendor-neutral way to compute it. Pimlico serves a tiered oracle; Alchemy's Rundler expects a hand-assembled base fee plus a per-chain-buffered priority fee.
**You usually need to do nothing.** By default the wrapper asks the bundler you configured:
1. `pimlico_getUserOperationGasPrice` against that chain's `bundlerUrl`, taking the `fast` tier.
2. If the bundler doesn't implement that method, an EIP-1559 estimate from the chain, **doubled** — the same buffer viem applies for a bundler it knows nothing about.
Only an *unsupported-method* reply falls through to step 2. A real failure — auth, rate limit, network — propagates instead, because pricing against a bundler that is refusing to talk produces a submission rejected for an unactionable reason.
**Override it when your bundler prescribes something else**, notably Rundler, whose prechecks the generic estimate does not satisfy. The hook receives the bundle's chain id:
```tsx
estimateUserOperationFees: async (chainId) =>
// e.g. Pimlico: (await pimlicoClient.getUserOperationGasPrice()).fast
getYourBundlerGasPrice(chainId),
// @error: Never return 0n. Sponsorship covers the cost, not the gas-price fields.
```
`maxFeePerGas` is a **refundable ceiling** — a paymaster or refund covers the difference — so returning the fast tier never overcharges.
:::warning
Even paymaster-sponsored operations need **real, non-zero** fees. Sponsorship covers the cost, not the gas-price fields, so never return `0n`. A stale cached price below the bundler's floor fails the same way.
:::
## Chain reads
The wrapper makes exactly three chain reads: the EOA's **pending nonce** for the authorization, **`eth_getCode`** for delegation status, and the EntryPoint's **`getNonce`** per UserOperation. All three are ordinary node methods.
Omit `rpcUrl` and they go over the wrapped wallet's own EIP-1193 transport. The wallet is already connected to a node, so there is no second endpoint to configure or to drift out of step with the wallet mid-payment. Supply `rpcUrl` when you'd rather not depend on the wallet vendor's node — it always takes precedence.
Two things to know if you omit it:
* **EIP-1193 carries no per-request chain.** Reads land on whichever chain the wallet is currently on, so the wrapper checks the wallet's `eth_chainId` against the bundle's chain once per bundle and refuses on a mismatch with `-32602`. Switch the wallet before sending — which any EIP-5792 caller already does.
* **`blockTag: 'pending'` is the one read a wallet may refuse.** A wallet that method-whitelists its node proxy can reject it. That fails loudly and names `rpcUrl`, rather than retrying at `latest` — which could sign an authorization for an already-used nonce.
## Authorization signing
There is **no universal** way to sign an EIP-7702 authorization across wallets. Some expose `wallet_signAuthorization` over EIP-1193, some only a raw signer, and embedded wallets sign through their own SDK. The wrapper doesn't guess — you supply `signAuthorization`, receive the unsigned request, and return the signed authorization however your wallet does it.
**Try the built-in helper first.** For wallets that sign over EIP-1193:
```tsx
import { signAuthorizationViaProvider, withYodl7702 } from '@yodlpay/react-native-eip-7702-provider';
const provider = withYodl7702(walletProvider, {
signAuthorization: signAuthorizationViaProvider(walletProvider),
});
```
**If the wallet doesn't sign over EIP-1193**, sign through its SDK instead:
```tsx
signAuthorization: ({ account, chainId, contractAddress, nonce }) =>
yourWallet.signAuthorization({ address: account, chainId, contractAddress, nonce }),
```
The wrapper always fetches the `nonce` itself — over the chain's `rpcUrl` if one is configured, otherwise over the wallet's own transport — so the authorization can't go stale. You only sign.
### Picking a signing helper
`signAuthorizationViaProvider` is a **discovery convenience**. It tries `wallet_signAuthorization`, then falls back to raw `secp256k1_sign` — but only when the first is genuinely unsupported. Real errors such as user rejection surface as-is and are never masked.
Once you know which method your wallet uses, prefer the single-strategy primitive so failures stay precise:
| Helper | Method |
| --- | --- |
| `signAuthorizationViaWalletRpc(provider)` | `wallet_signAuthorization` only |
| `signAuthorizationViaSecp256k1(provider)` | raw `secp256k1_sign` only |
| `signAuthorizationViaProvider(provider)` | the former, falling back to the latter on unsupported-method |
# Reference
## `withYodl7702(provider, options)`
Returns an EIP-1193-compatible provider wrapper.
| Option | Type | Required | Description |
| --- | --- | --- | --- |
| `chains` | `{ id: number; bundlerUrl: string; rpcUrl?: string }[]` | No | Chains where the wrapper serves the fallback. Defaults to [`yodlHostedChains()`](#yodlhostedchainschainids). An explicitly empty array is rejected. See [using Yodl's bundler](/sdk/eip-7702-provider/setup#using-yodls-bundler) |
| `estimateUserOperationFees` | `(chainId: number) => Promise<{ maxFeePerGas: bigint; maxPriorityFeePerGas: bigint }>` | No | Overrides the built-in pricing, which asks the configured bundler. See [gas pricing](/sdk/eip-7702-provider/setup#gas-pricing) |
| `signAuthorization` | `(request: { account; chainId; contractAddress; nonce }) => Promise` | Yes | Signs the EIP-7702 authorization. No universal wallet method exists, so this one has no default. See [authorization signing](/sdk/eip-7702-provider/setup#authorization-signing) |
`chains[].rpcUrl` is optional. Omit it and the wrapper's three chain reads go over the wrapped wallet's own transport — see [chain reads](/sdk/eip-7702-provider/setup#chain-reads).
## `yodlHostedChains(chainIds?)`
Returns `chains` entries pointing at Yodl's own bundler — what `withYodl7702` falls back to when `chains` is omitted. Called with no argument it covers every chain Yodl sponsors gas on; pass ids to narrow it.
```tsx
yodlHostedChains(); // every sponsored chain
yodlHostedChains([8453]); // Base only
```
A chain outside `YODL_SPONSORED_CHAIN_IDS` throws rather than being handed an invented endpoint, and an empty list is rejected. Mainnet is deliberately excluded: on L1 gas is bundled into the payment rather than sponsored.
| Export | What it is |
| --- | --- |
| `yodlHostedChains(chainIds?)` | `chains` entries for Yodl's bundler, with no `rpcUrl` so reads ride the wallet transport |
| `YODL_SPONSORED_CHAIN_IDS` | The chain ids Yodl sponsors gas on |
| `YODL_BUNDLER_BASE_URL` | The bundler and ERC-7677 paymaster endpoint those entries point at |
## How it works
The wrapper is **native-first**. Every request is tried against the original provider, and the EIP-7702 path is used only as a fallback.
```mermaid
flowchart TD
UI["Yodl UI calls wallet_sendCalls"] --> TRY[Try the wallet natively]
TRY -->|Succeeds| NATIVE[Native path wins]
TRY -->|"Unsupported, 5700 or 5760"| FB[EIP-7702 fallback]
TRY -->|"User rejected or any other error"| ERR[Surfaced as-is, never masked]
FB --> SIGN["signAuthorization — you supply this"]
SIGN --> FEES["estimateUserOperationFees — defaults to the bundler's oracle"]
FEES --> SUB[Submit through the configured bundler]
SUB --> ID[Return an EIP-5792 id and track it]
```
`wallet_sendCalls` falls back when native support is unavailable, when the wallet rejects a required capability with EIP-5792 code `5700`, or when it reports atomicity unsupported with code `5760`. User rejection and unrelated wallet errors surface as-is and are never hidden by the fallback.
When it does fall back, the wrapper sends the calls through the Yodl EIP-7702 smart-account path, returns an EIP-5792 `{ id }`, and tracks that id for later status checks.
## Per-method behaviour
| Method | Behaviour |
| --- | --- |
| `wallet_getCapabilities` | Advertises fallback capabilities for the configured `chains` — the hosted default when none were passed: `atomic: ready` and `paymasterService` available |
| `wallet_sendCalls` | Tries native first; falls back only on `5700` / `5760` / unsupported. A `capabilities.paymasterService.url` is used via the standard ERC-7677 paymaster client |
| `wallet_getCallsStatus` | EIP-5792-shaped status for wrapper-tracked ids — pending, confirmed, reverted, partially reverted |
| `wallet_showCallsStatus` | Resolves with no result for wrapper-tracked ids, since fallback bundles have no native status screen. Unknown ids delegate to the original provider, then report the EIP-5792 unknown-bundle error |
| everything else | Passed straight through to the original provider |
## Troubleshooting
| Symptom | Cause and fix |
| --- | --- |
| `crypto.getRandomValues ... not supported` | The `react-native-get-random-values` native module isn't linked. Reinstall pods and rebuild — not Expo Go |
| `Can't find variable: BigInt` | Hermes is disabled — see [requirements](/sdk/react-native#requirements) |
| `Unsupported chain id` (EIP-5792 `5710`) | The bundle's chain isn't in `chains`. Add a `{ id, bundlerUrl }` entry for it. On the hosted default this means Yodl doesn't sponsor that chain — mainnet included |
| Wallet chain mismatch (`-32602`) | Chain reads are riding the wallet transport and the wallet is on a different chain than the bundle. Switch the wallet before sending, or give that chain its own `rpcUrl`. See [chain reads](/sdk/eip-7702-provider/setup#chain-reads) |
| Error naming `rpcUrl` and a `'pending'` nonce | The wallet's node proxy method-whitelists and refuses `eth_getTransactionCount` at `blockTag: 'pending'`. Configure `rpcUrl` for that chain |
| Fallback never triggers | The wallet supports `wallet_sendCalls` natively. That's expected — native always wins |
| `maxFeePerGas` / `maxPriorityFeePerGas` too low | An `estimateUserOperationFees` override returned a value below the bundler's floor, e.g. `0n` or a stale cached price. Drop the override to use the bundler's own oracle, or return real fees from it |
| Fallback fails at submission | Check that chain's `bundlerUrl` (and `rpcUrl` if you set one), including API keys |
:::tip
"Fallback never triggers" is the most-reported non-issue. If the wallet has native EIP-5792, this package is correctly doing nothing — verify with `wallet_getCapabilities` before debugging further.
:::
# Integrations
An **integration** is a wallet or app that embeds the Yodl SDK and wants payment data on its own backend.
This section is optional. The SDK ships the complete payment flow and needs no backend of yours to work. Everything here exists for when you want your own records.
## What you get
| Capability | What it does | You need |
| --- | --- | --- |
| [Payment notifications](/integrations/notifications) | We POST a signed snapshot whenever a payment's state or merchant data changes | An HTTPS endpoint and the webhook signing secret |
| [Payments API](/integrations/payments-api) | You query the payments made through your integration, newest first | The API secret |
| [Email attestation](/integrations/email-attestation) | Your users skip Yodl's email OTP because you already verified the address | A JWKS URL, and per-integration approval from us |
Notifications and the Payments API carry the same payment shape, so you parse one contract.
## The integration address
An integration is identified by an **integration address**: an EVM address (`0x` followed by 40 hex characters) that you pick and we register. It is used in three places:
* payments made through your app carry it in their on-chain metadata, which is how we attribute a payment to you
* it is the HTTP Basic **username** on every Payments API request
* it is the `iss` claim on every email attestation you sign
We store and match it lowercased. Use an address you control and will not reuse. A processor's own address cannot be registered.
## What we issue, what you provide
You provide:
| Item | Notes |
| --- | --- |
| Integration address | An EVM address you control |
| Label | A human name, for our operator tooling |
| Notification URL | Where we POST notifications. HTTPS only |
| JWKS URL | Only if you want email attestation. HTTPS only |
We issue:
| Item | Notes |
| --- | --- |
| Webhook signing secret | Verifies our notifications |
| API secret | The Basic password on Payments API requests |
## The two secrets
Both are opaque, high-entropy random strings, with opposite handling.
| | Webhook signing secret | API secret |
| --- | --- | --- |
| Direction | Yodl → you | You → Yodl |
| Purpose | Key for the HMAC on notifications we send you | Password for HTTP Basic on read requests you send us |
| Ever on the wire? | No | Yes, on every Payments API request |
| If leaked | Anyone can forge notifications to your endpoint | Anyone can read every payment made through your integration |
:::danger
Neither secret belongs in a mobile app, a browser bundle, or a repository. The API secret in particular travels on **every** Payments API request, so it must stay server-side. If either leaks, ask us to rotate it.
:::
## Getting started
Work through [onboarding](/integrations/onboarding) — nine steps from picking an address to reconciling payments.
# Onboarding
The order matters — each step depends on the one before it.
:::steps
##### Pick your integration address
An EVM address you control and won't reuse. See [the integration address](/integrations/integration-address) for how it's used and why it isn't a secret.
##### Stand up an HTTPS notification endpoint
It needs to exist before we can send you anything. HTTPS only.
##### Send us your details
The integration address, a label for our operator tooling, and the notification URL. Add a JWKS URL if you want [email attestation](/integrations/email-attestation).
##### Receive your secrets
We issue the webhook signing secret and the API secret. They are handled differently — see [the two secrets](/integrations/overview#the-two-secrets) before storing them.
##### Pass the address at SDK init
[Pass the integration address](/integrations/integration-address) when you create the SDK. Payments made before this carry no attribution.
##### Verify every signature
[Verify the HMAC](/integrations/notifications) on each notification and reject anything that fails. An unverified endpoint accepts forged payments.
##### Make your handler idempotent
Key on `chainId` + `txHash` + `version`, and discard anything not newer than what you already stored. We retry, and updates arrive out of order.
##### Sweep the Payments API
Poll the [Payments API](/integrations/payments-api) periodically. Same data, by pull — it covers anything a webhook failed to deliver.
##### Enable email attestation, if you want it
Publish your JWKS, send us the URL, and ask us to switch it on. It is off by default and a valid token does nothing until we enable it.
:::
:::warning
Before you credit anything to a user, read [a declared address is not an authenticated one](/integrations/integration-address#a-declared-address-is-not-an-authenticated-one). A notification means a payment *claimed* your integration address — not that it came from your app. Reconcile against your own records.
:::
# The integration address
Once your integration address is registered, tell the SDK about it. That is the whole client-side change.
## Pass the address at SDK init
```ts twoslash
import { createSdk } from '@yodlpay/react-native'
const sdk = createSdk({
// Your registered integration address.
integrationAddress: '0x5f2f6f387f49f7cfe0f6f302ff7f6ba6748b6ff1',
supportedChainIds: [1, 8453, 42161],
})
```
Configure it once at startup and pass the instance to `YodlProvider`. Nothing else in your payment code changes.
The example above is React Native. For the rest of the client setup — install, provider, components — see the [React Native quickstart](/sdk/react-native/quickstart); this page only covers the integration address.
The SDK attaches the address to each payment's on-chain metadata, which is how we attribute the payment to you. Attribution requires an SDK version that writes it — confirm the minimum version with us when you onboard.
## One integration per payment
A payment belongs to at most one integration. A payment with no integration address is attributed to nobody and notifies nobody.
## A declared address is not an authenticated one
:::danger
A payment's metadata is written by whoever builds the payment, so **anyone can claim any integration address, including yours.**
A notification, or a row in the Payments API, means some payment *claimed* your integration address. It does not mean the payment came from your app. Reconcile against your own records before you credit anything to a user.
:::
This is a property of on-chain metadata, not a gap we can close: the address is an attribution label, not a credential. Your webhook signing secret authenticates that *we* sent a notification; it says nothing about who originated the underlying payment.
## Historic payments
Integration addresses were added at a point in time and older payments were not backfilled. Your history starts when your integration goes live.
# Payment notifications
When a payment's state or merchant data changes, we POST the new snapshot to your notification URL. The [Payments API](/integrations/payments-api) is the same data, by pull.
We notify on a payment's first indexing, on every accepted processor update that changes anything, and whenever receipt polling advances the state.
## The delivery
```http
POST /your/webhook HTTP/1.1
Content-Type: application/json
X-Yodl-Integration: 0x5f2f6f387f49f7cfe0f6f302ff7f6ba6748b6ff1
X-Yodl-Signature: t=1785153600,v1=6ced28eb0f662564c0b4e43592bf376caeb042775d4abaa93a008781e1e38071
```
```json
{
"integrationAddress": "0x5f2f6f387f49f7cfe0f6f302ff7f6ba6748b6ff1",
"chainId": "8453",
"txHash": "0x82aa1e4f552e56f68c92d3cf3fdccbcafb51905c356ba163a4ae84631f416b96",
"version": 3,
"state": "success",
"recipient": {
"title": "Acme GmbH",
"subtitle": "Vietcombank 0123"
},
"invoice": {
"amount": "12345",
"currency": "VND",
"settlementAmount": "0.48",
"settlementCurrency": "USD"
}
}
```
Your endpoint must be HTTPS. Any `2xx` is a success; anything else is recorded as a failure on our side. We give up after 5 seconds.
## Payload
This is a full snapshot, not a patch. Every field is always present, though several are nullable.
| Field | Type | Notes |
| --- | --- | --- |
| `integrationAddress` | string | Lowercased. Matches the `X-Yodl-Integration` header |
| `chainId` | string | Destination chain ID, as a decimal string |
| `txHash` | string | Destination transaction hash |
| `version` | number | The payment's state version. Increments on every state change, never decreases |
| `state` | string | null | One of the states below, or `null` if no state is stored yet |
| `recipient` | object | null | `null` when no merchant name is known |
| `recipient.title` | string | Merchant name |
| `recipient.subtitle` | string | null | Secondary merchant line, e.g. a masked bank account |
| `invoice` | object | null | `null` unless **all four** of its fields are known |
| `invoice.amount` | string | Invoice amount, in `invoice.currency` |
| `invoice.currency` | string | ISO-4217 code |
| `invoice.settlementAmount` | string | What was settled on chain |
| `invoice.settlementCurrency` | string | ISO-4217 code |
`state` is one of:
| State | Meaning |
| --- | --- |
| `pending` | Payment is in flight at the processor |
| `success` | Payment completed |
| `failure` | Payment failed |
| `refund_pending` | A refund is in flight |
| `refund_success` | Refund completed |
| `refund_failure` | Refund failed |
Amounts are strings, not numbers. Do not parse them into a float and back.
## Headers
| Header | Value |
| --- | --- |
| `X-Yodl-Signature` | `t=,v1=<64 lowercase hex characters>` |
| `X-Yodl-Integration` | The integration address the delivery is addressed to, lowercased |
`X-Yodl-Integration` exists so that an endpoint serving more than one integration can select the right webhook signing secret *before* verifying. It is an unauthenticated routing hint — only a successful signature check proves anything.
`v1` is a **scheme version, not a key version**. Parse `X-Yodl-Signature` as a comma-separated list of `name=value` fields, look up the version you implement, and ignore fields you do not recognise. A future delivery may carry several (`t=…,v1=…,v2=…`) while an algorithm is being rotated. Today only `v1` is emitted.
## The signed string
`v1` is the lowercase hex HMAC-SHA256, keyed on your **webhook signing secret**, over:
```text
signed string = "" + "." +
v1 = hex(hmac_sha256(key = , message = signed string))
```
That is: the value of `t`, a literal `.`, then the request body exactly as it arrived.
* **Read the raw body before any JSON parsing, and sign that.** Re-serializing the parsed object produces different bytes — key order alone is enough. In Express, `express.json({ verify: (req, _res, buf) => { req.rawBody = buf } })` keeps the raw bytes; in Next.js route handlers, `await req.text()`.
* **The body is UTF-8.** HMAC the bytes, not a decoded string.
The webhook signing secret is never transmitted. It is only used as an HMAC key, so nothing in the request contains it.
## Verifying a signature
:::code-group
```js [Node]
import { createHmac, timingSafeEqual } from "node:crypto"
const TOLERANCE_SECONDS = 5 * 60
// rawBody must be the exact bytes received: a string or Buffer, never a re-serialized object.
function verifyYodlSignature(header, rawBody, webhookSigningSecret) {
if (!header) return false
const fields = new Map(
header.split(",").map((part) => {
const i = part.indexOf("=")
return [part.slice(0, i).trim(), part.slice(i + 1).trim()]
})
)
const timestamp = fields.get("t")
const signature = fields.get("v1")
if (!timestamp || !signature) return false
// Freshness window. Without it, a captured delivery can be replayed at any
// point in the future and will still verify.
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false
const expected = createHmac("sha256", webhookSigningSecret)
.update(`${timestamp}.${rawBody}`)
.digest()
const received = Buffer.from(signature, "hex")
// Constant-time comparison. timingSafeEqual throws on a length mismatch,
// so check the length yourself first.
// [!code word:timingSafeEqual]
return received.length === expected.length && timingSafeEqual(expected, received)
}
```
```python [Python]
import hmac
import hashlib
import time
TOLERANCE_SECONDS = 5 * 60
# raw_body must be the exact bytes received, never a re-serialized dict.
def verify_yodl_signature(header: str, raw_body: bytes, webhook_signing_secret: str) -> bool:
if not header:
return False
fields = {}
for part in header.split(","):
key, _, value = part.partition("=")
fields[key.strip()] = value.strip()
timestamp, signature = fields.get("t"), fields.get("v1")
if not timestamp or not signature:
return False
# Freshness window. Without it, a captured delivery replays forever.
try:
age = abs(int(time.time()) - int(timestamp))
except ValueError:
return False
if age > TOLERANCE_SECONDS:
return False
expected = hmac.new(
webhook_signing_secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
# Constant-time comparison.
return hmac.compare_digest(expected, signature)
```
:::
:::warning
`expected.toString("hex") === signature` is **not acceptable**. String comparison returns early on the first differing byte, and that timing difference is enough to recover a valid signature byte by byte. Use `crypto.timingSafeEqual`, or your language's equivalent constant-time comparison.
:::
## Worked example
This digest is what our signer produces for this secret, timestamp, and body. It is pinned by a test, so it cannot drift from what we send. Run it through your verifier before you go live.
* **Webhook signing secret:** `integration-example-secret`
* **Timestamp:** `1785153600`
* **Signature:** `6ced28eb0f662564c0b4e43592bf376caeb042775d4abaa93a008781e1e38071`
* **Body**, one line, no trailing newline, no reformatting:
```text
{"integrationAddress":"0x5f2f6f387f49f7cfe0f6f302ff7f6ba6748b6ff1","chainId":"8453","txHash":"0x82aa1e4f552e56f68c92d3cf3fdccbcafb51905c356ba163a4ae84631f416b96","version":3,"state":"success","recipient":{"title":"Acme GmbH","subtitle":"Vietcombank 0123"},"invoice":{"amount":"12345","currency":"VND","settlementAmount":"0.48","settlementCurrency":"USD"}}
```
The signed string is `1785153600.` followed by that body line verbatim.
:::tip
The timestamp is fixed, so the freshness check in the verifier above will reject this example as stale. When you test against it, pin your clock or test the digest computation on its own — and keep the freshness check in production.
:::
## Handling a delivery
Verify first, then decide whether the snapshot is newer than what you hold. Both gates matter, and in this order.
```mermaid
flowchart TD
IN[Delivery arrives] --> SIG{"Signature valid and within tolerance?"}
SIG -->|No| REJ[Reject, do not process]
SIG -->|Yes| VER{"version newer than stored?"}
VER -->|No| DROP[Discard: replay or out of order]
VER -->|Yes| STORE[Store and act on it]
STORE --> REC["Reconcile against your own records before crediting"]
```
## Two semantics you have to build for
### 1. Current state wins
Deliveries are not ordered. The same payment can arrive twice, and a later state can arrive before an earlier one.
`version` is the payment's state version. It increments on every state change and never decreases. Store it alongside the payment and **discard any delivery whose `version` is not greater than the one you have stored.**
`chainId` + `txHash` identifies the payment; adding `version` identifies a snapshot. Make your handler idempotent on that triple.
### 2. A notification is a claim, not proof of your sale
A payment's integration address is written into its on-chain metadata by whoever builds the payment, so anyone can claim yours.
A delivery proves that some payment claimed your integration address. It does not prove the payment came from your app. Reconcile against your own records before you credit anything, and treat a payment you cannot match as noise.
# Payments API
A read API over the payments made through your integration. Same payment shape as [payment notifications](/integrations/notifications), by pull.
Base URL: `https://api.yodl.me`
## Endpoints
```text
GET /api/v1/integration/payments
GET /api/v1/integration/payments/{txHash}
GET /api/v1/integration/wallet/{address}/payments
GET /api/v1/integration/wallet/{address}/payments/{txHash}
```
| Endpoint | Returns |
| --- | --- |
| `/payments` | Every payment made through your integration, newest first. Paginated |
| `/payments/{txHash}` | One payment, by **destination** transaction hash |
| `/wallet/{address}/payments` | The payments of one wallet, made through your integration. Paginated |
| `/wallet/{address}/payments/{txHash}` | One payment, scoped to both the wallet and your integration |
`{txHash}` is always the **destination** transaction hash — the same `txHash` a notification carries. Source-chain hashes are not accepted as a lookup key; use `source.txHash` for reconciliation only.
For the wallet endpoints, `address` matches **either side** of the payment, so a wallet's outgoing and incoming payments both appear. Matching is case-insensitive.
## Authentication
HTTP Basic, on every request.
| | |
| --- | --- |
| Username | Your integration address |
| Password | Your **API secret** |
:::code-group
```bash [curl]
curl -u "0x5f2f6f387f49f7cfe0f6f302ff7f6ba6748b6ff1:$YODL_API_SECRET" \
https://api.yodl.me/api/v1/integration/payments
```
```ts [TypeScript]
const credentials = Buffer.from(
`${process.env.YODL_INTEGRATION_ADDRESS}:${process.env.YODL_API_SECRET}`,
).toString('base64');
const res = await fetch('https://api.yodl.me/api/v1/integration/payments', {
headers: { Authorization: `Basic ${credentials}` },
});
if (!res.ok) throw new Error(`Yodl API ${res.status}`);
const page = await res.json();
```
```python [Python]
import os, requests
res = requests.get(
"https://api.yodl.me/api/v1/integration/payments",
auth=(os.environ["YODL_INTEGRATION_ADDRESS"], os.environ["YODL_API_SECRET"]),
timeout=10,
)
res.raise_for_status()
page = res.json()
```
:::
Send it as a real `Authorization: Basic ` header — the raw `address:secret` form is rejected.
:::warning
The API secret is **not** the webhook signing secret, and it travels on every request — see [the two secrets](/integrations/overview#the-two-secrets). Keep it server-side. Anything holding it can read every payment made through your integration.
:::
Every authentication failure is the same `401` with `{"error": "Unauthorized"}`. An unknown address, a wrong secret, a disabled integration, and an integration with no API secret issued are indistinguishable to the caller.
## You only ever see your own payments
Every query filters on your integration address. The wallet and transaction-hash filters only narrow that further.
* `/wallet/{address}/payments` returns what that wallet paid **through your integration**, not everything that wallet has ever done on Yodl.
* Payments indexed before integration addresses existed were not backfilled, and are invisible to every integration.
## Response
`/payments` and `/wallet/{address}/payments` return a page:
```json
{
"payments": [
{
"integrationAddress": "0x5f2f6f387f49f7cfe0f6f302ff7f6ba6748b6ff1",
"chainId": "8453",
"txHash": "0x82aa1e4f552e56f68c92d3cf3fdccbcafb51905c356ba163a4ae84631f416b96",
"version": 3,
"state": "success",
"recipient": { "title": "Acme GmbH", "subtitle": "Vietcombank 0123" },
"invoice": {
"amount": "12345",
"currency": "VND",
"settlementAmount": "0.48",
"settlementCurrency": "USD"
},
"sender": "0x1111111111111111111111111111111111111111",
"receiver": "0x2222222222222222222222222222222222222222",
"memo": "invoice-42",
"blockTimestamp": "2026-07-01T10:11:12.000Z",
"source": {
"chainId": "42161",
"txHash": "0x9c3b1a0e1b0f9a5d4c7e2f8b6a1d0c3e5f7a9b1d3c5e7f9a1b3d5c7e9f1a3b5d"
},
"tokenIn": { "address": "0x3333333333333333333333333333333333333333", "symbol": "WETH", "amount": "0.05" },
"tokenOut": {
"address": "0x4444444444444444444444444444444444444444",
"symbol": "USDT",
"amountGross": "1.00",
"amountNet": "0.99"
}
}
],
"nextCursor": "MTc1MTM2..."
}
```
The single-payment endpoints return one payment object in exactly that shape, **not** wrapped in an array and not wrapped in a page.
### Fields
The first seven fields are the notification payload, field for field — see the [notification payload table](/integrations/notifications#payload) for `state`, `recipient`, and `invoice`. The rest is the on-chain context a read API needs and a push does not.
| Field | Type | Notes |
| --- | --- | --- |
| `integrationAddress` | string | Your integration address, lowercased |
| `chainId` | string | Destination chain ID |
| `txHash` | string | Destination transaction hash, lowercased |
| `version` | number | Payment state version |
| `state` | string | null | `pending`, `success`, `failure`, `refund_pending`, `refund_success`, `refund_failure` |
| `recipient` | object | null | `title`, `subtitle` |
| `invoice` | object | null | `amount`, `currency`, `settlementAmount`, `settlementCurrency`. All four or `null` |
| `sender` | string | Payer address, lowercased |
| `receiver` | string | Payee address, lowercased |
| `memo` | string | The payment memo, verbatim |
| `blockTimestamp` | string | ISO-8601 UTC |
| `source` | object | `chainId` and `txHash` of the source-chain transaction, hash lowercased |
| `tokenIn` | object | `address` (lowercased), `symbol` (may be `null`), `amount` |
| `tokenOut` | object | `address` (lowercased), `symbol` (may be `null`), `amountGross`, `amountNet` |
Every identifier — addresses, transaction hashes, token addresses — is lowercased on the way out, so you have one canonical form to compare against. Symbols, memos, and merchant text are returned verbatim: they are content, not identifiers. All amounts are strings.
The field set is deliberately narrow. It is the on-chain facts of the payment plus the merchant data attached to it, and nothing from Yodl's own domain: no payment ID, no Yodl account or wallet identifiers, no linkage between a wallet and an account, no points, no community, no processor identity or processor memo, no solver, no raw decoder payload, and no indexing timestamps.
Responses are sent with `Cache-Control: private, no-store`. Do not put them in a shared cache.
## Pagination
Both list endpoints take:
| Parameter | Default | Notes |
| --- | --- | --- |
| `limit` | `50` | Rows per page. Must be an integer in `1`–`200` |
| `cursor` | — | The `nextCursor` from the previous page |
A `limit` outside `1`–`200` is a `400`, not a silent cap. That is deliberate: silently returning 200 rows to a caller that asked for 10,000 lets it believe it has the whole set.
Paging is keyset, ordered by `(blockTimestamp, chainId, txHash)` descending, so a page cannot shift under you while new payments arrive.
`nextCursor` is **opaque** — treat it as a token and do not parse or construct it. It is `null` on the last page.
:::code-group
```bash [curl]
curl -u "$YODL_INTEGRATION_ADDRESS:$YODL_API_SECRET" \
"https://api.yodl.me/api/v1/integration/payments?limit=100&cursor=$CURSOR"
```
```ts [Reconciliation sweep]
const BASE = 'https://api.yodl.me/api/v1/integration/payments';
const auth = Buffer.from(
`${process.env.YODL_INTEGRATION_ADDRESS}:${process.env.YODL_API_SECRET}`,
).toString('base64');
// Page newest-first and stop at the first payment already stored.
let cursor: string | null = null;
do {
const url = new URL(BASE);
url.searchParams.set('limit', '100');
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url, { headers: { Authorization: `Basic ${auth}` } });
if (!res.ok) throw new Error(`Yodl API ${res.status}: ${await res.text()}`);
const page = await res.json();
for (const payment of page.payments) {
if (await alreadyStored(payment.chainId, payment.txHash)) return; // caught up
await store(payment);
}
cursor = page.nextCursor; // opaque — never parse or construct it
} while (cursor);
```
:::
For reconciliation, page newest-first until you reach a payment you have already stored, then stop.
:::tip
Reconcile on `chainId` + `txHash` rather than `txHash` alone. The same hash can be indexed on more than one destination chain, which is what the `409` on the single-payment endpoint is telling you.
:::
## Errors
| Status | When |
| --- | --- |
| `400` | `limit` out of range or not an integer, unparseable `cursor`, malformed `txHash` (not a 32-byte hex string), or malformed `address` (not a 20-byte hex string) |
| `401` | Missing or invalid credentials — unknown address, wrong API secret, disabled integration, or no API secret issued |
| `404` | No such payment for your integration |
| `409` | The same transaction hash is indexed on more than one destination chain, so the reference is ambiguous. Narrow it with the wallet-scoped endpoint |
Error bodies are `{"error": "..."}`. On a `400` from `limit`, the body also carries `maxLimit`.
Note that parameter validation happens *after* authentication: an unauthenticated caller always gets a `401`, never a `400`, whatever it sends.
## Same caveat as notifications
These are payments that **claimed** your integration address on chain, which is not the same as payments your app originated — see [a declared address is not an authenticated one](/integrations/integration-address#a-declared-address-is-not-an-authenticated-one).
# Email attestation
Optional. If your app has already verified a user's email address, you can assert that to Yodl and the user skips Yodl's own email OTP during registration.
:::warning
Email attestation is **granted per integration, by us**, and is off by default. It is a separate switch from the one that enables your integration. Until we turn it on, a valid token does nothing and the user gets the OTP. Ask for it during onboarding.
:::
## How it works
1. Your backend signs a short-lived JWT asserting "this wallet belongs to this email".
2. Your app passes it into the Yodl registration step as `integrationEmailToken`, alongside the wallet's own Yodl token from the wallet-signature step.
3. We fetch your public key from your JWKS URL, verify the token, and register the user with the email already verified.
These are two different tokens from two different issuers: the wallet token proves the wallet, yours asserts the email.
```mermaid
sequenceDiagram
autonumber
actor U as User
participant App as Your app
participant BE as Your backend
participant Y as Yodl
U->>App: Starts registration
App->>BE: Ask for an attestation token
BE-->>App: JWT, iss = your integration address
Note over App,Y: The wallet separately signs SIWE, producing the wallet token
App->>Y: register with walletToken + integrationEmailToken
Y->>BE: Fetch your JWKS URL
BE-->>Y: Public keys
Y-->>App: Registered, email already verified
```
Two tokens, two issuers, one request. Yours travels as `integrationEmailToken`; the wallet's proves control of the address in `sub`.
**Every failure is a fallback, not an error.** An expired token, an unknown `kid`, a replayed `jti`, or an email that already belongs to another Yodl account all send the user through the normal OTP flow. You cannot rely on the attestation path always being taken.
## What you register with us
| Item | Notes |
| --- | --- |
| JWKS URL | Where you publish your public keys. **HTTPS only** — an `http://` URL is treated as no key material at all |
| Integration address | Already registered; it doubles as your `iss` |
We fetch and cache the key set and handle `kid` rotation, so publishing a new key and signing with it needs no coordination. Keep old keys in the set until tokens signed with them have expired.
## The claim set
```json [Header]
{
"alg": "EdDSA",
"kid": "wallet-2026-07"
}
```
```json [Payload]
{
"iss": "0x5f2f6f387f49f7cfe0f6f302ff7f6ba6748b6ff1",
"aud": "api.yodl.me",
"sub": "0x000000000000000000000000000000000000c0de",
"email": "user@example.com",
"email_verified": true,
"iat": 1785153600,
"exp": 1785153720,
"jti": "01J8Z6TESTVECTOR0000000001"
}
```
| Claim | Required | Value |
| --- | --- | --- |
| `alg` (header) | yes | `EdDSA` or `ES256`. Nothing else, ever |
| `kid` (header) | yes | Non-empty. Must match a key in your JWKS |
| `iss` | yes | Your integration address |
| `aud` | yes | `api.yodl.me` |
| `sub` | yes | **The user's wallet address** — not the email |
| `email` | yes | The address you have verified |
| `email_verified` | yes | Exactly boolean `true` |
| `iat` | yes | Issued-at, unix seconds. Must not be in the future |
| `exp` | yes | Expiry, unix seconds. At most 5 minutes after `iat` |
| `jti` | yes | Unique per token, single use |
### `sub` is the wallet, not the email
This is the part people get wrong first, so it is worth dwelling on.
The token's primary assertion is a **binding between a wallet and an email**. Putting the wallet in `sub` is what makes that binding part of the token's identity rather than a pair of loose attributes: a token minted for one wallet cannot be replayed against another.
We check `sub` against the wallet the caller has *already* proved control of in the signature step. If they differ, the token is rejected. So you can only ever speak about a wallet the user is currently proving they hold — you cannot attach an email you verified to a wallet you do not control.
Likewise, `email` is checked against the address actually being registered. A mismatch is rejected, and — deliberately — does *not* burn the token's `jti`, so a token stays usable for the address it was really minted for.
### Algorithms
`EdDSA` (Ed25519) and `ES256` (P-256) only. The allowlist is applied before anything else, so the token header can never widen it and `alg: none` never gets past the first check.
### Lifetime
`exp - iat` must be **at most 300 seconds (5 minutes)**, and `exp` must be after `iat`. We enforce the cap ourselves rather than trusting the issuer to keep it short.
`iat` must not be in the future either. A short window placed a week from now would verify today and keep its OTP-bypass authority until that far-future `exp` — the cap constrains the window's width, not where you put it.
There is a 30-second clock tolerance on both boundaries, so small clock skew between your server and ours is fine. Do not rely on it for anything else.
Mint tokens on demand, at the moment the user starts registration. Two minutes is a comfortable `exp`. Do not mint them in advance and cache them.
### `jti`
Single use, scoped to your integration. We record it on first successful use and reject the second. Use a UUID or a ULID; anything with real entropy per token is fine. Never reuse one, and never derive it from the user or the email.
The `jti` is only consumed by a token we would otherwise accept, so a token rejected on a mismatched email or an expired window has not burned its identifier.
## Verification order
We check in this order, and what a failure means depends on where it happened.
:::steps
##### Algorithm allowlist
The header never gets to pick the key type.
##### Registry gates
Your integration must be registered, enabled, cleared for email attestation, and have key material. All of this happens *before* any signature is checked — which is why a correctly signed token can still fail if attestation isn't switched on for you.
##### `kid` resolution
Resolved against your JWKS.
##### Signature, `iss` and `aud`
The cryptographic check, plus issuer and audience.
##### Lifetime
The 5-minute cap, and where the window sits relative to now.
##### The attested facts
`email_verified`, `email`, and `sub` against the wallet.
##### `jti`
Last, so only a token we would otherwise accept is spent.
:::
:::tip
The ordering is the useful part when debugging. A failure at step 2 means a registration or enablement problem, not a signing problem — so check with us before re-deriving keys.
:::
## A minimal signing server
Node and TypeScript, using [`jose`](https://github.com/panva/jose): a JWKS endpoint, and an endpoint that mints a token for a user your app has already authenticated.
Generate the keypair once, out of band:
```ts [generate-keypair.ts]
import { exportJWK, exportPKCS8, generateKeyPair } from 'jose'
const { publicKey, privateKey } = await generateKeyPair('EdDSA', { extractable: true })
console.log(await exportPKCS8(privateKey)) // -> your secret store
console.log(JSON.stringify(await exportJWK(publicKey))) // -> your JWKS
```
Then the server:
```ts [server.ts]
import { randomUUID } from 'node:crypto'
import express from 'express'
import { importPKCS8, SignJWT } from 'jose'
const KID = 'wallet-2026-07'
const ALG = 'EdDSA'
const INTEGRATION_ADDRESS = '0x5f2f6f387f49f7cfe0f6f302ff7f6ba6748b6ff1'
const TOKEN_LIFETIME_SECONDS = 120
// Private key from your secret manager. Never from a bundled file, never from the app.
const privateKey = await importPKCS8(process.env.YODL_ATTESTATION_PRIVATE_KEY!, ALG)
// The public half, as a JWK, with the kid you sign under.
const publicJwk = { ...JSON.parse(process.env.YODL_ATTESTATION_PUBLIC_JWK!), kid: KID, alg: ALG, use: 'sig' }
const app = express()
// This is the URL you register with us.
app.get('/.well-known/jwks.json', (_req, res) => {
res.json({ keys: [publicJwk] })
})
app.post('/yodl/email-attestation', async (req, res) => {
// YOUR session, YOUR verified email, YOUR wallet binding. Nothing here is taken
// from the request body: a caller must not get to choose the email or the wallet.
const user = await requireAuthenticatedUser(req)
if (!user.emailVerified) return res.status(403).json({ error: 'email not verified' })
const now = Math.floor(Date.now() / 1000)
const token = await new SignJWT({ email: user.email, email_verified: true })
.setProtectedHeader({ alg: ALG, kid: KID })
.setIssuer(INTEGRATION_ADDRESS)
.setAudience('api.yodl.me')
.setSubject(user.walletAddress)
.setIssuedAt(now)
.setExpirationTime(now + TOKEN_LIFETIME_SECONDS)
.setJti(randomUUID())
.sign(privateKey)
res.json({ token })
})
```
Three constraints on that endpoint:
* **The private key must not ship in the mobile app.** Anyone who extracts it can assert any email for any wallet. Sign on your server.
* **`exp` must be short.** 120 seconds is enough; 5 minutes is the ceiling we accept. Mint on demand.
* **`jti` must be unique per token.** Reuse a value and the second, legitimate use is the one rejected.
## Getting the token to us
If you use the React Native SDK, you do not build the handoff yourself. Give the component a function and the hosted flow calls it at the moment it registers — after the wallet signature, so the address it hands you is the one it will bind. `` is the usual place, but `` and `` take the same prop, since a wallet with no Yodl account can be asked to register there too:
```tsx [SignupScreen.tsx]
{
const response = await fetch('https://api.yourwallet.com/yodl/email-attestation', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session}` },
body: JSON.stringify({ walletAddress: address, email }),
signal,
});
if (!response.ok) return null;
const { token } = await response.json();
return { token };
}}
/>
```
Hand the same function to `` when your app has no separate signup step and the account gets created mid-payment — the demo integration does exactly this:
```tsx [PayScreen.tsx]
```
Yodl asks only when a flow actually registers an account, so on a wallet that already has one the function is never called and the prop costs nothing.
That is why the endpoint above takes no wallet from the request body: the SDK tells you which address to sign for, and your session tells you which email. Neither is caller-controlled.
:::tip
Being called per attempt is the point. Each one gets an unspent `jti` and a full lifetime, so a user who takes their time signing — or retries — still gets the fast path. Do not cache what you return.
:::
Returning `null`, throwing, or taking longer than 30 seconds all mean the same thing: the user verifies by code, exactly as if you had never integrated this. See [the signup flow](/sdk/react-native/signup#skipping-email-verification).
Integrating without our SDK? Send the token as `integrationEmailToken` alongside `email` and the wallet token in the registration request.