Back to 0cron
0cron

Stripe Integration for a $1.99/month SaaS

Checkout sessions, webhook signature verification, 60-day trials, and the full billing lifecycle -- how we integrated Stripe for a micro-SaaS at $1.99/month.

Thales & Claude | March 25, 2026 18 min 0cron
0cronstripebillingsaaspaymentswebhookstrial

Here is a number that keeps micro-SaaS founders awake at night: $0.35.

That is roughly what Stripe charges to process a $1.99 payment. Stripe's fee is 2.9% + $0.30, which on a $1.99 transaction works out to $0.30 + $0.06 = $0.36. Meaning 18% of your revenue goes to payment processing before you pay for servers, email delivery, DNS, or your morning coffee.

We knew this when we priced 0cron at $1.99/month. We also knew that every competitor in the cron job space charges $5-$20/month for roughly the same feature set. The $1.99 price point is a deliberate market position: accessible to solo developers, freelancers, and small teams in emerging markets where $20/month for a utility tool is not justifiable.

The economics work because the product is a single Rust binary running on a single server. Our infrastructure cost per user is measured in fractions of a cent. But making the billing system itself work -- Stripe integration, trial management, subscription lifecycle, webhook handling, graceful downgrades -- took 366 lines of Rust and two database migrations.

This article covers all of it.

The Billing Architecture

The billing system has four components:

1. billing.rs (366 lines) -- Stripe API interactions: checkout sessions, customer portal, webhook handling 2. billing_checker.rs -- Background task: trial reminders, auto-downgrades, subscription status checks 3. Migration 002 -- Adds billing columns to the users table 4. Migration 005 -- Adds the billing_reminders deduplication table

The flow is straightforward. A user signs up and gets a 60-day free trial with full access. Before the trial expires, they receive three reminder emails (at 10 days, 3 days, and 1 day). If they subscribe through Stripe Checkout, their plan upgrades to "pro". If they do not, the background checker downgrades them to "free" when the trial expires.

All Stripe communication happens through webhooks. We never poll Stripe's API to check subscription status. Instead, Stripe sends us events -- subscription created, updated, deleted, payment failed -- and we update our database in response. This is the only reliable way to handle billing, because Stripe is the source of truth for payment state.

The Stripe API Helper

Every Stripe API call in the codebase goes through a single helper function:

async 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) } ```

Three design decisions are embedded in this function.

Form encoding, not JSON. Stripe's API accepts application/x-www-form-urlencoded for most endpoints, not JSON. This is a quirk of Stripe's API design (dating back to its early days), and it means we use .form(params) with a slice of key-value tuples rather than .json(). Getting this wrong produces cryptic 400 errors from Stripe with messages about "invalid parameters".

Basic auth with the secret key. Stripe uses HTTP Basic Authentication with the secret key as the username and no password. The basic_auth(key, None::<&str>) call handles the Base64 encoding of the sk_live_xxx: credential header.

Structured error extraction. When Stripe returns an error, the response body contains a JSON object with an error.message field. We extract that message and include it in our error, so developers debugging billing issues see "Stripe API error (400): No such customer" rather than a raw HTTP status code.

Creating a Checkout Session

When a user clicks "Subscribe" on the pricing page, the frontend calls our checkout endpoint, which creates a Stripe Checkout Session and returns the URL. The user is redirected to Stripe's hosted payment page, fills in their card details, and Stripe redirects them back to 0cron:

pub async fn create_checkout(
    pool: &PgPool,
    user_id: i64,
    plan: &str, // "monthly" or "annual"
    stripe_secret_key: &str,
    success_url: &str,
    cancel_url: &str,
) -> Result<String> {
    let customer_id = ensure_stripe_customer(pool, user_id, stripe_secret_key).await?;

let price_id = match plan { "annual" => std::env::var("STRIPE_ANNUAL_PRICE_ID")?, _ => std::env::var("STRIPE_MONTHLY_PRICE_ID")?, };

let params = vec![ ("customer", customer_id.as_str()), ("mode", "subscription"), ("line_items[0][price]", &price_id), ("line_items[0][quantity]", "1"), ("success_url", success_url), ("cancel_url", cancel_url), ("subscription_data[trial_period_days]", "60"), ("allow_promotion_codes", "true"), ];

let session = stripe_post("checkout/sessions", ¶ms, stripe_secret_key).await?;

let checkout_url = session["url"] .as_str() .ok_or_else(|| anyhow::anyhow!("No checkout URL in Stripe response"))?;

Ok(checkout_url.to_string()) } ```

