Back to 0cron
0cron

Intégration Stripe pour un SaaS à 1,99 $/mois

Sessions de checkout, vérification de signature webhook, essais de 60 jours, et le cycle de vie complet de facturation -- comment nous avons intégré Stripe pour un micro-SaaS à 1,99 $/mois.

Thales & Claude | March 30, 2026 9 min 0cron
EN/ FR/ ES
0cronstripebillingsaaspaymentswebhookstrial

Voici un chiffre qui empêche les fondateurs de micro-SaaS de dormir la nuit : 0,35 $.

C'est approximativement ce que Stripe facture pour traiter un paiement de 1,99 $. Les frais de Stripe sont de 2,9 % + 0,30 $, ce qui sur une transaction de 1,99 $ donne 0,30 $ + 0,06 $ = 0,36 $. Soit 18 % de votre revenu qui part en traitement de paiement avant que vous ne payiez les serveurs, l'envoi d'e-mails, le DNS, ou votre café du matin.

Nous le savions quand nous avons fixé le prix de 0cron à 1,99 $/mois. Nous savions aussi que chaque concurrent dans l'espace des tâches cron facture 5-20 $/mois pour un ensemble de fonctionnalités grosso modo identique. Le prix de 1,99 $ est un positionnement de marché délibéré : accessible aux développeurs solo, aux freelances, et aux petites équipes dans les marchés émergents où 20 $/mois pour un outil utilitaire n'est pas justifiable.

L'économie fonctionne parce que le produit est un unique binaire Rust tournant sur un seul serveur. Notre coût d'infrastructure par utilisateur se mesure en fractions de centime. Mais faire fonctionner le système de facturation lui-même -- intégration Stripe, gestion d'essai, cycle de vie des abonnements, traitement des webhooks -- a nécessité 366 lignes de Rust et deux migrations de base de données.

Cet article couvre tout cela.

L'architecture de facturation

Le système de facturation comporte quatre composants :

  1. billing.rs (366 lignes) -- Interactions API Stripe : sessions de checkout, portail client, traitement de webhooks
  2. billing_checker.rs -- Tâche de fond : rappels d'essai, rétrogradations automatiques, vérifications de statut d'abonnement
  3. Migration 002 -- Ajoute les colonnes de facturation à la table users
  4. Migration 005 -- Ajoute la table de déduplication billing_reminders

Le flux est simple. Un utilisateur s'inscrit et reçoit un essai gratuit de 60 jours avec accès complet. Avant l'expiration de l'essai, il reçoit trois e-mails de rappel (à 10 jours, 3 jours, et 1 jour). S'il s'abonne via Stripe Checkout, son plan passe à « pro ». Sinon, le vérificateur de fond le rétrograde à « free » quand l'essai expire.

Toute la communication avec Stripe passe par des webhooks. Nous n'interrogeons jamais l'API de Stripe pour vérifier le statut d'un abonnement. Au lieu de cela, Stripe nous envoie des événements -- abonnement créé, mis à jour, supprimé, paiement échoué -- et nous mettons à jour notre base de données en réponse. C'est le seul moyen fiable de gérer la facturation, parce que Stripe est la source de vérité pour l'état des paiements.

Le helper API Stripe

Chaque appel API Stripe dans la base de code passe par une seule fonction helper :

rustasync fn stripe_post(
    endpoint: &str,
    params: &[(&str, &str)],
    stripe_secret_key: &str,
) -> Result<serde_json::Value> {
    let url = format!("https://api.stripe.com/v1/{endpoint}");

    let response = reqwest::Client::new()
        .post(&url)
        .basic_auth(stripe_secret_key, None::<&str>)
        .form(params)
        .send()
        .await?;

    let status = response.status();
    let body: serde_json::Value = response.json().await?;

    if !status.is_success() {
        let error_msg = body["error"]["message"]
            .as_str()
            .unwrap_or("Unknown Stripe error");
        return Err(anyhow::anyhow!("Stripe API error ({}): {}", status, error_msg));
    }

    Ok(body)
}

Encodage formulaire, pas JSON. L'API de Stripe accepte application/x-www-form-urlencoded pour la plupart des endpoints, pas du JSON. C'est une particularité de la conception de l'API de Stripe (datant de ses débuts), et cela signifie que nous utilisons .form(params) avec un slice de tuples clé-valeur plutôt que .json().

