
icRamp Devlog #15 — Stripe Order Payments (Email↔️Connect, per‑order redirects)
Continuation of Devlog #14 — Stripe Frontend (Register & Checkout UX). There we shipped Express onboarding + frontend wiring for providers. In this post we finished the order payment flow:
- Onramper pays with card (identified by Email provider).
- Offramper receives funds on their Stripe Connect account (destination charges).
- Per‑order success/cancel URLs (no more static
example.com), and payer email is seeded + verified on return.
TL;DR — What changed
- Per‑order redirects: the FE builds
success_url/cancel_urlas/view?stripe=success&order_id=…and sends them to the backend during lock. - Email as payer identity: Onramper must have
PaymentProvider::Email. We seedcustomer_email+receipt_emailin the Checkout Session and verify that email on our side. - Stripe platform state simplified: removed static success/cancel from canister state. Kept
{ label, api_url, publishable_key, secret_key }. - LockedOrder now carries
payment_id+payment_url. FE renders Pay by Card (Stripe) for Email‑based Onrampers. - FE redirect effect: upon returning from Stripe, the hook reads
?stripe=success&order_id=…, verifies only that order, and cleans the URL.
Data model (recap)
We enforce the split:
- Offramper ↔️
PaymentProvider::Stripe { account_id, platform }(Connect). - Onramper ↔️
PaymentProvider::Email { email }(payer identity).
#[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
Email { email: String }, // Onramper
}Backend — changes & snippets
1) Stripe platform config (state)
Removed static success/cancel; we now pass per‑order URLs from FE.
#[derive(Clone, CandidType, Deserialize)]
pub struct StripePlatformConfig {
pub label: String,
pub api_url: String,
pub publishable_key: String,
pub secret_key: String,
}Install/upgrade (excerpt):
# kept only label/api_url/pk/sk per platform
# (DFX arg excerpt from my deploy script)2) CheckoutSession typing — capture payer email
#[derive(Debug, Deserialize, Serialize)]
pub struct CustomerDetails { pub email: Option<String> }
#[derive(Debug, Deserialize, Serialize)]
pub struct CheckoutSession {
pub id: String,
pub url: Option<String>,
pub payment_status: Option<String>,
pub payment_intent: Option<serde_json::Value>, // expanded when requested
pub customer_details: Option<CustomerDetails>, // NEW
pub customer_email: Option<String>, // NEW
}3) Lock: pass per‑order redirects + seed payer email
Canister method signature (public):
#[ic_cdk::update]
async fn lock_order(
order_id: u64,
session_token: String,
onramper_user_id: u64,
onramper_provider: PaymentProvider,
onramper_address: TransactionAddress,
stripe_success_url: Option<String>, // NEW
stripe_cancel_url: Option<String>, // NEW
) -> Result<()> { /* … forwards … */ }Manager logic (excerpt):
let stripe_session: Option<(String, String)> = if on_is_email {
let (acct, platform) = order.offramper_providers.iter().find_map(|p| {
if let PaymentProvider::Stripe { account_id, platform } = p.1 {
Some((account_id.clone(), platform.clone()))
} else { None }
}).ok_or(OrderError::InvalidOnramperProvider)?;
let amount_minor = price + offramper_fee;
let payer_email = if let PaymentProvider::Email { email } = onramper_provider.clone() { email } else { return Err(OrderError::InvalidOnramperProvider)?; };
let (sid, url) = create_checkout_session_for_order(
order_id,
&acct,
amount_minor,
&order.currency,
Some(platform),
stripe_success_url.ok_or_else(|| OrderError::InvalidInput("Stripe success_url should be present".into()))?,
stripe_cancel_url .ok_or_else(|| OrderError::InvalidInput("Stripe cancel_url should be present".into()))?,
payer_email,
).await?;
Some((sid, url))
} else { None };Session creation now accepts required per‑order URLs + payer email and seeds both customer_email and receipt_email:
pub async fn create_checkout_session_for_order(
order_id: u64,
offramper_acct: &str,
amount_minor: u64,
currency: &str,
platform_label: Option<String>,
success_url: String,
cancel_url: String,
payer_email: String,
) -> Result<(String, String)> {
let p = pick_platform(platform_label)?;
let proxy_url = read_state(|s| s.proxy_url.clone());
let cur = currency.to_lowercase();
let email_q = format!(
"&customer_email={ce}&payment_intent_data[receipt_email]={re}",
ce = pct_encode(&payer_email),
re = pct_encode(&payer_email),
);
let body = format!(
"mode=payment\
&success_url={success}\
&cancel_url={cancel}\
&line_items[0][quantity]=1\
&line_items[0][price_data][currency]={cur}\
&line_items[0][price_data][unit_amount]={amt}\
&line_items[0][price_data][product_data][name]=Order%20{oid}\
&payment_intent_data[transfer_data][destination]={dest}\
{email_q}",
success = pct_encode(&success_url),
cancel = pct_encode(&cancel_url),
cur = cur,
amt = amount_minor,
oid = order_id,
dest = offramper_acct,
email_q = email_q,
);
// http_request(..) unchanged; parse id/url as before
}4) Verify: destination + amount + currency + payer email
We reused the already‑implemented session verify function and extended it to check the payer’s email:
pub async fn verify_session_paid_destination(
session_id: &str,
expected_minor: i64,
expected_currency_upper: &str,
expected_destination: &str,
platform_label: Option<String>,
onramper_email: &str, // NEW
) -> Result<bool> {
let s = retrieve_session(session_id, true, platform_label).await?;
// … paid + PI.succeeded + currency + destination + amount_received >= expected_minor …
let payer_email = s.customer_details.as_ref().and_then(|d| d.email.as_deref())
.or(s.customer_email.as_deref())
.or_else(|| s.payment_intent.as_ref()
.and_then(|pi| pi.pointer("/charges/data/0/billing_details/email"))
.and_then(|v| v.as_str()))
.unwrap_or("");
if !payer_email.trim().eq_ignore_ascii_case(onramper_email.trim()) {
return Err(OrderError::PaymentVerificationFailed)?;
}
Ok(true)
}And in the transaction processing we call it for the Email provider branch:
match &order.clone().onramper.provider {
// … PayPal / Revolut …
PaymentProvider::Email { email } => {
let session_id = order.payment_id.clone().ok_or(OrderError::PaymentVerificationFailed)?;
let platform = order.base.offramper_providers.iter().find_map(|p| {
if let PaymentProvider::Stripe { platform, .. } = p.1 { Some(platform.clone()) } else { None }
}).ok_or(OrderError::PaymentVerificationFailed)?;
let expected_minor = (order.price + order.offramper_fee) as i64;
let stripe_account_id = order.base.offramper_providers.get(&PaymentProviderType::Stripe)
.and_then(|provider| if let PaymentProvider::Stripe { account_id, .. } = provider { Some(account_id) } else { None })
.ok_or_else(|| OrderError::InvalidOfframperProvider)?;
verify_session_paid_destination(
&session_id,
expected_minor,
&order.base.currency,
&stripe_account_id,
Some(platform),
email,
).await?;
}
PaymentProvider::Stripe { .. } => return Err(OrderError::InvalidOnramperProvider.into()),
}5) Deploy
dfx deploy icramp_backend --argument "( variant { Upgrade = null } )" --upgrade-unchangedFrontend — changes & snippets
1) Lock → pass per‑order URLs
// useOrderLogic.ts
const onIsEmail = 'Email' in provider;
const base = `${window.location.origin}/view`;
const successUrl = `${base}?stripe=success&order_id=${orderId}`;
const cancelUrl = `${base}?stripe=cancel&order_id=${orderId}`;
const result = await backend.lock_order(
orderId,
sessionToken,
user.id,
provider,
onramperAddress,
onIsEmail ? [successUrl] : [],
onIsEmail ? [cancelUrl] : [],
);2) Render Stripe button in Locked orders (Email onramper)
// OrderCard.tsx (inside Locked + onramper==Email branch)
<button
className={`px-4 py-2 bg-purple-700 rounded-md hover:bg-purple-800 ${disabled ? "cursor-not-allowed opacity-70" : ""}`}
onClick={handleStripePay}
disabled={disabled}
>
Pay by Card (Stripe)
</button>// useOrderLogic.ts
const handleStripePay = () => {
if (!('Locked' in orderState)) return;
const payUrl = orderState.Locked?.payment_url?.[0] ?? orderState.Locked?.payment_url;
if (!payUrl) { setMessage('Stripe payment link not available'); return; }
setIsLoading(true);
setLoadingMessage('Redirecting to Stripe Checkout…');
window.location.href = payUrl as string;
};3) Verify on return (hook effect)
- Listens to
?stripe=success&order_id=…. - Verifies only the matching card (
order_id === card.id). - Uses a ref to avoid double‑fire across re-renders, then cleans the URL.
useEffect(() => {
if (!('Locked' in orderState) || !sessionToken || !user) return;
const thisOrderId = orderState.Locked.base.id;
const redirectStatus = (searchParams.get('redirect_status') || '').toLowerCase();
const stripeFlag = (searchParams.get('stripe') || searchParams.get('stripe_status') || '').toLowerCase();
const spOrder = searchParams.get('order_id');
const successHit = stripeFlag === 'success' || redirectStatus === 'succeeded';
const matchesThisCard = !!spOrder && !!thisOrderId && BigInt(spOrder) === BigInt(thisOrderId);
if (!(successHit && matchesThisCard)) return;
if (processedStripeReturnRef.current === String(thisOrderId)) return;
processedStripeReturnRef.current = String(thisOrderId);
(async () => {
try {
setIsLoading(true);
setLoadingMessage('Payment received. Verifying');
const resp = await backend.verify_transaction(thisOrderId, [sessionToken], 'stripe');
if ('Ok' in resp) {
// EVM/BTC special cases; else mark released and refetch
setLoadingMessage('Funds released');
setTimeout(() => {
fetchOrder(thisOrderId);
refetchUser();
setIsLoading(false);
fetchBalances();
navigate('/view?status=Completed');
}, 2500);
} else {
setIsLoading(false);
setMessage(rampErrorToString(resp.Err));
}
} catch (e) {
setIsLoading(false);
setMessage('Error verifying Stripe payment.');
console.error(e);
} finally {
// Clean query so other cards won't react
navigate(location.pathname, { replace: true });
}
})();
}, [orderState, sessionToken, searchParams, orderId]);Note: I kept the verification in the hook (card‑scoped). A route‑level guard also works; if added, ensure only one runs by matching
order_idand cleaning the URL immediately.
E2E Test (Solana devnet)
- Offramper onboarded via Express (validated capabilities), created two orders.
- Onramper with
Emailprovider locked the order → FE got apayment_urland showed Pay by Card (Stripe). - Paid on Stripe; returned to
/view?stripe=success&order_id=…; hook verified → order moved to Completed.
Locked order with the new Stripe button