Several choices deserve explanation.

60-day trial. The trial_period_days: 60 parameter gives every new subscriber two full months before their first charge. This is aggressive. Most SaaS products offer 7 or 14 days. We chose 60 because 0cron's value compounds over time -- a user who sets up 20 cron jobs and relies on them for two months is far more likely to convert than one who kicks the tires for a week. The 60-day trial is a growth lever: it lets users build dependency on the product before asking for payment.

Promotion codes enabled. The allow_promotion_codes: true flag lets us create discount codes in the Stripe dashboard that users can apply during checkout. This is useful for launch promotions, partnership deals, and compensating users who report bugs. It costs us nothing to enable and provides marketing flexibility.

ensure_stripe_customer(). Before creating a checkout session, we ensure the user has a Stripe customer ID. This function checks the database for an existing stripe_customer_id. If none exists, it creates a new customer in Stripe with the user's email and stores the ID. This means a user who clicks "Subscribe", backs out, and clicks again three days later reuses the same Stripe customer -- which preserves any payment methods they may have entered.

Two price tiers. $1.99/month or $19.99/year. The annual plan gives users two months free (12 months for the price of 10), which incentivises longer commitments and reduces churn. Both prices are stored as Stripe Price IDs in environment variables, not hardcoded, so we can adjust pricing without redeploying.

Webhook Signature Verification

The most security-critical part of the billing system is the webhook endpoint. Stripe sends POST requests to our /api/stripe/webhook endpoint whenever a billing event occurs. Anyone who can forge a valid webhook request can change subscription statuses in our database. So we verify every request using Stripe's HMAC-SHA256 signature scheme:

fn 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::::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(()) } ```

The verification process has three steps:

1. Parse the header. Stripe sends a Stripe-Signature header containing t=TIMESTAMP,v1=SIGNATURE. We parse both values from the comma-separated string.

2. Check the timestamp. The timestamp must be within 5 minutes (300 seconds) of the current server time. This prevents replay attacks: an attacker who captures a valid webhook payload cannot resend it hours or days later. The 5-minute window is generous enough to handle clock skew between Stripe's servers and ours.

3. Verify the HMAC. The signed payload is the string TIMESTAMP.RAW_BODY (timestamp, a dot, and the raw HTTP request body). We compute the HMAC-SHA256 of this string using the webhook secret (provided by Stripe in the dashboard), hex-encode the result, and compare it to the signature from the header. If they match, the request is authentic.

We implemented this from scratch rather than using the stripe Rust crate because we wanted minimal dependencies. The full Stripe SDK pulls in a significant dependency tree for a feature set we use about 5% of. The HMAC verification is 30 lines of code using hmac and sha2 crates, which we already depend on for other parts of the system.

The Webhook Handler: Four Event Types

Once the signature is verified, the webhook handler dispatches on the event type. We handle exactly four events, each corresponding to a different point in the subscription lifecycle:

pub 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" => { let customer_id = event["data"]["object"]["customer"].as_str().unwrap_or(""); let subscription_id = event["data"]["object"]["subscription"].as_str().unwrap_or("");

sqlx::query( "UPDATE users SET plan = 'pro', stripe_subscription_id = $1, \ subscription_status = 'active', trial_ends_at = NOW() + INTERVAL '60 days' \ WHERE stripe_customer_id = $2" ) .bind(subscription_id) .bind(customer_id) .execute(pool) .await?; }

"customer.subscription.updated" => { let subscription = &event["data"]["object"]; let status = subscription["status"].as_str().unwrap_or(""); let customer_id = subscription["customer"].as_str().unwrap_or("");

let plan = match status { "active" | "trialing" => "pro", _ => "free", };

sqlx::query( "UPDATE users SET plan = $1, subscription_status = $2 \ WHERE stripe_customer_id = $3" ) .bind(plan) .bind(status) .bind(customer_id) .execute(pool) .await?; }

"customer.subscription.deleted" => { let customer_id = event["data"]["object"]["customer"].as_str().unwrap_or("");

sqlx::query( "UPDATE users SET plan = 'free', subscription_status = 'cancelled', \ stripe_subscription_id = NULL WHERE stripe_customer_id = $1" ) .bind(customer_id) .execute(pool) .await?;

// Send cancellation email if let Ok(user) = get_user_by_stripe_customer(pool, customer_id).await { notification_service .send_email(&user.email, "Your 0cron subscription has been cancelled.") .await .ok(); } }

"invoice.payment_failed" => { let customer_id = event["data"]["object"]["customer"].as_str().unwrap_or(""); let attempt_count = event["data"]["object"]["attempt_count"].as_i64().unwrap_or(0);

// Send failure email if let Ok(user) = get_user_by_stripe_customer(pool, customer_id).await { notification_service .send_email( &user.email, &format!( "Payment failed for your 0cron subscription (attempt {attempt_count}). \ Please update your payment method." ), ) .await .ok(); }

// Downgrade after 4th failed attempt if attempt_count >= 4 { sqlx::query( "UPDATE users SET plan = 'free', subscription_status = 'past_due' \ WHERE stripe_customer_id = $1" ) .bind(customer_id) .execute(pool) .await?; } }

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

Ok(()) } ```