Auth basique avec la clé secrète. Stripe utilise l'authentification HTTP Basic avec la clé secrète comme nom d'utilisateur et pas de mot de passe.

Extraction d'erreur structurée. Quand Stripe retourne une erreur, le corps de la réponse contient un objet JSON avec un champ error.message. Nous extrayons ce message et l'incluons dans notre erreur, pour que les développeurs déboguant des problèmes de facturation voient « Stripe API error (400): No such customer » plutôt qu'un code de statut HTTP brut.

Vérification de signature webhook

La partie la plus critique en termes de sécurité du système de facturation est l'endpoint webhook. Stripe envoie des requêtes POST à notre endpoint /api/stripe/webhook chaque fois qu'un événement de facturation se produit. Quiconque peut forger une requête webhook valide peut changer les statuts d'abonnement dans notre base de données. Nous vérifions donc chaque requête en utilisant le schéma de signature HMAC-SHA256 de Stripe :

rustfn verify_stripe_signature(
    payload: &str,
    signature_header: &str,
    webhook_secret: &str,
) -> Result<()> {
    let mut timestamp = "";
    let mut signature = "";

    for part in signature_header.split(',') {
        let part = part.trim();
        if let Some(t) = part.strip_prefix("t=") {
            timestamp = t;
        } else if let Some(v) = part.strip_prefix("v1=") {
            signature = v;
        }
    }

    if timestamp.is_empty() || signature.is_empty() {
        return Err(anyhow::anyhow!("Missing timestamp or signature"));
    }

    // Verify timestamp is within 5 minutes
    let ts: i64 = timestamp.parse()?;
    let now = chrono::Utc::now().timestamp();
    if (now - ts).abs() > 300 {
        return Err(anyhow::anyhow!("Webhook timestamp too old"));
    }

    // Compute expected signature
    let signed_payload = format!("{timestamp}.{payload}");
    let mut mac = Hmac::<Sha256>::new_from_slice(webhook_secret.as_bytes())?;
    mac.update(signed_payload.as_bytes());
    let expected = hex::encode(mac.finalize().into_bytes());

    if expected != signature {
        return Err(anyhow::anyhow!("Invalid webhook signature"));
    }

    Ok(())
}

Le processus de vérification comporte trois étapes :

  1. Parser l'en-tête. Stripe envoie un en-tête Stripe-Signature contenant t=TIMESTAMP,v1=SIGNATURE.
  1. Vérifier l'horodatage. L'horodatage doit être dans les 5 minutes (300 secondes) de l'heure actuelle du serveur. Cela empêche les attaques par rejeu.
  1. Vérifier le HMAC. Le payload signé est la chaîne TIMESTAMP.CORPS_BRUT. Nous calculons le HMAC-SHA256 de cette chaîne en utilisant le secret webhook, l'encodons en hexadécimal, et le comparons à la signature de l'en-tête.

Le handler webhook : quatre types d'événements

Nous gérons exactement quatre événements, chacun correspondant à un point différent du cycle de vie de l'abonnement :

rustpub async fn stripe_webhook(
    pool: &PgPool,
    notification_service: &NotificationService,
    payload: &str,
    signature_header: &str,
    webhook_secret: &str,
) -> Result<()> {
    verify_stripe_signature(payload, signature_header, webhook_secret)?;

    let event: serde_json::Value = serde_json::from_str(payload)?;
    let event_type = event["type"].as_str().unwrap_or("");

    match event_type {
        "checkout.session.completed" => {
            // Upgrade to pro, store subscription ID
            // ...
        }

        "customer.subscription.updated" => {
            // Map active/trialing to pro, everything else to free
            // ...
        }

        "customer.subscription.deleted" => {
            // Downgrade to free, send cancellation email
            // ...
        }

        "invoice.payment_failed" => {
            // Email on each failure, downgrade after 4th attempt
            // ...
        }

        _ => {
            tracing::debug!("Unhandled Stripe event type: {event_type}");
        }
    }

    Ok(())
}

Chaque type d'événement correspond à une règle métier spécifique :

checkout.session.completed se déclenche quand un utilisateur complète avec succès le flux Stripe Checkout. Nous passons son plan en « pro » et stockons l'ID d'abonnement.