Stripe Checkout on devnet

Back to the Orders page

Stripe JSON sanity check
stripe_test_retrieve_session("cs_test_…", true, null)
→ payment_status: "paid"
→ PI.status: "succeeded"
→ PI.transfer_data.destination: "acct_…"
→ amount_received == expected_minor
→ customer_details.email == onramper.emailDeployment
- Upgraded canister after removing static URLs from platform state and adding the new lock signature:
dfx deploy icramp_backend --argument "( variant { Upgrade = null } )" --upgrade-unchanged- Regenerated frontend declarations.
- Redeployed FE and tested the full round‑trip.
Gotchas & notes
- Always pass currency correctly (I initially paid USD with an "EUR" mental model during manual tests; the verifier upper‑cases the PI currency).
- Seed payer email at session creation; otherwise Stripe might not persist it in all flows.
- Clean the URL after verify so other cards don’t react to
?stripe=success.
Next
- UI polish: show brand + last4 in the success toast.
- Webhooks (optional) for idempotent postbacks if we ever need async recovery.
- Add a tiny route guard as belt‑and‑suspenders if we expand the orders view to infinite scroll.
Stay Updated
Get notified when I publish new articles about Web3 development, hackathon experiences, and cryptography insights.
You might also like

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 #14 — Stripe Frontend (Register & Checkout UX)
Frontend wiring for Stripe Connect: a register flow that survives redirect/refresh, provider cards UI, and Create Order with destination charges. Includes the Onramper≠Offramper split and backend-validated providers.

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.