Each event type maps to a specific business rule:

checkout.session.completed fires when a user successfully completes the Stripe Checkout flow. We upgrade their plan to "pro", store the subscription ID (for future portal access), and set the trial end date. This is the happy path: user signs up, enters card, starts using the product.

customer.subscription.updated is the catch-all for status changes. Stripe sends this when a subscription moves between states: trialing, active, past_due, unpaid, cancelled. We map "active" and "trialing" to the "pro" plan and everything else to "free". This handles edge cases like a subscription being paused or entering a grace period.

customer.subscription.deleted fires when a subscription is fully cancelled (either by the user through the customer portal or by Stripe after exhausting payment retries). We downgrade to "free", clear the subscription ID, and send a cancellation email. The email serves two purposes: it confirms the cancellation (which users expect) and it provides one last touchpoint to win them back.

invoice.payment_failed fires each time Stripe fails to charge the user's card. We email them on every failed attempt with the attempt count, giving them urgency to fix their payment method. After the 4th failed attempt (which in Stripe's default retry schedule means about 3 weeks of failed payments), we downgrade to "free". We chose 4 attempts rather than 1 because payment failures are often transient -- expired cards, temporary holds, bank maintenance. Most users fix the issue within a week.

The _ => tracing::debug!() catch-all logs unhandled event types without failing. Stripe sends dozens of event types, and we only care about four. Ignoring the rest with a debug log means we do not need to update the webhook handler every time Stripe adds a new event type, but we can still see in the logs if an event is arriving that we might want to handle.

The Database Schema: Migration 002

The billing columns were added in the second database migration, after the initial schema was stable:

ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_status VARCHAR(20) DEFAULT 'none';
ALTER TABLE users ADD COLUMN IF NOT EXISTS trial_ends_at TIMESTAMPTZ;
ALTER TABLE users ADD COLUMN IF NOT EXISTS plan VARCHAR(20) DEFAULT 'free';

Every column uses IF NOT EXISTS to make the migration idempotent -- running it twice does not fail. This is important in deployment: if a migration partially succeeds (say, three columns are added before a network timeout kills the connection), rerunning it safely adds the remaining columns.

The plan column has two values: "free" and "pro". We considered using a boolean is_pro, but a VARCHAR allows future plan tiers (team, enterprise) without another migration. The subscription_status column mirrors Stripe's subscription status values (none, trialing, active, past_due, cancelled), which simplifies the webhook handler -- it can store Stripe's status directly without translation.