customer.subscription.updated est le catch-all pour les changements de statut. Nous mappons « active » et « trialing » au plan « pro » et tout le reste à « free ».

customer.subscription.deleted se déclenche quand un abonnement est complètement annulé. Nous rétrogradons à « free » et envoyons un e-mail de confirmation d'annulation.

invoice.payment_failed se déclenche à chaque fois que Stripe échoue à débiter la carte de l'utilisateur. Nous envoyons un e-mail à chaque tentative échouée. Après la 4e tentative échouée (ce qui dans le calendrier de retry par défaut de Stripe signifie environ 3 semaines de paiements échoués), nous rétrogradons à « free ». Nous avons choisi 4 tentatives plutôt que 1 parce que les échecs de paiement sont souvent transitoires -- cartes expirées, blocages temporaires, maintenance bancaire.

L'économie : faire fonctionner 1,99 $

Soyons transparents sur les chiffres.

À 1,99 $/mois, après les frais de Stripe (0,36 $), nous encaissons environ 1,63 $ par abonné mensuel. Pour un abonné annuel payant 19,99 $/an, Stripe prend environ 0,88 $, nous laissant 19,11 $ -- soit 1,59 $/mois. Les abonnés annuels nous rapportent en fait légèrement moins par mois, mais ils ont un taux d'attrition considérablement plus bas, donc la valeur vie est plus élevée.

Notre coût d'infrastructure est d'environ 40 $/mois pour un serveur dédié (Hetzner AX41, 64 Go de RAM, 512 Go NVMe). À 1,63 $ de revenu net par utilisateur par mois, nous avons besoin de 25 utilisateurs payants pour atteindre le seuil de rentabilité sur l'infrastructure. À 100 utilisateurs, nous encaissons environ 123 $/mois après infrastructure. À 1 000 utilisateurs, c'est 1 590 $/mois.

Le prix de 1,99 $ fonctionne parce que le coût marginal de chaque utilisateur supplémentaire est quasi nul. Un binaire Rust gérant des tâches cron utilise un CPU négligeable -- un seul serveur peut gérer des dizaines de milliers de tâches.

Leçons apprises

L'essai de 60 jours est notre meilleure fonctionnalité de croissance. Plus long que ce que quiconque attend, il laisse les utilisateurs construire une vraie dépendance au produit.

La facturation pilotée par webhooks est la seule approche fiable. Interroger l'API de Stripe introduirait de la latence, coûterait des appels API, et raterait des événements.

La gestion des échecs de paiement nécessite de l'empathie. Un paiement échoué n'est pas une fraude -- c'est généralement une carte expirée ou un solde insuffisant. Quatre tentatives de retry sur trois semaines, avec un e-mail à chaque échec, donne aux utilisateurs amplement le temps de corriger le problème.

Les migrations idempotentes ne sont pas négociables. IF NOT EXISTS et ON CONFLICT DO NOTHING ne coûtent rien à écrire et économisent des heures de débogage quand les déploiements tournent mal.

Le système de facturation fait 366 lignes de Rust, deux migrations SQL, et une tâche de fond. Il gère le cycle de vie complet de l'abonnement, de l'essai à l'annulation.


Ceci est la partie 6 d'une série de 10 articles sur la construction de 0cron.dev.

#ArticleFocus
1Pourquoi le monde a besoin d'un service cron à 2 $Analyse de marché et philosophie tarifaire
24 agents, 1 produit : construire 0cron en une seule sessionBuild parallèle avec 4 agents Claude
3Construire un moteur de planification cron en RustAxum, sorted sets Redis, exécuteur de tâches
4"Tous les jours à 9 h" : parsing de planification en langage naturelParseur NLP à base de regex en 152 lignes
5Notifications multi-canaux : e-mail, Slack, Discord, Telegram, webhooksDispatch de notifications sur 5 canaux
6Intégration Stripe pour un SaaS à 1,99 $/moisCet article
7Du HTML statique au tableau de bord SvelteKit en une nuitArchitecture frontend et runes Svelte 5
8Monitoring heartbeat : quand votre tâche devrait vous pinguerModèle moniteur, pings, et périodes de grâce
9Secrets chiffrés, clés API, et sécuritéAES-256-GCM, authentification par clé API, signature HMAC
10D'Abidjan à la production : lancement de 0cron.devL'histoire complète et la suite
Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles