icRamp Devlog #13 — Stripe Backend (Connect + Checkout)

icRamp Devlog #13 — Stripe Backend (Connect + Checkout)

10/15/202510 min • icramp
ICPStripeConnectCheckoutP2PCanisters

Milestone 2: Payment Layer Overhaul & Vault Refactor. Targets: Stripe + alternative payment methods, crypto payments (BTC/ETH/ICP/stables), cleaner EVM via ic-alloy, unified vaults, and liquid orders (partial fills/top-ups). This post covers Step 1: Stripe Connect integration on the backend canister—Express accounts, destination charges via Checkout Sessions, and Candid tests.

Why Stripe (and why Connect)?

icRamp is a P2P on/off-ramp. The buyer pays the seller, not the platform. Stripe Connect matches this: we onboard each offramper as a connected account and create destination charges, so funds settle to their account (and we can set an application_fee_amount later).

Key constraints we solved:

  • IPv6 vs IPv4: ICP HTTPS outcalls need a proxy for IPv4-only APIs. We reuse our proxy pattern (like PayPal), forwarding x-forwarded-host.
  • Region routing: Stripe is country-sensitive. We support multiple platforms (US, ES, …) and pick the right keypair per order.
  • Security: No client secrets in FE. All Stripe secret keys live in the canister state.

New config & state (init + storage)

We extended InitArg and state to include multi-platform Stripe:

// NEW
#[derive(CandidType, Deserialize, Clone, Debug)]
pub struct StripePlatformConfig {
    pub label: String,           // e.g. "ES", "US"
    pub api_url: String,         // "api.stripe.com"
    pub publishable_key: String, // pk_test_...
    pub secret_key: String,      // sk_test_... (kept server-side)
    pub success_url: String,     // FE route with {CHECKOUT_SESSION_ID}
    pub cancel_url: String,      // FE cancel route
}
 
#[derive(CandidType, Deserialize, Clone, Debug)]
pub struct StripeConfig {
    pub default_platform: String,           // e.g. "ES"
    pub platforms: Vec<StripePlatformConfig>
}
 
// ---------- state ----------
#[derive(Clone, CandidType, Deserialize, Debug)]
pub struct StripePlatformState {
    pub label: String,
    pub api_url: String,
    pub publishable_key: String,
    pub secret_key: String,
    pub success_url: String,
    pub cancel_url: String,
}
 
#[derive(Clone, CandidType, Deserialize, Debug)]
pub struct StripeState {
    pub default_platform: String,
    pub platforms: std::collections::HashMap<String, StripePlatformState>, // label -> platform
    pub platform_labels: Vec<String>, // useful for debugging / metrics
}
 
// added to InitArg
#[derive(CandidType, Deserialize, Clone, Debug)]
pub struct InitArg {
    pub canister_ids: CanisterIdsConfig,
    pub chains: Vec<ChainConfig>,
    pub ecdsa_key_id: EcdsaKeyId,
    pub paypal: PaypalConfig,
    pub stripe: StripeConfig,              // NEW
    pub revolut: RevolutConfig,
    pub proxy_url: String,
    pub ordiscan: OrdiscanConfig,
    pub unisat: UnisatConfig,
}
 
// added to State
#[derive(Clone, CandidType, Deserialize)]
pub struct State {
    pub canister_ids: CanisterIds,
    pub chains: std::collections::HashMap<u64, ChainState>,
    pub ecdsa_pub_key: Option<Vec<u8>>,
    pub ecdsa_key_id: EcdsaKeyId,
    pub evm_address: Option<String>,
    pub paypal: PayPalState,
    pub stripe: StripeState,               // NEW
    pub revolut: RevolutState,
    pub proxy_url: String,
    pub ordiscan: OrdiscanState,
    pub unisat: UnisatState,
    pub icp_tokens: std::collections::HashMap<Principal, IcpToken>,
}

And a tiny init mapping:

// map Config -> State
fn stripe_state_from(cfg: &StripeConfig) -> StripeState {
    use std::collections::HashMap;
    let mut platforms = HashMap::new();
    let mut labels = Vec::new();
    for p in &cfg.platforms {
        platforms.insert(
            p.label.clone(),
            StripePlatformState {
                label: p.label.clone(),
                api_url: p.api_url.clone(),
                publishable_key: p.publishable_key.clone(),
                secret_key: p.secret_key.clone(),
                success_url: p.success_url.clone(),
                cancel_url: p.cancel_url.clone(),
            },
        );
        labels.push(p.label.clone());
    }
    StripeState { default_platform: cfg.default_platform.clone(), platforms, platform_labels: labels }
}

