Elements
Embed Affiliateo in your own app. Your users get their referral link, watch their earnings and cash out without ever leaving your app.
Try them in the playground before you write any code.
How it fits together
Three steps, and the shape will be familiar if you have used Stripe Connect embedded components.
- Your backend mints a session with your
afk_key, naming the affiliate, the components, and the origins allowed to show them. - That returns a client secret. Hand it to your frontend.
- Our SDK mounts each component as an iframe on your page, on our domain.
Your secret key never reaches the browser, and the affiliate's data never passes through your servers.
Components
| Name | Shows | Asks them to confirm |
|---|---|---|
affiliate | Their referral link, clicks/sales/earned, and a date filter. The combined piece. | No |
link | Just their referral link, with a dropdown to switch link formats. | No |
qr | A scannable QR code of their referral link. | No |
stats | Just the clicks, sales and total earned. | No |
products | Your catalogue with what they earn on each product. | No |
activity | Their recent sales on this app. Refunds show as negatives. | No |
balance | What your app owes them: pending, payable, paid. Your numbers, so no sign-in. | No |
withdraw | The full cash-out flow: amount, payout speed, fees, bank. In place on native; the web default shows the balance plus a button that opens it in a popup, and flow: "inline" opts in to the in-place form. | Yes |
payouts | Their withdrawal history: amount, status, bank, arrival. Asks them to confirm. | Yes |
identity | Gets them verified and set up to be paid. | Yes |
Mix them with the API
Elements and the REST API are not a choice between two paths. Build the screens you want control over yourself, and drop in the ones you would rather not build. Most people do not want to write bank collection or ID capture, and almost everyone wants their own referral-link screen.
A session only carries the components you ask for, so ask for what you are actually mounting. One is fine. Requesting all ten and hiding eight of them is not.
// Your own links + balance screens, from the REST API:
GET /api/v1/businesses/{slug}/apps/{appId}/affiliates?email=alex@example.com
-> links.short / links.username / links.direct
stats.clicks / stats.conversions / stats.commission_cents
payouts.pending_cents / payable_cents / paid_cents
// Our withdraw element for the part you would rather not build.
// Ask for that ONE component:
POST /api/v1/businesses/{slug}/apps/{appId}/affiliates/embed-session
{ "email": "alex@example.com",
"components": ["withdraw"],
"allowed_origins": ["https://app.example.com"] }
// Prefer no SDK at all? Skip the session and use the hosted link.
// One POST, one redirect, nothing to mount:
POST /api/v1/businesses/{slug}/apps/{appId}/affiliates/withdrawal-link
{ "email": "alex@example.com", "return_url": "https://app.example.com/done" }
-> { "url": "...", "expires_in": 3600 }The data behind every open element is on the API too, under the same afk_ key: GET /affiliates?email= returns the referral links, the click and sale counts, and the same pending/payable/paid the balance element renders. So a screen you build by hand and a screen you mount show the same numbers, and you can move a screen from one to the other later without the figures shifting.
The one thing you cannot build yourself is the wallet total. It spans every program the affiliate is in, so no owner-side endpoint returns it. If your own screen needs a “ready to withdraw” figure, that figure only exists inside withdraw and payouts, after the affiliate has confirmed who they are. Your API-built screens show what you owe them.
Which to use, per screen. Every element is its own page load, an iframe on the web and a WebView in an app, so a tab stacking four of them pays for four before it paints, and your app cannot cache any of them. Anything the API already returns is faster built yourself, and you can cache it so the screen paints the moment it opens. We recommend building link, qr, stats, products, activity and balance, and mounting elements only for withdraw, payouts and identity: the three that collect bank details, open the account-wide wallet, or run ID capture. This is true on the web and in an app, and it bites hardest in an app, where every element is a whole WebView.
Lists are the sharpest case. The embedded activity and payouts elements show the 25 most recent and neither paginate nor filter, so an affiliate with a year of sales cannot reach the 26th, and cannot ask to see just their refunds. GET /affiliates/conversions is cursor-paginated (?limit=, ?starting_after=) and filterable by date (?from=&to=) and by type (?type=refund,chargeback, comma-separated, any of subscription, one_time, renewal, trial, refund, chargeback).
Filter on our side, not in yours. Both filters are applied in the query, before the cursor, so a filtered feed pages through matching rows only. Filtering rows you have already fetched looks equivalent and is not: it can only search what is on screen, so a Refunds chip finds nothing for someone whose single refund sits 300 rows down, and the chip reads as broken. An unknown ?type= value comes back 400 rather than an empty page, so a typo is never mistaken for “this affiliate has no refunds”.
What you give up by building it. The API returns data, never copy. lang is a mint parameter for elements only: no read endpoint takes it, and none returns a label. So an API-built screen needs its own words, but not its own translations. The exact strings our elements use are published at /locales/{lang}.json in all 16 languages, so you can lift them instead of writing and translating around 25 strings. The keys worth taking: embed.appEarnings.* for the buckets and their hint line, embed.activity.* for row types and empty states, embed.filter.preset.* for the date chips, and embed.link.format* for the picker. The embed.activity.* type words double as the labels for a Type menu, since a row reading “Refund” and a filter offering “Refund” should not be two different translations of the same word.
Copy the values into your own catalogue at build time rather than fetching them live. Those files are the elements' runtime asset, not a versioned API, so a key can be renamed with no deprecation cycle and a live fetch would blank labels in an app you have already shipped.
Keep the money words verbatim even if you reword everything else. Pending is earned but not yet released by the business, Payable is released and ready to pay out, and Paid means the money is already in their Affiliateo balance and not yet in their bank. “Paid out” is the wording to avoid: it reads as though the money reached the bank, and it contradicts the hint line sitting directly under it.
Two things nobody translates: product names are whatever you typed, so “Premium Annual” reads the same in Japanese, and the tab bar is yours, because no endpoint describes navigation.
The recommended setup
Ten components is a lot of freedom, and freedom is not a layout. Which of the two below you want depends on one thing: whether affiliates are the whole product, or a section of one.
One page, for a section inside an existing product
The common case, and where most integrations should start. A settings page, an account area, a creator dashboard. One scrolling page: referral link and QR at the top, then lifetime stats, then earnings with the sales list, then a cash-out button. Less navigation, and everything the affiliate came for is visible at once, which is what to share, what they have made, and how to get it. Build all of it from GET /affiliates?email=, GET /apps/{appId} and GET /affiliates/conversions, and mount exactly one element: withdraw.
Put cash out in a dialog rather than stacked inline at the bottom. The element is an iframe you place, so the dialog is yours: a modal on the web, a full-screen sheet on mobile web, its own pushed screen in a native app. It measures itself and reports its height, so the dialog can size to it. Inline works, but then the page grows and shrinks under the reader as the bank step expands, on the one surface where that feels worst.
Do not unmount the element when the dialog closes. This is the easiest way to make the whole thing feel broken. The confirm lives in the iframe's memory and nowhere else, so destroying it, which most dialog libraries do on close, throws that away and the affiliate re-confirms on every single open. Mount it once and toggle visibility with display: none instead. The confirm then survives the whole visit, a hidden element emails nobody because it only asks once it has actually been on screen, and the first open is instant.
Three tabs, for a dedicated affiliate surface
What we ship in our own apps, where affiliates are the product rather than a feature of it.
Tab 1 Link qr, link, stats, products
Tab 2 Balance balance, activity
Tab 3 Withdraw withdraw
// Three tabs in your platform's NATIVE bottom tab bar. One session
// covers all of it:
{
"email": "alex@example.com",
"components": ["qr", "link", "stats", "products", "balance", "activity", "withdraw"],
"platform": "native",
"appearance": { "colorPrimary": "#16A34A", "contentPadding": "16px" }
}Three tabs, and in our own apps only the third has an element in it. Following the split above, the first two are built from the API and the session names components: ["withdraw"] alone. Mounting all of them still works if you would rather we rendered everything, it is just slower and your app cannot cache it.
Link. A QR of the short link, because short survives a username change and a printed code has to keep working. Then the referral link in a field whose own chevron opens the direct / short / username picker, with a copy icon beside it, a share button under that, then a clicks / sales / earned row, then the per-product rates. Balance. Pending plus payable plus paid as one headline figure, the three buckets as stacked proportional bars underneath, then date chips (all, today, 7d, 30d, 90d, 1y), a Type menu beside the list heading, and a paginated list of sales under both. Cash out. The withdraw element, on its own.
The Type menu is five entries over six raw types: All types, Sales (?type=subscription,one_time), Renewals (renewal), Trials (trial) and Refunds (?type=refund,chargeback). Whether a first sale renews, and whether a reversal was the customer asking or the bank taking, are not distinctions an affiliate filters on: both mean money back. The rows still say Sale and Chargeback individually, so the difference stays visible where it is actually useful.
On mobile, put those tabs in the platform's native bottom tab bar rather than a hand-built one. It brings the OS's own hit-testing, animation and accessibility, and on current iOS it is the floating pill people already know. On the web there is no system equivalent to reach for, so use whatever navigation the page already has: a tab strip, a sidebar, three routes. Nothing about elements depends on it, because no endpoint describes navigation. Either way each tab is one scroll view with its components stacked, nothing between them, and your own background painted behind: elements leave their margins transparent on purpose so your ground shows through.
SwiftUI
// SwiftUI. The system tab bar IS the floating pill on current iOS,
// and it brings hit-testing, the morph animation and VoiceOver free.
TabView(selection: $tab) {
Tab(value: .link) { page(["qr", "link", "stats", "products"]) }
label: { Label("Link", systemImage: "link") }
Tab(value: .balance) { page(["balance", "activity"]) }
label: { Label("Balance", systemImage: "wallet.pass.fill") }
Tab(value: .withdraw) { page(["withdraw"]) }
label: { Label("Cash out", systemImage: "banknote.fill") }
}
.tint(brandColor)
// One tab: its elements stacked, scrolling as a single page, on YOUR
// background (elements leave their margins transparent on purpose).
func page(_ components: [String]) -> some View {
ScrollView {
VStack(spacing: 0) {
ForEach(urls(for: components), id: \.absoluteString) { url in
SelfSizingElementWebView(url: url) // sizes from onContentHeight
}
}
}
.background(Color.appBackground)
}Kotlin, Jetpack Compose
// Material 3 NavigationBar: the platform's own bottom bar.
Scaffold(
bottomBar = {
NavigationBar {
tabs.forEach { t ->
NavigationBarItem(
selected = tab == t,
onClick = { tab = t },
icon = { Icon(t.icon, null) },
label = { Text(t.label) },
)
}
}
}
) { padding ->
Column(
Modifier
.padding(padding)
.verticalScroll(rememberScrollState())
.background(MaterialTheme.colorScheme.background)
) {
// AffiliateoElementView per component; set onContentHeight on each
// and give the view that height instead of guessing one.
tab.components.forEach { component -> AffiliateoElement(component) }
}
}React Native, Expo
// Expo Router. A native bottom tab bar, which is the pill on iOS.
// app/affiliate/_layout.tsx
import { Tabs } from 'expo-router'
<Tabs screenOptions={{ tabBarActiveTintColor: brand }}>
<Tabs.Screen name="link" options={{ title: 'Link' }} />
<Tabs.Screen name="balance" options={{ title: 'Balance' }} />
<Tabs.Screen name="withdraw" options={{ title: 'Cash out' }} />
</Tabs>
// app/affiliate/link.tsx: one tab, its elements stacked. Each sizes
// itself, so there are no heights to guess.
<ScrollView style={{ backgroundColor: theme.background }}>
<QrElement fetchClientSecret={mint} appearance={tokens} />
<LinkElement fetchClientSecret={mint} appearance={tokens} />
<StatsElement fetchClientSecret={mint} appearance={tokens} />
<ProductsElement fetchClientSecret={mint} appearance={tokens} />
</ScrollView>Do not give identity a tab of its own next to withdraw. Withdraw already walks an unverified affiliate through the ID check exactly where they need it, and skips it forever once they pass. A separate tab shows them the same step twice. Reach for identity only as a standalone get-paid-ready page when you are not showing withdraw at all, for instance to get someone set up before they have earned anything.
1. Mint a session
From your backend only. The afk_ key must never ship inside a browser bundle or an app binary.
curl -X POST \
"https://affiliateo.com/api/v1/businesses/{slug}/apps/{appId}/affiliates/embed-session" \
-H "Authorization: Bearer afk_live_..." \
-H "Content-Type: application/json" \
-d '{
"email": "alex@example.com",
"components": ["affiliate", "balance", "withdraw"],
"allowed_origins": ["https://app.example.com"],
"appearance": {
"colorPrimary": "#1754D8",
"buttonColor": "linear-gradient(135deg, #1754D8, #7C3AED)",
"borderRadius": "12px"
}
}'Returns:
{
"client_secret": "eyJhbGciOiJIUzI1NiJ9...",
"expires_in": 3600,
"platform": "web",
"components": ["affiliate", "balance", "withdraw"],
"allowed_origins": ["https://app.example.com"],
"requires_step_up": ["withdraw"],
"element_urls": {
"affiliate": "https://affiliateo.com/embed/affiliate/eyJhbGci...",
"balance": "https://affiliateo.com/embed/balance/eyJhbGci...",
"withdraw": "https://affiliateo.com/embed/withdraw/eyJhbGci..."
}
}element_urls are paste-able. Drop one into an <iframe src> to see a component working before you install anything. Sessions last an hour.
2. Serve the secret
One endpoint of your own that mints a session for whoever is logged in. Your key stays on your server.
// Your own endpoint. The afk_ key never leaves your server.
app.post('/api/affiliateo-session', async (req, res) => {
const r = await fetch(
`https://affiliateo.com/api/v1/businesses/${SLUG}/apps/${APP_ID}/affiliates/embed-session`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AFFILIATEO_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: req.user.email, // who this session is for
components: ['affiliate', 'withdraw'],
allowed_origins: ['https://app.example.com'],
}),
},
)
res.json({ client_secret: (await r.json()).client_secret })
})3. Mount
<div id="affiliateo-link"></div>
<script src="https://affiliateo.com/embed.js"></script>
<script>
const affiliateo = Affiliateo.init({
// Called now, and again whenever a session needs replacing.
fetchClientSecret: () =>
fetch('/api/affiliateo-session')
.then((r) => r.json())
.then((d) => d.client_secret),
// Optional: theme every element from first paint (no brand flash).
appearance: { colorBackground: '#191919', colorText: '#eeeeee' },
})
// mount() returns a handle. initialHeight is the height it shows before
// the element reports its real one, which avoids a first-paint jump.
const el = affiliateo.mount('affiliate', '#affiliateo-link', { initialHeight: 220 })
// When you remove the element from your page, tear down its frame + listener:
// el.destroy()
</script>fetchClientSecret is a function, not a string, so the SDK can call it again on its own: it re-mints shortly before each hourly session expiry (and on tab wake after a sleep) and swaps the element to the new session. A page left open longer than an hour keeps working — mint a fresh secret on every call rather than caching one.
The web, React Native and Swift SDKs all do this for you, because all three take fetchClientSecret and can call it again. Kotlin is the exception: its load() takes a secret you already minted, so there is nothing for it to call. Set onSessionExpiring and it gets the same behaviour. Leave it unset and the view keeps its single session, which is fine for a screen nobody leaves open for an hour and a silent death for one they do.
// Kotlin. Without this the view keeps one session and goes dead after an hour.
element.onSessionExpiring = { deliver ->
lifecycleScope.launch { deliver(api.mintAffiliateoSecret()) }
}
element.load(AffiliateoComponent.BALANCE, firstSecret)You never set the final height. Elements measure themselves and report it: on the web the SDK resizes the frame, and in a native app the same measurement crosses the SDK's bridge and sizes the view, so several elements stack in one scroll view without anyone guessing pixel heights. The optional initialHeight is just the placeholder shown until that first measurement lands.
Prefer a typed wrapper? The same elements ship as @affiliateo/elements-react and @affiliateo/elements-react-native on npm, as a Swift package from affiliateo-elements-swift and as a Kotlin source module from affiliateo-elements-android. All of them are thin frames over these same hosted pages, so a platform improvement reaches every app without a package update.
Expo needs no separate package: @affiliateo/elements-react-native is the one, and it runs in Expo Go, in development builds and on EAS, because react-native-webview is one of the native modules Expo Go already bundles. Install it with npx expo install react-native-webview so the build matches your SDK version. The only Expo-shaped difference is the camera permission the identity element needs: it goes in app.json under ios.infoPlist.NSCameraUsageDescription and android.permissions, not in the native files, which an Expo project does not edit.
Where they run
Elements fill the width you give them and lay out from there, so one integration covers every surface. There is no mobile build.
| Surface | platform | allowed_origins |
|---|---|---|
| Website, desktop or phone | web | Your page origins |
| Capacitor, Ionic, Cordova | web | Your shell origin, e.g. capacitor://localhost |
| Native iOS or Android | native | Omit it |
A native app has no iframe, so it opens an element_urls entry top-level in a WebView instead. Nothing frames it, so there is no origin to name.
// Backend: no origins, because nothing frames a native WebView.
// Send appearance here too: it is what themes the FIRST paint.
{
"email": "alex@example.com",
"components": ["balance", "withdraw"],
"platform": "native",
"appearance": {
"colorPrimary": "#16A34A", // or your elements arrive in our blue
"contentPadding": "16px" // a WebView is edge to edge; give it room
}
}
// iOS: open element_urls.balance in a WKWebView
// Android: open it in android.webkit.WebView
// On native the withdraw element renders the full cash-out
// form in place by default; no popup, no extra button.Three things a native host owns that a web page gets for free. Theme: pass appearance when you mint, or the elements arrive in our default blue next to your brand. Padding: elements carry no outer margin because on the web they already sit inside your padded layout, and a WebView is edge to edge, so set contentPadding or pad the view around it. Background: elements paint on a transparent ground so your app shows through, but a WebView is opaque white until you say otherwise. Our SDKs do the last one for you; a hand-rolled WebView needs isOpaque = false on iOS.
import { ScrollView } from 'react-native'
import { QrElement, BalanceElement } from '@affiliateo/elements-react-native'
// Stack as many as you like. Each one sizes itself, so there are no
// hardcoded heights to get wrong when a row or an error line appears.
<ScrollView>
<QrElement fetchClientSecret={mintOnMyBackend} appearance={theme} />
<BalanceElement fetchClientSecret={mintOnMyBackend} appearance={theme} />
</ScrollView>
// autoHeight={false} for an element that owns a whole screen and takes
// its height from your own style instead.Make it feel instant
The difference between an element that feels native and one that feels like a web page is WHEN it loads. Load it before the person looks, and the loading state simply never gets seen.
On the web: mount every element when your page (or your tab container) first opens, and show or hide them as the person switches tabs, rather than mounting each element on tab-click. A hidden element still loads, so by the time its tab is tapped it is already rendered with fresh data.
In a native app: create the WebViews for all your tabs when the affiliate screen opens, not when each tab is tapped, and keep them alive across switches.
The gated elements are safe to pre-load too (withdraw, identity). A gated element only asks for a sign-in code once it has actually been on screen, so one loaded off screen or behind a hidden tab stays silent: it quietly recognises an existing session if there is one, and otherwise waits. Nobody is emailed a code for a screen they did not open.
Showing your own placeholder? Use onReady, on every SDK. Do not use the iframe's load event or the WebView's onLoadEnd / onPageFinished: those fire when the document arrives, which is before the element has fetched its data and laid out, so a spinner removed there uncovers an empty box. onReady fires after layout, and again after any reload (a finished withdrawal, an hourly session refresh), which is when you want the placeholder back anyway. Your appearance and range are re-applied before it fires, so what you uncover is already in your brand.
// Web
affiliateo.mount('balance', '#balance', { onReady: () => setLoading(false) })
// React
<BalanceElement onReady={() => setLoading(false)} />
// React Native / Expo
<BalanceElement fetchClientSecret={mint} onReady={() => setLoading(false)} />
// Swift
controller.onReady = { spinner.stopAnimating() }
// Kotlin
element.onReady = { spinner.isVisible = false }Two related signals a native host can use: the page posts aeElevated when someone types a code and aeRecognized when an element unlocks silently. Reloading your other gated elements on aeElevated makes one code unlock every element on screen. Never reload on aeRecognized: it is a notification, not a refresh signal, and reloading on it puts sibling elements in a loop.
Confirming identity
The components marked above first ask the affiliate to confirm who they are: in place inside the element on native, in a small window on affiliateo.com on the web. They sign in, or we email them a 6-digit code; accounts with two-factor enabled sign in with it instead, so an emailed code alone never moves money. A confirm lasts 60 minutes, and the first code typed on a device is usually the last: after it, an active Affiliateo login is recognised silently, and in a native app one confirm unlocks every gated element on screen.
This is not something you can turn off, and the reason is worth knowing: an Affiliateo wallet is per person, not per app. It holds what they have earned across every program they are in, including your competitors'. Your session is enough to show their numbers for your app; it is deliberately not enough to open the wallet.
That line is also why balance is not on the list. It shows what your app owes them and has paid them, which is the same pending/payable/paid you already get from GET /affiliates?email=. Since the element tells you nothing your API key does not, it renders straight away with no confirm. The account-wide wallet total lives on withdraw, which is where the sign-in stayed.
The window is on our domain so they can see the address bar. Nothing they type there reaches your page, and the credential it produces never enters your JavaScript.
Wording the button
The button says Confirm it's you by default, which on a page somebody opened in order to cash out reads like a security challenge rather than the thing they clicked toward. Two ways to change it, at mint:
// Mint parameter. All four are already translated into 16 languages.
{ "gate_label": "withdraw" } // Withdraw / Retirar / Retirer / 出金
{ "gate_label": "verify" } // Get set up to be paid
{ "gate_label": "get_started" } // Get started
{ "gate_label": "confirm" } // Confirm it's you (the default)
// Pair with lang so the button follows the viewer, not their browser.
{ "gate_label": "withdraw", "lang": "es" } // Retirar
// Or your own words. NOT translated: this exact string is shown to every
// viewer, so a multi-language app looks the phrase up in the viewer's
// language itself and passes that one.
{ "gate_label_text": "Get my money" }Only the button and its heading. The sentence underneath, explaining that this opens an account-wide balance, is always ours. That is what makes a custom label safe: however you word the button, the affiliate still reads an accurate description of what they are authorising. An unknown gate_label is refused with a 400 naming the valid ones, rather than silently leaving the default in place and giving you nothing to debug.
Reach for a preset first, and the reason is translation. All four are already in 16 languages, and paired with lang the button lands in the viewer's. gate_label_text moves that job to you permanently, in every language you ship, which is a real cost to take on for a phrase we already have. Use it when the wording genuinely is not in the list, pass a different string per viewer from your own catalogue, and remember the SDK re-mints hourly, so a language change carries over on the next refresh.
Which preset to send is knowable before the element loads: payout.identity_verified rides every affiliate response, so an affiliate who still needs the ID check can get "verify" and everyone else "withdraw".
One more source people miss: the strings our elements use are published at /locales/{lang}.json in all 16 languages, so you can lift our translations into your own catalogue rather than writing them. That matters most for the screens you build from the REST API, the Pending / Payable / Paid wording and the row types. For this button it is redundant, because a preset already does it.
Worth knowing which way the trade runs here: the hosted withdrawal link is the one with less friction, not the element. It is a real page on our domain, so it sees the affiliate's Affiliateo session and a signed-in person skips the confirm entirely. The element is an iframe on your domain, where that cookie does not travel, so it always costs one tap on the web. Mount the element to keep somebody on your page mid-task. Use the hosted link when leaving is cheap, where you also own the button outright.
On the web's default flow, withdrawing and identity checks then continue in that same window, against the flows we already run. On native, and on web pages that opt in with the { flow: "inline" } mount option, the withdraw element IS the cash-out flow, rendered in place: your app's pending/payable/paid, the withdrawable balance, amount, payout speed with fees shown before confirming, and bank management. An affiliate who still needs the identity check starts it right there, a checkpoint on the way to the money; a verified affiliate lands straight on the form. Either way we never rebuild bank collection or ID capture inside your page: ID photos and selfies are checked in real time and never stored, and bank numbers and SSN go straight to Stripe, so your site never needs camera permission and money data never touches your servers.
Windows open from a tap, never on load, so pop-up blockers leave them alone.
One confirm covers every gated element, and it keeps covering them. Confirming signs the person in on our side, so the other gated elements on the screen recognise that session and open without a second email, and so does the next visit. The step-up itself lasts an hour and lives only in the element's memory; the sign-in behind it outlives that, which is what makes later opens silent.
When that sign-in eventually lapses, the element goes back to asking, and it tells you so: onLocked on the mount options, and the matching callback in each native SDK. You only need it if your app remembers somewhere that the person is signed in, but if you do remember and never clear it, the rest of your screen keeps acting signed in beside a login form. Ours did exactly that.
Styling
33 tokens. Pass them when you mint a session so the first paint is already yours, and call updateAppearance() for anything that changes afterwards.
// Restyle mounted elements in place, e.g. on a dark-mode toggle.
affiliateo.updateAppearance({
colorBackground: '#191919',
colorText: '#eeeeee',
colorBorder: '#2e2e2e',
})Every SDK restyles in place, so a dark-mode switch never has to reload an element and throw away whatever the person was in the middle of. React Native takes an appearance prop, SwiftUI an appearance parameter, UIKit and Kotlin an appearance property. All four re-apply after a reload too, so a finished withdrawal or an hourly session refresh comes back in your brand rather than ours.
Anything we do not recognise is ignored and listed back to you in ignored_appearance_keys, so a typo degrades to the default look instead of failing the call.
colorBackground, colorSurface and buttonColor take a CSS gradient as well as a flat colour, so a brand gradient on the card or the button works, e.g. linear-gradient(135deg, #1754D8, #7C3AED). The rest stay flat colours: they paint text and borders, where a gradient has no meaning.
contentPadding is the element's own inset, and it takes the CSS box shorthand, so 0 16px gives room down the sides and nothing on top. It defaults to none, because on the web your own layout already supplies the margin. In a native app nothing does, so set it or the text lands against the bezel.
All 33 tokens
colorPrimarycolorcolorPrimaryTextcolorcolorBackgroundcolorOrGradientcolorSurfacecolorOrGradientcolorTextcolorcolorTextSecondarycolorcolorBordercolorcolorLinkcolorcolorSuccesscolorcolorWarningcolorcolorDangercolorfontFamilyfontFamilyfontSizeBaselengthfontSizeSmlengthfontSizeLglengthfontWeightNormalfontWeightfontWeightMediumfontWeightfontWeightBoldfontWeightlineHeightnumberletterSpacinglengthborderRadiuslengthborderRadiusSmlengthborderRadiusLglengthborderWidthlengthspacingGaplengthcontentPaddinglengthBoxbuttonColorcolorOrGradientbuttonTextColorcolorbuttonRadiuslengthbuttonHeightlengthbuttonFontSizelengthbuttonFontWeightfontWeightfocusRingColorcolorLayout is not a token, on purpose. These are money surfaces, and a restyled confirm button that no longer reads as one is a mis-click we both pay for. For a different arrangement, mount the components separately and lay them out yourself. That is unlimited freedom over structure with none of the risk inside a component.
Custom fonts
fontFamily alone only names a font, so the browser uses it only when the device already has it. To load your own, pass a fonts array when you mint. Each entry takes a family and an src (an https URL to a .woff2, .woff, .ttf or .otf file), plus optional weight, style and display.
// When you mint the session, on your backend:
{
"email": "alex@example.com",
"components": ["affiliate"],
"allowed_origins": ["https://app.example.com"],
"fonts": [
{ "family": "Inter", "src": "https://cdn.example.com/inter.woff2" },
{ "family": "Inter", "src": "https://cdn.example.com/inter-bold.woff2", "weight": "700" }
],
// Then reference the family. (Set only fonts and the first family is used
// automatically, so this line is optional.)
"appearance": { "fontFamily": "Inter, system-ui, sans-serif" }
}A font loads only from an origin you list here, enforced the same way as allowed_origins, so a leaked session can never be pointed at a new font host. Up to 6 fonts; anything malformed is dropped and counted back in ignored_fonts.
Filtering by date
The affiliate and activity elements come with a date filter: Today, 7 / 30 / 90 days, 1 year, All time, or a custom range. It moves the clicks / sales / earned numbers and the activity feed together, and it is on by default.
Prefer to drive it from your own controls? Hide the built-in filter with { filter: false } and call updateRange():
// affiliate + activity ship with a date filter built in.
// To drive the range from your own controls instead, hide it and set the range:
const el = affiliateo.mount('activity', '#activity', { filter: false })
affiliateo.updateRange({ from: '2026-07-01', to: '2026-07-31' })
affiliateo.updateRange(null) // back to all-timeThe native SDKs take the same range. Note the three-way distinction they all draw: not driving the range at all (our filter stays in charge) is different from driving it and asking for all-time, and a plain null can only say one of those.
// React Native / Expo — declarative. undefined leaves our filter in charge.
<ActivityElement fetchClientSecret={mint} filter={false}
range={{ from: '2026-07-01', to: '2026-07-31' }} />
<ActivityElement fetchClientSecret={mint} filter={false} range={null} /> // all-time
// SwiftUI
AffiliateoElementView(component: .activity,
range: .range(AffiliateoDateRange(from: "2026-07-01", to: "2026-07-31")))
AffiliateoElementView(component: .activity, range: .allTime)
// UIKit
controller.updateRange(AffiliateoDateRange(from: "2026-07-01", to: "2026-07-31"))
controller.updateRange(nil) // all-time
// Kotlin
element.updateRange(AffiliateoDateRange("2026-07-01", "2026-07-31"))
element.updateRange(null) // all-timeOnly affiliate and activity have a time axis; the other elements ignore the range. Either way the element re-reads only its own data for the window, so a filter grants no access the session did not already have.
Who can show your elements
allowed_origins is the list of pages permitted to display them. Exact origins, up to 10, no paths and no wildcards. Anywhere else, the browser refuses to render the frame at all, so nobody can lift your elements onto their own site.
Use http://localhost:3000 while developing. Plain http is only accepted for localhost.
If something is not showing
- Blank frame. Your page's origin is probably not in
allowed_origins. The browser console will say the frame was refused. - "Session expired". Sessions last an hour. Check
fetchClientSecretmints a fresh one each time rather than returning a cached string. - "Not enabled". That component was not in
componentswhen you minted. - "Not an affiliate yet". The email is not enrolled in this app. Elements never enrol anyone.
- Confirmation window blocked. Something is calling it outside a tap, or the browser is blocking pop-ups for your site.
Next
The playground for styling, or the API reference for everything the REST endpoints expose.