icRamp Devlog #15 — Stripe Order Payments (Email↔️Connect, per‑order redirects)

icRamp Devlog #15 — Stripe Order Payments (Email↔️Connect, per‑order redirects)

10/22/20257 min • icramp
ICPStripeConnectCheckoutBackendFrontendRustReact

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_url as /view?stripe=success&order_id=… and sends them to the backend during lock.
  • Email as payer identity: Onramper must have PaymentProvider::Email. We seed customer_email + receipt_email in 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-unchanged

Frontend — 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_id and cleaning the URL immediately.

E2E Test (Solana devnet)

  • Offramper onboarded via Express (validated capabilities), created two orders.
  • Onramper with Email provider locked the order → FE got a payment_url and 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

Locked order with Stripe button

Stripe Checkout on devnet

Stripe Checkout payment page

Back to the Orders page

Verifying Stripe Payment

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.email

Deployment

  • 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.