Provider model: adding Stripe

We extended the user providers to store the offramper’s Stripe account:

#[derive(CandidType, Deserialize, Clone, Debug, Eq, PartialEq, Hash)]
pub enum PaymentProviderType {
    PayPal,
    Revolut,
    Stripe, // NEW
}
 
#[derive(CandidType, Deserialize, Clone, Debug, Eq)]
pub enum PaymentProvider {
    PayPal { id: String },
    Revolut { scheme: String, id: String, name: Option<String> },
    // NEW: the connected account + the platform label we will target (key routing)
    Stripe { account_id: String, platform_label: String },
}
 
impl PaymentProvider {
    pub fn provider_type(&self) -> PaymentProviderType {
        match self {
            PaymentProvider::PayPal { .. } => PaymentProviderType::PayPal,
            PaymentProvider::Revolut { .. } => PaymentProviderType::Revolut,
            PaymentProvider::Stripe { .. } => PaymentProviderType::Stripe, // NEW
        }
    }
 
    pub fn validate(&self) -> Result<()> {
        match self {
            PaymentProvider::PayPal { id } => {
                if id.is_empty() { Err(SystemError::InvalidInput("Paypal ID is empty".into()))? }
            }
            PaymentProvider::Revolut { scheme, id, .. } => {
                if scheme.is_empty() || id.is_empty() {
                    Err(SystemError::InvalidInput("Revolut details are empty".into()))?
                }
            }
            PaymentProvider::Stripe { account_id, platform_label } => {
                if account_id.is_empty() || platform_label.is_empty() {
                    Err(SystemError::InvalidInput("Stripe account/platform missing".into()))?
                }
            }
        }
        Ok(())
    }
}

This lets an offramper link their acct_… (via onboarding) and pin a platform (ES, US, …) for charge creation.

Stripe HTTP outcalls (core utilities)

All outcalls reuse the proxy and forward host without scheme (api.stripe.com), which fixed IPv6 proxy quirks.

// helpers
fn forwarded_host(api_url: &str) -> String {
    let s = api_url.trim().trim_start_matches("https://").trim_start_matches("http://");
    s.split('/').next().unwrap_or(s).to_string()
}
 
fn pct_encode(s: &str) -> String {
    s.as_bytes().iter().flat_map(|&b| match b {
        b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => vec![b as char],
        _ => {
            let hex = format!("{:02X}", b);
            vec!['%', hex.chars().nth(0).unwrap(), hex.chars().nth(1).unwrap()]
        }
    }).collect()
}
 
fn auth_headers(sk: &str, api_url: &str, idem: &str) -> Vec<HttpHeader> {
    vec![
        HttpHeader { name: "Content-Type".into(), value: "application/x-www-form-urlencoded".into() },
        HttpHeader { name: "Authorization".into(), value: format!("Bearer {}", sk) },
        HttpHeader { name: "x-forwarded-host".into(), value: forwarded_host(api_url) },
        HttpHeader { name: "Accept".into(), value: "application/json".into() },
        HttpHeader { name: "idempotency-key".into(), value: idem.into() },
    ]
}
 
fn pick_platform(label: Option<String>) -> Result<StripePlatformState> {
    crate::model::memory::heap::read_state(|s| {
        let lbl = label.unwrap_or_else(|| s.stripe.default_platform.clone());
        s.stripe.platforms.get(&lbl)
            .cloned()
            .ok_or(SystemError::InvalidInput(format!("Unknown Stripe platform label: {}", lbl)).into())
    })
}

Onboarding (Express)

