BASE44DEVS

ARTICLE · 10 MIN READ

Base44 Stripe Integration: A Production Guide for Subscriptions and Payments

Stripe on Base44 mostly works, until the parts that don't: webhooks fire only when users are active, signature validation is your job, the platform doesn't support in-app purchase for iOS apps, and AI agent regenerations regularly break checkout flows. This guide walks the production-grade integration: Checkout vs. Elements, customer model, subscription lifecycle, webhook signature validation, refund handling, and the iOS rejection trap.

Last verified
2026-05-01
Published
2026-05-01
Read time
10 min
Words
1,905
  • STRIPE
  • PAYMENTS
  • SUBSCRIPTIONS
  • WEBHOOKS

Why this matters

Stripe is the payments backbone for a huge fraction of Base44 apps. The integration mostly works out of the box. The parts that don't are exactly the parts that cost you revenue when they break: webhooks not firing, signatures not validated, subscription state drifting, AI agent regressions on checkout flow.

This guide is the production-grade walkthrough. It assumes you have a working Stripe account, have read Stripe's own docs, and want to know what specifically changes when you run on Base44.

Architecture

The right architecture for Stripe on Base44:

  • Frontend: Stripe Checkout or Stripe Elements. Loads stripe.js, redirects to Stripe-hosted pages, or renders Stripe-supplied iframes.
  • Backend functions: all Stripe API calls, all webhook processing, all customer/subscription state management.
  • Entities: mirror of relevant Stripe state (customer_id, subscription status, last_payment_at) for fast access. Stripe is the source of truth; the entity is a cache.
  • Secrets: Stripe secret key in the backend function's environment variables. Never on the frontend.

This keeps cardholder data out of Base44's storage entirely (SAQ A scope) and keeps Stripe API access scoped to backend functions where you can audit it.

Customer creation flow

User signs up. Backend function creates the Stripe customer. Stripe customer ID stored on the User entity.

// backend/functions/createStripeCustomer.ts
import Stripe from "https://esm.sh/stripe@14";

export default async function handler(req: Request): Promise<Response> {
  if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405 });

  const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, {
    apiVersion: "2024-06-20",
  });

  const me = await base44.User.me();
  if (!me) return new Response("Unauthorized", { status: 401 });

  // Idempotency: if user already has a customer_id, return it
  const existing = await base44.entities.User.list({ email: me.email }, null, 1);
  if (existing[0]?.stripe_customer_id) {
    return new Response(
      JSON.stringify({ customer_id: existing[0].stripe_customer_id }),
      { status: 200, headers: { "content-type": "application/json" } }
    );
  }

  const customer = await stripe.customers.create({
    email: me.email,
    name: me.full_name,
    metadata: { base44_user_id: me.id },
  });

  await base44.entities.User.update(me.id, {
    stripe_customer_id: customer.id,
  });

  return new Response(
    JSON.stringify({ customer_id: customer.id }),
    { status: 200, headers: { "content-type": "application/json" } }
  );
}

Notes on this pattern:

  • Idempotent: re-calling for an existing user returns the existing customer ID, doesn't create a duplicate.
  • Stripe customer metadata includes the Base44 user ID for cross-referencing.
  • Customer ID stored on the User entity; never accept it from the client.
  • The function inherits the user's identity, so User.me() returns the calling user, not an arbitrary user.

Checkout flow

Use Stripe Checkout for most cases. The frontend triggers a backend function that creates a Checkout Session, then redirects.

// backend/functions/createCheckoutSession.ts
import Stripe from "https://esm.sh/stripe@14";

export default async function handler(req: Request): Promise<Response> {
  if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405 });

  const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, { apiVersion: "2024-06-20" });
  const { priceId } = await req.json();

  const me = await base44.User.me();
  if (!me) return new Response("Unauthorized", { status: 401 });

  const userRecord = (await base44.entities.User.list({ email: me.email }, null, 1))[0];
  if (!userRecord?.stripe_customer_id) {
    return new Response("Customer not initialized", { status: 400 });
  }

  // Validate the priceId against an allow-list to prevent attackers using arbitrary prices
  const ALLOWED_PRICES = (Deno.env.get("ALLOWED_PRICE_IDS") || "").split(",");
  if (!ALLOWED_PRICES.includes(priceId)) {
    return new Response("Invalid price", { status: 400 });
  }

  const session = await stripe.checkout.sessions.create({
    customer: userRecord.stripe_customer_id,
    mode: "subscription",
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${Deno.env.get("APP_URL")}/billing?status=success&session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${Deno.env.get("APP_URL")}/billing?status=cancelled`,
    metadata: { base44_user_id: me.id },
  });

  return new Response(JSON.stringify({ url: session.url }), {
    status: 200,
    headers: { "content-type": "application/json" },
  });
}

Frontend redirects to session.url. Stripe handles card collection. After completion, Stripe redirects to your success URL. The actual subscription state update happens via webhook, not via the success URL — never trust the success URL alone, since it can be hit by an attacker.

Webhook processing

Webhooks are how Stripe tells your app what happened. They are critical.

// backend/functions/stripeWebhook.ts
import Stripe from "https://esm.sh/stripe@14";

export default async function handler(req: Request): Promise<Response> {
  if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405 });

  const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, { apiVersion: "2024-06-20" });
  const signature = req.headers.get("stripe-signature");
  const webhookSecret = Deno.env.get("STRIPE_WEBHOOK_SECRET")!;

  if (!signature) return new Response("Missing signature", { status: 400 });

  const body = await req.text();
  let event: Stripe.Event;
  try {
    event = await stripe.webhooks.constructEventAsync(body, signature, webhookSecret);
  } catch (err) {
    console.error("Webhook signature verification failed", err);
    return new Response("Invalid signature", { status: 400 });
  }

  // Idempotency: skip if we've already processed this event
  const seen = await base44.entities.WebhookLog.list({ event_id: event.id }, null, 1);
  if (seen.length > 0) {
    return new Response("Already processed", { status: 200 });
  }

  await base44.entities.WebhookLog.create({
    event_id: event.id,
    event_type: event.type,
    received_at: new Date().toISOString(),
  });

  switch (event.type) {
    case "customer.subscription.created":
    case "customer.subscription.updated":
      await handleSubscriptionUpdate(event.data.object as Stripe.Subscription);
      break;
    case "customer.subscription.deleted":
      await handleSubscriptionCancelled(event.data.object as Stripe.Subscription);
      break;
    case "invoice.payment_failed":
      await handlePaymentFailed(event.data.object as Stripe.Invoice);
      break;
    case "invoice.payment_succeeded":
      await handlePaymentSucceeded(event.data.object as Stripe.Invoice);
      break;
  }

  return new Response("OK", { status: 200 });
}

async function handleSubscriptionUpdate(sub: Stripe.Subscription) {
  const userRecord = (await base44.entities.User.list({ stripe_customer_id: sub.customer as string }, null, 1))[0];
  if (!userRecord) {
    console.error("Subscription for unknown customer", sub.customer);
    return;
  }
  await base44.entities.User.update(userRecord.id, {
    subscription_status: sub.status,
    subscription_id: sub.id,
    current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
    plan_id: sub.items.data[0]?.price.id,
  });
}

Critical points:

  • Signature verification first. Reject any request without a valid signature.
  • Idempotency via event ID. Stripe retries deliveries; your handler must be idempotent. Track processed event IDs in a WebhookLog entity.
  • Map Stripe customer to Base44 user. Use the stripe_customer_id field on the User entity.
  • Update entities, don't trust client to update. Subscription state is driven by Stripe events.
  • Always return 200 on success. A non-2xx response makes Stripe retry.
  • Log all events. You will need this for debugging.

The webhook-fires-only-when-active issue

Multiple Base44 users have reported webhooks firing only when users are actively using the app. Subscription renewals at 3am can be delayed.

Mitigation: daily reconciliation.

// backend/functions/reconcileStripeEvents.ts
import Stripe from "https://esm.sh/stripe@14";

export default async function handler(req: Request): Promise<Response> {
  const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, { apiVersion: "2024-06-20" });

  // Look at the last 25 hours to overlap with the previous run
  const since = Math.floor((Date.now() - 25 * 60 * 60 * 1000) / 1000);

  let cursor: string | undefined;
  let processed = 0;

  while (true) {
    const events = await stripe.events.list({
      created: { gte: since },
      limit: 100,
      starting_after: cursor,
    });

    for (const event of events.data) {
      const seen = await base44.entities.WebhookLog.list({ event_id: event.id }, null, 1);
      if (seen.length > 0) continue;

      // Re-process the event
      await base44.entities.WebhookLog.create({
        event_id: event.id,
        event_type: event.type,
        received_at: new Date().toISOString(),
        source: "reconcile",
      });

      // ... call same handlers as the webhook function ...
      processed++;
    }

    if (!events.has_more) break;
    cursor = events.data[events.data.length - 1].id;
  }

  return new Response(JSON.stringify({ processed }), { status: 200 });
}

Schedule this externally (cron-job.org, GitHub Actions) to run hourly or daily depending on how time-critical your subscription events are.

PCI scope

Three PCI scopes are practically achievable on Base44:

  • SAQ A. Card data never touches your servers. Achieved by using Stripe Checkout (full redirect) or Elements (iframe with no raw access). This is what we recommend.
  • SAQ A-EP. Slightly more involved Elements integration where you also handle 3DS challenges. Still SAQ A territory.
  • SAQ D / PCI Level 1. Full handling of card data. Not achievable on Base44 — the platform doesn't have the controls (network segmentation, key management, audit) to support full PCI compliance.

Stick to SAQ A. If you need SAQ D for some reason (rare for a Base44 app), you have a structural problem the platform cannot solve.

Refunds and disputes

Refunds initiated from your app go through a backend function:

// backend/functions/refundCharge.ts
import Stripe from "https://esm.sh/stripe@14";

export default async function handler(req: Request): Promise<Response> {
  const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, { apiVersion: "2024-06-20" });
  const { paymentIntentId } = await req.json();

  // Verify the caller has admin privileges
  const me = await base44.User.me();
  if (!me || me.role !== "admin") {
    return new Response("Forbidden", { status: 403 });
  }

  const refund = await stripe.refunds.create({
    payment_intent: paymentIntentId,
    metadata: { initiated_by: me.email },
  });

  await base44.entities.RefundLog.create({
    refund_id: refund.id,
    payment_intent_id: paymentIntentId,
    initiated_by: me.email,
    initiated_at: new Date().toISOString(),
  });

  return new Response(JSON.stringify({ refund_id: refund.id }), { status: 200 });
}

Disputes (chargebacks) come in via webhook events charge.dispute.created and charge.dispute.updated. Process them like any other webhook event, surfacing the dispute to your admin UI.

Testing

Stripe provides a comprehensive test mode. Use it for everything before going live:

  • Test card 4242 4242 4242 4242 for successful payments.
  • Test card 4000 0000 0000 9995 for declined payments.
  • Test card 4000 0000 0000 0341 for chargeback simulation.
  • Stripe CLI for replaying webhooks against your local backend functions.

Test the full flow end-to-end at least three times before going to production. Then test it again after every significant change. Checkout flow regressions are among the most common AI-agent-introduced bugs we see.

The iOS in-app purchase trap

Apple requires StoreKit for digital subscriptions in iOS apps. Stripe is not an option for iOS digital subscriptions.

If you publish a native iOS app wrapping your Base44 app:

  1. Subscriptions purchased in the iOS app must use StoreKit.
  2. Subscriptions purchased on the web (and accessed via the iOS app) can continue to use Stripe.
  3. The native iOS shell needs to handle StoreKit purchases, validate receipts, and sync with your backend.

This is a real engineering project, not a configuration. We cover it in detail in the App Store rejection fix.

Common Stripe-on-Base44 mistakes

Skipping webhook signature verification. Direct revenue exposure.

Putting the secret key in the frontend. It's the secret key for a reason.

Trusting the success URL for subscription state. Use webhooks; the URL can be forged.

Ignoring webhook idempotency. Stripe retries; your handler must be idempotent.

No reconciliation job for missed events. The platform's webhook quirk requires it.

Letting the AI agent regenerate the checkout flow without review. Checkout regressions are common; manual review is non-negotiable.

Building a custom card form. Escalates PCI scope to D. Don't.

Not testing refunds. They have edge cases (partial refunds, cross-currency, disputes) you'll discover only by testing.

Stripe-on-Base44 checklist

  • Stripe Checkout or Elements (no custom card form).
  • Customer creation in backend function on signup.
  • Customer ID stored on User entity, never accepted from client.
  • Price ID validated against allow-list before creating Checkout Session.
  • Webhook signature verified on every webhook call.
  • WebhookLog entity tracks processed event IDs (idempotency).
  • Webhook handlers update User entity from Stripe events.
  • Daily/hourly reconciliation job catches missed webhooks.
  • Refunds restricted to admin role.
  • Test mode used for all development.
  • iOS app uses StoreKit for digital purchases (if applicable).
  • Manual review of any AI agent change to checkout flow.

Want us to audit your Stripe integration?

Our $497 audit reviews your Stripe integration for signature validation, idempotency, reconciliation, PCI scope, and the AI-regression risk on checkout flow. Most apps have 2–4 fixable issues. Order an audit or book a free 15-minute call.

QUERIES

Frequently asked questions

Q.01Should I use Stripe Checkout or Stripe Elements with Base44?
A.01

Stripe Checkout for most apps. It's a hosted page Stripe runs on stripe.com, so cardholder data never touches your Base44 app — that gives you SAQ A PCI scope, the lowest compliance burden. Use Elements if you need a fully embedded checkout UI for brand reasons; this is also SAQ A as long as you only render Stripe-provided iframe components and never see raw card numbers. Never build a custom card form. Doing so escalates you to SAQ D and full PCI Level 1 compliance, which Base44 cannot support.

Q.02Why are my Stripe webhooks not firing reliably on Base44?
A.02

Base44 has a documented behavior where webhook delivery is tied to active user sessions. Subscription renewal events at 3am, when no users are active, may not get processed promptly. Stripe will retry failed deliveries for several days, so most events eventually land, but real-time subscription state can drift. Mitigations: (1) verify Stripe automatic retries are enabled, (2) run a daily reconciliation job that polls Stripe for the last 24 hours of events and re-applies any your app missed, (3) for time-critical events like dunning, schedule the reconciliation hourly.

Q.03Do I need to validate Stripe webhook signatures on Base44?
A.03

Yes, always. Without signature validation, any attacker who knows your webhook URL can forge events — fake payments, fake refunds, fake subscription cancellations. Stripe sends a Stripe-Signature header on every webhook; verify it in your backend function before processing. The cost of skipping this is direct revenue exposure and potential regulatory issues. Stripe's library handles the verification for you; the only failure mode is forgetting to call it.

Q.04Can I use Stripe for in-app purchases on iOS apps wrapping Base44?
A.04

No. Apple requires StoreKit (Apple's own in-app purchase system) for digital goods sold in iOS apps. Stripe and other third-party payment processors are not allowed for digital subscriptions or consumables on iOS. If you wrap your Base44 app in a native iOS shell to publish on the App Store, the shell must implement StoreKit for subscriptions. Base44 does not support StoreKit natively. The workaround is a native shell that handles StoreKit for iOS, while Stripe handles web and Android. This is a non-trivial engineering project documented in [our App Store rejection fix](/fix/app-store-rejection-no-storekit).

Q.05How do I handle Stripe customer creation in Base44?
A.05

Create the Stripe customer in a backend function on user signup, store the Stripe customer_id on the User entity, and never let the client supply or modify the customer_id. The pattern: user signs up, Base44's frontend calls a backend function called createStripeCustomer with the user's email, the function creates the customer in Stripe via the API, returns the ID, and the function updates the User entity. Customer data flows server-side; the client only sees the eventual customer_id.

Q.06What's the most common Stripe-on-Base44 production bug?
A.06

Lost or duplicate subscriptions due to incomplete webhook processing combined with missing idempotency. The pattern: a checkout completes, the customer.subscription.created webhook is delivered but the function fails partway through processing, the function returns 500, Stripe retries, and now you've created two subscription records in your User entity. Fix by making every webhook handler idempotent: include the event ID in your processing record so duplicate retries are no-ops, and use entity unique constraints where the platform supports them.

NEXT STEP

Need engineers who actually know base44?

Book a free 15-minute call or order a $497 audit.