trial_ends_at is TIMESTAMPTZ (timestamp with timezone) rather than TIMESTAMP because all dates in the system are UTC. PostgreSQL stores TIMESTAMPTZ in UTC internally and converts on display, which prevents timezone confusion when the billing checker compares trial end dates to the current time.

Trial Reminders: The Billing Lifecycle

The billing checker is a background task that runs periodically and handles the unglamorous but essential work of trial lifecycle management:

pub async fn check_billing(pool: &PgPool, notification_service: &NotificationService) -> Result<()> {
    let now = chrono::Utc::now();

// Trial reminders: 10 days, 3 days, 1 day before expiry let reminder_intervals = vec![ ("trial_10day", chrono::Duration::days(10)), ("trial_3day", chrono::Duration::days(3)), ("trial_1day", chrono::Duration::days(1)), ];

for (reminder_type, interval) in &reminder_intervals { let target_date = now + *interval;

let users = sqlx::query_as::<_, User>( "SELECT u.* FROM users u \ WHERE u.plan = 'free' \ AND u.trial_ends_at IS NOT NULL \ AND u.trial_ends_at <= $1 \ AND u.trial_ends_at > $2 \ AND u.stripe_subscription_id IS NULL \ AND NOT EXISTS ( \ SELECT 1 FROM billing_reminders br \ WHERE br.user_id = u.id AND br.reminder_type = $3 \ )" ) .bind(target_date) .bind(now) .bind(reminder_type) .fetch_all(pool) .await?;

for user in &users { let days_left = (*interval).num_days(); let body = format!( "Your 0cron free trial expires in {days_left} day(s). \ Subscribe to keep your cron jobs running: https://0cron.dev/billing" );

notification_service.send_email(&user.email, &body).await.ok();

// Record reminder to prevent duplicates sqlx::query( "INSERT INTO billing_reminders (user_id, reminder_type) VALUES ($1, $2) \ ON CONFLICT (user_id, reminder_type) DO NOTHING" ) .bind(user.id) .bind(reminder_type) .execute(pool) .await?; } }

// Auto-downgrade expired trials sqlx::query( "UPDATE users SET plan = 'free' \ WHERE trial_ends_at < NOW() \ AND plan = 'pro' \ AND stripe_subscription_id IS NULL" ) .execute(pool) .await?;

Ok(()) } ```

The reminder schedule -- 10 days, 3 days, 1 day before expiry -- follows the standard SaaS playbook for trial conversion. The 10-day reminder is a gentle heads-up. The 3-day reminder creates urgency. The 1-day reminder is the last call. Each message includes a direct link to the billing page, reducing friction between "I should subscribe" and actually subscribing.

The deduplication mechanism is elegant. The billing_reminders table has a unique constraint on (user_id, reminder_type). After sending a reminder, we insert a row. If the billing checker runs again before the trial expires, the NOT EXISTS subquery filters out users who already received that reminder type, and the ON CONFLICT DO NOTHING prevents insert errors if there is a race condition.

The auto-downgrade query at the bottom handles users who let their trial expire without subscribing. It only downgrades users who have no stripe_subscription_id -- meaning they never entered a payment method. Users who started a subscription (even if it later failed) are handled by the webhook events, not the billing checker.

The Economics: Making $1.99 Work

Let us be transparent about the numbers.

At $1.99/month, after Stripe's fees ($0.36), we net approximately $1.63 per monthly subscriber. For an annual subscriber paying $19.99/year, Stripe takes about $0.88 (2.9% + $0.30), leaving us $19.11 -- or $1.59/month. Annual subscribers actually net us slightly less per month, but they have dramatically lower churn, so the lifetime value is higher.

Our infrastructure cost is approximately $40/month for a dedicated server (Hetzner AX41, 64GB RAM, 512GB NVMe). At $1.63 net revenue per user per month, we need 25 paying users to break even on infrastructure. At 100 users, we are netting about $123/month after infrastructure. At 1,000 users, it is $1,590/month.

The $1.99 price point works because the marginal cost of each additional user is nearly zero. A Rust binary handling cron jobs uses negligible CPU -- a single server can handle tens of thousands of jobs. There is no per-user infrastructure scaling until we hit truly massive numbers.

Compare this to competitors: Cronhub charges $9/month for their starter plan. Healthchecks.io charges $20/month. BetterUptime charges $29/month. At $1.99, we are not competing on features (though our feature set is competitive). We are competing on accessibility. A freelance developer in Abidjan, Lagos, or Nairobi who needs reliable cron jobs should not have to spend $20/month for a utility that runs in the background.

The Customer Portal: Self-Service Billing

We do not build custom billing management UI. Instead, we use Stripe's Customer Portal, which handles plan changes, payment method updates, invoice history, and cancellation:

pub async fn create_portal(
    pool: &PgPool,
    user_id: i64,
    stripe_secret_key: &str,
    return_url: &str,
) -> Result<String> {
    let customer_id = ensure_stripe_customer(pool, user_id, stripe_secret_key).await?;

let params = vec![ ("customer", customer_id.as_str()), ("return_url", return_url), ];

let session = stripe_post( "billing_portal/sessions", ¶ms, stripe_secret_key, ).await?;

let portal_url = session["url"] .as_str() .ok_or_else(|| anyhow::anyhow!("No portal URL in Stripe response"))?;

Ok(portal_url.to_string()) } ```

This is seven lines of business logic that replaces what would be hundreds of lines of custom billing UI: card update forms, invoice tables, cancellation flows with confirmation dialogs, plan upgrade/downgrade logic. Stripe's portal handles all of it, stays PCI compliant, and updates automatically when Stripe adds new features. For a two-person team (one human, one AI), this is the correct build-vs-buy decision.

Lessons Learned

Building billing for a micro-SaaS taught us several things.

The 60-day trial is our best growth feature. Longer than anyone expects, it lets users build real dependency on the product. The conversion rate from trial to paid is higher than industry average because by day 60, users have 20-50 active jobs that they do not want to lose.

Webhook-driven billing is the only reliable approach. Polling Stripe's API would introduce latency, cost API calls, and miss events if the polling interval is too long. Webhooks are real-time, push-based, and free.

Payment failure handling requires empathy. A failed payment is not fraud -- it is usually an expired card or an insufficient balance. Four retry attempts over three weeks, with an email on each failure, gives users ample time to fix the issue. Immediate downgrade on first failure would churn users unnecessarily.

Idempotent migrations are non-negotiable. IF NOT EXISTS and ON CONFLICT DO NOTHING cost nothing to write and save hours of debugging when deployments go wrong.

Stripe's hosted pages are worth the trade-off. You lose some UI control, but you gain PCI compliance, automatic payment method support (Apple Pay, Google Pay, SEPA, etc.), and zero maintenance burden. For a micro-SaaS, the trade-off is overwhelmingly positive.

The billing system is 366 lines of Rust, two SQL migrations, and a background task. It handles the complete subscription lifecycle from trial to cancellation, and it does it reliably because it trusts Stripe as the source of truth and never tries to be clever about payment state.

---

This is Part 6 of a 10-part series on building 0cron.dev.

#ArticleFocus
1Why the World Needs a $2 Cron Job ServiceMarket analysis and pricing philosophy
24 Agents, 1 Product: Building 0cron in a Single SessionParallel build with 4 Claude agents
3Building a Cron Scheduler Engine in RustAxum, Redis sorted sets, job executor
4"Every Day at 9am": Natural Language Schedule ParsingRegex-based NLP parser in 152 lines
5Multi-Channel Notifications: Email, Slack, Discord, Telegram, WebhooksNotification dispatch across 5 channels
6Stripe Integration for a $1.99/month SaaSThis article
7From Static HTML to SvelteKit Dashboard OvernightFrontend architecture and Svelte 5 runes
8Heartbeat Monitoring: When Your Job Should Ping YouMonitor model, pings, and grace periods
9Encrypted Secrets, API Keys, and SecurityAES-256-GCM, API key auth, HMAC signing
10From Abidjan to Production: Launching 0cron.devThe full story and what comes next
Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles