
icRamp Devlog #14 — Stripe Frontend (Register & Checkout UX)
Follows [Devlog #13 — Stripe Backend (Connect + Checkout)]: there we shipped multi-platform Stripe config, Express onboarding, and Checkout Sessions from the canister. This post is the UI + wiring: a resilient register flow that survives the Stripe redirect, cleaner provider cards, and a Create Order screen that uses destination charges. It also introduces the Onramper vs Offramper split.
TL;DR — What changed
- Single hook to start onboarding:
startStripeKyc(...). It stores temp user only when needed (register page), and redirects to Stripe. - After redirect back:
- Register restores
loginMethod,userType,providersfrom localStorage and appends the new Stripe provider from query (stripe_acct,platform). - Profile finalizes by calling
add_user_payment_providerand then cleans the URL.
- Register restores
- Backend validates providers (Stripe capabilities, email format) and enforces role rules:
- Stripe only for Offrampers.
- Email provider only for Onrampers.
- Onramper vs Offramper:
- Offramper links Stripe (connected account) and receives destination charges.
- Onramper does not link Stripe; they provide an Email provider (payer identity) used later to verify the payer.
- UI: New provider cards (selectable, readable), centered onboarding CTA with spinner on the right, success notice in green under the cards (not in the error slot).
- Create Order: Provider list is now the same card style; selecting Stripe means “pay the offramper via destination charge”.
Onramper ≠ Offramper (data model)
We extended the backend (recap of the delta since #13) and modified the Stripe enum, also added a new one: Email.
#[derive(CandidType, Deserialize, Clone, Debug, Eq, PartialEq, Hash)]
pub enum PaymentProviderType { PayPal, Revolut, Stripe, Email }
#[derive(CandidType, Deserialize, Clone, Debug, Eq)]
pub enum PaymentProvider {
PayPal { id: String },
Revolut { scheme: String, id: String, name: Option<String> },
Stripe { account_id: String, platform: String }, // Offramper only
Email { email: String }, // Onramper only
}- Offramper: can link
Stripe{account_id, platform}(validated server-side). - Onramper: provides
Email{email}to later verify that the payer matches.
Validation happens server-side:
impl PaymentProvider {
pub async fn validate(&self) -> Result<()> {
match self {
PaymentProvider::Stripe { account_id, platform } => {
// ensure "acct_" and platform exists, then hit Stripe to check
verify_stripe_acct(account_id, platform).await?;
}
PaymentProvider::Email { email } => validate_email(email)?,
// ... PayPal/Revolut sanity checks ...
}
Ok(())
}
}This makes the frontend simpler and safer: FE never needs Stripe secrets nor capability checks.
The Stripe KYC hook
We keep it minimal and typed. It picks the right actor, optionally persists temp user (register only), creates the Express account, builds the account-link, and redirects.
// hooks/useStripeKyc.ts
import { ActorSubclass } from '@dfinity/agent';
import {
_SERVICE,
LoginAddress,
PaymentProvider,
} from '@/declarations/icramp_backend/icramp_backend.did';
import { backend } from '@/model/backendProxy';
import { storeTempUserData } from '@/model/emailConfirmation';
import { rampErrorToString } from '@/model/helpers/error';
import { mapCountryToPlatform } from '@/utils/stripe';
type StartArgs = {
userType: 'Offramper' | 'Onramper' | 'Visitor';
loginMethod: LoginAddress | null;
backendActor?: ActorSubclass<_SERVICE> | null;
email: string; // providerId
country: string; // 'ES', 'US', ...
providers?: PaymentProvider[]; // only needed in Register page (to store temp)
password?: string; // only needed in Register page
storeUserData: boolean;
setMessage: (s: string) => void;
setLoading: (b: boolean) => void;
};
export async function startStripeKyc({
userType,
loginMethod,
backendActor,
email,
country,
providers,
password,
storeUserData,
setMessage,
setLoading,
}: StartArgs) {
setMessage('');
if (userType !== 'Offramper') {
setMessage('Stripe is only used for Offrampers.');
return;
}
if (!loginMethod) {
setMessage('Please login first.');
return;
}
if (!email || !country) {
setMessage('Email and country are required for Stripe onboarding.');
return;
}
const actor =
'ICP' in (loginMethod ?? {}) && backendActor ? backendActor : backend;
// optional (Register): preserve temp state for post-redirect
if (storeUserData) {
storeTempUserData({
providers: providers ?? [],
userType,
loginMethod,
password: password ?? '',
confirmationToken: '',
});
}
setLoading(true);
const platform = mapCountryToPlatform(country);
try {
const r1 = await actor.stripe_create_express_account(email, country, [
platform,
]);
if ('Err' in r1) {
setMessage(`Stripe create account failed: ${rampErrorToString(r1.Err)}`);
return;
}
const acct = r1.Ok;
const origin = window.location.origin;
const returnUrl = `${origin}${
window.location.pathname
}?stripe_acct=${encodeURIComponent(acct)}&platform=${encodeURIComponent(
platform,
)}`;
const refreshUrl = `${origin}${
window.location.pathname
}?refresh=1&stripe_acct=${encodeURIComponent(
acct,
)}&platform=${encodeURIComponent(platform)}`;
const r2 = await actor.stripe_create_account_link(
acct,
refreshUrl,
returnUrl,
[platform],
);
if ('Err' in r2) {
setMessage(`Stripe account link failed: ${rampErrorToString(r2.Err)}`);
return;
}
window.location.href = r2.Ok;
} catch (e: any) {
setMessage(`Stripe onboarding failed: ${e?.message ?? String(e)}`);
} finally {
setLoading(false);
}
}Where we introduce a minimal helper to map countries to platforms. We will expand this latter as we support more regions.
// utils/strip.ts
export function mapCountryToPlatform(country: string): 'US' | 'ES' {
return (country || '').toUpperCase() === 'US' ? 'US' : 'ES';
}Register — survive redirect, then submit
1) Persist “in-flight” state before leaving for Stripe
We store the whole context right before opening the onboarding URL:
// before redirecting to Stripe
storeTempUserData({
providers, // whatever the user added so far
userType, // Onramper | Offramper
loginMethod, // EVM | ICP | ... (from Connect Address)
password: password ?? '',
confirmationToken: '',
});2) Start onboarding
const startStripeOnboarding = async () => {
await startStripeKyc({
userType,
loginMethod,
backendActor,
email: providerId,
country: stripeCountry,
providers,
password: password ?? undefined,
storeUserData: true,
setMessage,
setLoading: setLoadingStripe,
});
};3) Restore after returning from Stripe
On the /register?stripe_acct=...&platform=... route we restore the data and append the connected account provider. If a mismatch is detected (different login), we clear the temp payload.
const restoreTempData = () => {
const t = getTempUserData();
const acct = searchParams.get('stripe_acct');
if (t && acct) {
if (t.providers?.length) setProviders(t.providers);
if (t.userType) setUserType(t.userType);
if (t.loginMethod) setLoginMethod(t.loginMethod, t.password ?? null);
return true;
} else if (t && t.loginMethod && loginMethod && t.loginMethod !== loginMethod) {
clearTempUserData();
}
return false;
};
useEffect(() => {
restoreTempData();
const acct = searchParams.get('stripe_acct');
const platform = searchParams.get('platform');
if (!acct || !platform) return;
(async () => {
try {
setProviders((prev) => {
const next = prev.some((p) => 'Stripe' in p && p.Stripe.account_id === acct)
? prev
: [...prev, { Stripe: { account_id: acct, platform: mapCountryToPlatform(stripeCountry) } }];
const t = getTempUserData();
if (t) storeTempUserData({ ...t, providers: next });
return next;
});
setStripeReady(true);
setStripeInfoMsg('Stripe account added.');
} catch (e) {
setMessage(`Failed to verify Stripe account: ${e}`);
}
})();
}, [searchParams]);4) Provider type visibility (per role)
const visibleProviderTypes = useMemo<PaymentProviderTypes[]>(() =>
userType === 'Offramper'
? ['PayPal', 'Revolut', 'Stripe']
: ['PayPal', 'Revolut', 'Email']
, [userType]);
// If switching to Onramper, drop any previously added Stripe
useEffect(() => {
if (userType === 'Onramper') {
setProviders(ps => ps.filter(p => !('Stripe' in p)));
}
}, [userType]);5) Clean submit with backend validation
On submit, if we just returned from Stripe and the UI hasn’t yet reinstated loginMethod, we restore it from temp (single-shot) and proceed. The canister performs the heavy checks:
const handleSubmit = async () => {
if (providers.length === 0) {
setMessage('Please add at least one payment provider.');
return;
}
let login: LoginAddress;
const t = getTempUserData();
const acct = searchParams.get('stripe_acct');
if (t && acct && !loginMethod) {
setLoginMethod(t.loginMethod, t.password ?? null);
login = t.loginMethod;
} else if (!loginMethod) {
console.log('no login method');
navigate("/")
return;
} else {
login = loginMethod;
}
if ('Email' in login) {
await handleEmailConfirmation();
return;
}
let tmpActor = backend;
if ('ICP' in login) {
if (!backendActor) {
setMessage("Internet Identity not loaded with backend actor")
return;
}
tmpActor = backendActor;
}
setIsLoading(true);
try {
let result = await tmpActor.register_user(stringToUserType(userType), providers, login, []);
if ('Err' in result) {
setGlobalUser(null);
setMessage(`Could not register user: ${rampErrorToString(result.Err)}`)
}
if ('Ok' in result) {
navigate('/?auth=true');
}
} catch (error) {
setMessage(`Failed to register user: ${error}`);
} finally {
setIsLoading(false);
}
};User Profile — Add Stripe and Email providers
Visible providers
- Offramper →
Stripe/PayPal/Revolut - Onramper →
Email/PayPal/Revolut
Start onboarding from Profile
const startStripeOnboardingProfile = async () => {
await startStripeKyc({
userType: userTypeToString(user.user_type) as 'Offramper' | 'Onramper',
loginMethod,
backendActor,
email: providerId,
country: stripeCountry,
storeUserData: false,
setMessage,
setLoading: setLoadingStripe,
});
};Handle return on Profile
useEffect(() => {
const acct = searchParams.get('stripe_acct');
const platform = searchParams.get('platform') ?? mapCountryToPlatform(stripeCountry);
if (!acct || !platform || !sessionToken) return;
setLoadingAddProvider(true);
(async () => {
try {
const provider = { Stripe: { account_id: acct, platform } };
const r = await backend.add_user_payment_provider(
user.id,
sessionToken,
provider,
);
if ('Err' in r) {
setMessage(`Failed to add Stripe: ${rampErrorToString(r.Err)}`);
return;
}
await refetchUser();
} catch (e: any) {
setMessage(`Failed to finalize Stripe: ${e?.message ?? String(e)}`);
} finally {
setLoadingAddProvider(false);
window.history.replaceState({}, '', window.location.pathname);
}
})();
}, [
searchParams,
stripeCountry,
user,
sessionToken,
backend,
refetchUser,
setMessage,
]);UI
- Provider cards: PayPal/Revolut in gray/blue, Stripe in purple, Email in gray.
- Stripe CTA: centered label, spinner on right, disabled while loading or once ready.
- Success messages appear below the cards in green; red banner is errors only.
Provider cards
The list is now cards, not raw list items: readable, scannable, and consistent across light/dark.
<div className="mt-4 grid grid-cols-1 gap-3">
{providers.map((p, i) => (
<div key={i}
className={cn(
"rounded-lg border p-3",
"'Stripe' in p ? 'border-purple-500/50 bg-purple-500/10' : 'border-gray-500/40 bg-gray-300/40 dark:bg-gray-800/60'"
)}
>
{/* title + details */}
</div>
))}
</div>This adds a more modern and clean look, both in /register and /profile pages:


Onboarding CTA
Centered label, spinner on the right, and disabled state while loading or once ready.
<div
className={clsx(
"relative w-full flex items-center justify-center px-4 py-2 rounded-md",
"bg-purple-600 dark:bg-purple-800 font-semibold",
(loadingStripe || stripeReady) ? "opacity-60 cursor-not-allowed"
: "cursor-pointer hover:bg-purple-500 dark:hover:bg-purple-900"
)}
onClick={() => !isLoading && startStripeOnboarding()}
>
<span className="text-lg pointer-events-none">
{loadingStripe ? <>Setting up Stripe<DynamicDots isLoading /></>
: stripeReady ? "Stripe Ready"
: "Start Stripe Onboarding"}
</span>
{loadingStripe && (
<div className="absolute right-3 w-4 h-4 border-t-2 border-b-2 border-purple-700 rounded-full animate-spin" />
)}
</div>Success notice placement
Success feedback for Stripe lives below the cards in green; the red banner at the bottom remains reserved for errors only.
Create Order — provider selection, but pretty
The “Payment Providers” block now mirrors the register cards with a selectable card/checkbox pattern.
- Stripe card = “pay offramper via destination charge”
- Revolut/PayPal cards show their identifiers (truncated monospace)
- Selected state uses a subtle indigo outline/badge
Flow recap
- Offramper registers and links Stripe (Express). We persist progress pre-redirect and restore on return; the backend validates the connected account.
- Onramper registers and supplies Email (no Stripe onboarding at all).
- Order creation: onramper chooses amount + chain and selects a provider:
- If Stripe is selected for the offramper, the backend creates a Checkout Session with
payment_intent_data[transfer_data][destination]=acct_…. - FE opens the
session_url; success route returnssession_id.
- If Stripe is selected for the offramper, the backend creates a Checkout Session with
- Verification: FE calls backend to verify session → backend retrieves session (expanded PI), checks:
payment_status=paidPI.status=succeededcurrency&amount_receivedmatchtransfer_data.destination == offramper.acct
- Release: vault sends crypto to the onramper per the order, and the order is closed.
Edge cases & guardrails we handled
- Page refresh after Stripe: all state is restored from
tempUserData. If the restoredloginMethodmismatches the active one, we clear the temp payload. - Role toggles: switching to Onramper automatically removes Stripe providers from the list.
- No FE secrets: the frontend never touches Stripe secret keys nor tries to infer capabilities; all checks live in the canister.
- Idempotency: FE/BE both use idempotency keys where relevant (see backend post).
What’s next
- Order lock + session lifecycle UI (timer, cancel/replace, retry).
- Application fees (platform revenue): pass
application_fee_amountin Checkout. - Email payer match for Onramper: record and verify payer email post-payment.
- Docs + demo: a short clip of the full flow (register → link Stripe → create order → pay → release).
Stay Updated
Get notified when I publish new articles about Web3 development, hackathon experiences, and cryptography insights.
You might also like

icRamp Devlog #15 — Stripe Order Payments (Email↔️Connect, per‑order redirects)
End-to-end Stripe payments for orders: Onramper pays by email, Offramper receives via Connect destination charges. Per‑order success/cancel, email verification, and a resilient FE redirect flow.

icRamp Devlog #16 — Liquid Orders: Top‑ups + Provider Icons
Added liquid (top‑up) orders and unified provider icons across the app. Safe processing lock on the backend, fee recomputation on the new total, and a polished top‑up UI with available-balance max.

icRamp Devlog #13 — Stripe Backend (Connect + Checkout)
Starting Milestone 2: bringing Stripe Connect (destination charges) into icRamp with IPv6-friendly HTTPS outcalls, multi-region key routing, and Candid-first tests.