Recipe: Stripe Webhook Payment Gateway

Idempotent Stripe webhook handling using xstate-statemachine and xstate-persistence.


🎯 Statechart Topology

stateDiagram-v2
    [*] --> Pending
    Pending --> Validating: STRIPE_EVENT_RECEIVED
    Validating --> Paid: payment_intent.succeeded
    Validating --> Failed: payment_intent.payment_failed
    Validating --> Refunded: charge.refunded
    
    Paid --> [*]
    Failed --> [*]
    Refunded --> [*]

🛠️ Complete Implementation with Idempotency Guard

#![allow(unused)]
fn main() {
use std::sync::Arc;
use xstate_statemachine::prelude::*;
use xstate_statemachine::persistence::{SqliteStore, persisted, PersistedOptions};

const STRIPE_MACHINE: &str = r#"{
    "id": "stripe_invoice",
    "initial": "pending",
    "context": {"invoice_id": null, "amount_cents": 0},
    "states": {
        "pending": {
            "on": {
                "PAYMENT_SUCCEEDED": {
                    "target": "paid",
                    "actions": "recordPayment"
                },
                "PAYMENT_FAILED": {
                    "target": "failed",
                    "actions": "recordFailure"
                }
            }
        },
        "paid": {"type": "final"},
        "failed": {"type": "final"}
    }
}"#;

pub fn handle_stripe_webhook(
    store: &SqliteStore,
    machine: &Machine,
    logic: &MachineLogic,
    event_id: &str, // Stripe Event ID: "evt_1N..." used as idempotency key
    invoice_id: &str,
    event_type: &str,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
    let mut options = PersistedOptions::default();
    options.idempotency_key = Some(event_id.to_string());

    // persisted() automatically enforces idempotency:
    // If Stripe sends the same webhook twice, the duplicate is skipped and the cached receipt returned.
    let states = persisted(
        store,
        invoice_id,
        machine,
        logic,
        &options,
        |interp, _| {
            match event_type {
                "payment_intent.succeeded" => interp.send("PAYMENT_SUCCEEDED")?,
                "payment_intent.payment_failed" => interp.send("PAYMENT_FAILED")?,
                _ => return Ok(interp.state_ids()),
            };
            Ok(interp.state_ids())
        },
    )?;

    Ok(states)
}
}