// Create connected account (Express)
pub async fn create_express_account(email: &str, country: &str, platform_label: Option<String>) -> Result<String> {
    let p = pick_platform(platform_label)?;
    let proxy_url = crate::model::memory::heap::read_state(|s| s.proxy_url.clone());
 
    let body = format!(
        "type=express&country={}&email={}&capabilities[card_payments][requested]=true&capabilities[transfers][requested]=true",
        pct_encode(country), pct_encode(email),
    );
 
    let req = CanisterHttpRequestArgument {
        url: format!("{}/v1/accounts", proxy_url),
        method: HttpMethod::POST,
        body: Some(body.into_bytes()),
        max_response_bytes: Some(20_000),
        transform: None,
        headers: auth_headers(&p.secret_key, &p.api_url, &format!("stripe-acc-create-{}", ic_cdk::api::time())),
    };
 
    let (resp,) = http_request(req, 21_000_000_000u128)
        .await
        .map_err(|(r, m)| SystemError::HttpRequestError(r as u64, m))?;
 
    let v: serde_json::Value = serde_json::from_slice(&resp.body).map_err(SystemError::from)?;
    if let Some(err) = v.get("error") {
        let msg = err.get("message").and_then(|x| x.as_str()).unwrap_or("unknown");
        Err(SystemError::InvalidInput(msg.to_string()))?
    }
    Ok(v["id"].as_str().unwrap_or_default().to_string())
}
 
// Create onboarding link (refresh/return)
pub async fn create_account_link(account_id: &str, refresh_url: &str, return_url: &str, platform_label: Option<String>) -> Result<String> {
    let p = pick_platform(platform_label)?;
    let proxy_url = crate::model::memory::heap::read_state(|s| s.proxy_url.clone());
 
    let body = format!(
        "account={}&type=account_onboarding&refresh_url={}&return_url={}",
        pct_encode(account_id), pct_encode(refresh_url), pct_encode(return_url),
    );
 
    let req = CanisterHttpRequestArgument {
        url: format!("{}/v1/account_links", proxy_url),
        method: HttpMethod::POST,
        body: Some(body.into_bytes()),
        max_response_bytes: Some(20_000),
        transform: None,
        headers: auth_headers(&p.secret_key, &p.api_url, &format!("stripe-acct-link-{}", ic_cdk::api::time())),
    };
 
    let (resp,) = http_request(req, 21_000_000_000u128)
        .await
        .map_err(|(r, m)| SystemError::HttpRequestError(r as u64, m))?;
 
    let v: serde_json::Value = serde_json::from_slice(&resp.body).map_err(SystemError::from)?;
    Ok(v["url"].as_str().unwrap_or_default().to_string())
}

Checkout (destination charges)

#[derive(Debug, Deserialize)]
pub struct CheckoutSession {
    pub id: String,
    pub url: Option<String>,
    pub payment_status: Option<String>,
    pub payment_intent: Option<serde_json::Value>, // when expanded
}
 
// Create a destination charge via Checkout
pub async fn create_checkout_session_for_order(
    order_id: u64,
    offramper_acct: &str,    // acct_...
    amount_minor: u64,       // e.g., 1999 for €19.99
    currency_upper: &str,    // "EUR"
    platform_label: Option<String>,
) -> Result<(String, String)> {
    let p = pick_platform(platform_label)?;
    let proxy_url = crate::model::memory::heap::read_state(|s| s.proxy_url.clone());
    let cur = currency_upper.to_lowercase();
 
    let body = format!(
        "mode=payment\
         &success_url={}\
         &cancel_url={}\
         &line_items[0][quantity]=1\
         &line_items[0][price_data][currency]={}\
         &line_items[0][price_data][unit_amount]={}\
         &line_items[0][price_data][product_data][name]=Order%20{}\
         &payment_intent_data[transfer_data][destination]={}",
        pct_encode(&p.success_url),
        pct_encode(&p.cancel_url),
        cur,
        amount_minor,
        order_id,
        pct_encode(offramper_acct),
    );
 
    let req = CanisterHttpRequestArgument {
        url: format!("{}/v1/checkout/sessions", proxy_url),
        method: HttpMethod::POST,
        body: Some(body.into_bytes()),
        max_response_bytes: Some(20_000),
        transform: None,
        // IMPORTANT: unique idempotency key for tests; in prod use deterministic per-order
        headers: auth_headers(&p.secret_key, &p.api_url, &format!("stripe-create-{}-{}", order_id, ic_cdk::api::time())),
    };
 
    let (resp,) = http_request(req, 21_000_000_000u128)
        .await
        .map_err(|(r, m)| SystemError::HttpRequestError(r as u64, m))?;
 
    let v: serde_json::Value = serde_json::from_slice(&resp.body).map_err(SystemError::from)?;
    if let Some(err) = v.get("error") {
        let msg = err.get("message").and_then(|x| x.as_str()).unwrap_or("unknown");
        let param = err.get("param").and_then(|x| x.as_str()).unwrap_or("-");
        let req_url = err.get("request_log_url").and_then(|x| x.as_str()).unwrap_or("-");
        Err(SystemError::InvalidInput(format!("Stripe error: {msg} [param={param}] (log={req_url})")))?
    }
 
    let id  = v["id"].as_str().unwrap_or_default().to_string();
    let url = v["url"].as_str().unwrap_or_default().to_string();
    if id.is_empty() || url.is_empty() { Err(SystemError::ParseError("missing session id/url".into()))? }
    Ok((id, url))
}
 
// Retrieve session (optionally expand payment_intent & charges)
pub async fn retrieve_session(session_id: &str, expand_pi: bool, platform_label: Option<String>) -> Result<CheckoutSession> {
    let p = pick_platform(platform_label)?;
    let proxy_url = crate::model::memory::heap::read_state(|s| s.proxy_url.clone());
 
    let url = if expand_pi {
        format!("{}/v1/checkout/sessions/{}?expand[]=payment_intent&expand[]=payment_intent.charges", proxy_url, session_id)
    } else {
        format!("{}/v1/checkout/sessions/{}", proxy_url, session_id)
    };
 
    let req = CanisterHttpRequestArgument {
        url,
        method: HttpMethod::GET,
        body: None,
        max_response_bytes: Some(40_000),
        transform: None,
        headers: vec![
            HttpHeader { name: "Authorization".into(), value: format!("Bearer {}", p.secret_key) },
            HttpHeader { name: "x-forwarded-host".into(), value: forwarded_host(&p.api_url) },
            HttpHeader { name: "Accept".into(), value: "application/json".into() },
            HttpHeader { name: "idempotency-key".into(), value: format!("stripe-retrieve-{}", ic_cdk::api::time()) },
        ],
    };
 
    let (resp,) = http_request(req, 21_000_000_000u128)
        .await
        .map_err(|(r, m)| SystemError::HttpRequestError(r as u64, m))?;
 
    let s: CheckoutSession = serde_json::from_slice(&resp.body).map_err(SystemError::from)?;
    Ok(s)
}
 
// Strict verification for success route
pub async fn verify_session_paid_destination(
    session_id: &str,
    expected_minor: i64,
    expected_currency_upper: &str, // e.g. "EUR"
    expected_destination: &str,    // acct_...
    platform_label: Option<String>,
) -> Result<bool> {
    let s = retrieve_session(session_id, true, platform_label).await?;
    let paid = s.payment_status.as_deref() == Some("paid");
 
    let pi = s.payment_intent.as_ref().and_then(|v| v.as_object())
        .ok_or_else(|| SystemError::ParseError("no payment_intent".into()))?;
 
    let pi_status = pi.get("status").and_then(|v| v.as_str());
    let currency  = pi.get("currency").and_then(|v| v.as_str()).map(|c| c.to_uppercase());
    let dest      = pi.get("transfer_data").and_then(|x| x.get("destination")).and_then(|v| v.as_str());
    let received  = pi.get("amount_received").and_then(|v| v.as_i64()).unwrap_or(0);
 
    let ok = paid
        && pi_status == Some("succeeded")
        && currency.as_deref() == Some(expected_currency_upper)
        && dest == Some(expected_destination)
        && received >= expected_minor;
 
    Ok(ok)
}

Candid-first tests we used

These are the exact canister endpoints I used to validate the flow end-to-end before touching the frontend:

// Onboard an offramper
#[ic_cdk::update]
async fn stripe_test_create_express_account(email: String, country: String, platform_label: Option<String>) -> Result<String> {
    crate::stripe::create_express_account(&email, &country, platform_label).await
}
#[ic_cdk::update]
async fn stripe_test_create_account_link(account_id: String, refresh_url: String, return_url: String, platform_label: Option<String>) -> Result<String> {
    crate::stripe::create_account_link(&account_id, &refresh_url, &return_url, platform_label).await
}
#[ic_cdk::update]
async fn stripe_test_get_account(account_id: String, platform_label: Option<String>) -> Result<String> {
    crate::stripe::get_account_raw(&account_id, platform_label).await
}
 
// Create & inspect Checkout Session
#[ic_cdk::update]
async fn stripe_test_create_session(name: String, amount_minor: u64, currency_upper: String, destination_acct: String, platform_label: Option<String>) -> Result<(String, String)> {
    crate::stripe::create_checkout_session_for_order(0, &destination_acct, amount_minor, &currency_upper, platform_label).await
}
#[ic_cdk::update]
async fn stripe_test_retrieve_session(session_id: String, expand_pi: bool, platform_label: Option<String>) -> Result<String> {
    let s = crate::stripe::retrieve_session(&session_id, expand_pi, platform_label).await?;
    Ok(serde_json::to_string(&s).map_err(SystemError::from)?)
}
#[ic_cdk::update]
async fn stripe_test_verify_session(session_id: String, expected_minor: i64, expected_currency_upper: String, expected_destination: String, platform_label: Option<String>) -> Result<bool> {
    crate::stripe::verify_session_paid_destination(&session_id, expected_minor, &expected_currency_upper, &expected_destination, platform_label).await
}

Gotchas we hit (and fixed):

  • Idempotency replay during testing: using the same key replays the first failed attempt. For Candid tests we suffix the key with api::time() to force freshness.
  • x-forwarded-host must be just the host (api.stripe.com), not a URL with scheme.
  • Connected account must have transfers active and payouts enabled. In test mode we still need to finish onboarding (including test bank details provided by stripe sandbox).

What will land in an order lock (Stripe)

When an onramper locks an order to pay by Stripe, we will need persist:

// Sketch: new fields inside your Locked order
struct LockedStripe {
    destination_acct: String,     // acct_...
    platform_label: String,       // key routing
    currency: String,             // "EUR"
    amount_minor: u64,            // 1999
    session_id: String,           // cs_...
    session_url: String,          // https://checkout.stripe.com/...
    idempotency_key: String,      // deterministic per-order in prod
    created_at: u64,
    expires_at: u64,              // our own TTL < session TTL
    status: LockedStatus,         // PendingPayment | Paid | Expired | Canceled
}

Verification is the same pattern we already use (like PayPal/Revolut): retrieve → validate amounts/currency/destination → mark paid → release crypto.

Logs & sample outputs (test mode)

  • stripe_test_create_express_account("test@example.com", "ES", null)acct_…
  • stripe_test_create_account_link(acct, "http://127.0.0.1:8080/refresh", "http://127.0.0.1:8080/return", null) → onboarding URL
  • stripe_test_get_account(acct, null) → shows capabilities.transfers = "active", payouts_enabled = true
  • stripe_test_create_session("Order #123", 123, "EUR", acct, null)(cs_…, https://checkout.stripe.com/c/pay/cs_…)
  • stripe_test_retrieve_session(cs_…, true, null)payment_status: "unpaid" (until paid)
  • After simulated payment: stripe_test_verify_session(cs_…, 123, "EUR", acct, null)true

Risks & notes

  • Region limits: in live, we may need separate platform accounts per region. We already support multi-platform routing.
  • Fees: we can add payment_intent_data[application_fee_amount] to monetize (later).
  • Session lifecycle: do not reuse sessions if the amount changes; create a new session and update the lock.
  • Timeouts: auto-expire locks if the session remains unpaid past the window; optionally poll retrieve_session.

Next steps

  1. Frontend wiring
  • On lock (Stripe), call backend to create session; open session_url.
  • Use a success route with ?session_id={CHECKOUT_SESSION_ID}&order_id=<id>.
  • Call verify_stripe_session(order_id, session_id); refresh order state.
  1. Real payment test (Solana order)
  • Create a SOL/SPL order; lock with Stripe.
  • Pay via Checkout; on success, backend verifies and releases funds from the Solana vault (same pattern we use today).
  • Record application_fee_amount if we want platform revenue.
  1. Then: add BTC/ETH/ICP/stables paths, refactor EVM, and design liquid orders (partial fills/top-ups).

This serves as a firts implementation's approach to the backend foundation for Stripe in Milestone 2. Frontend integration is next.

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 #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 #20 — Pay with Crypto (Settlement & Verification)

We finish the pay-with-crypto flow: from Locked orders to on-chain payments, matching provider assets, and verifying EVM/Solana txs on the backend.