BASE44DEVS

ARTICLE · 16 MIN READ

Base44 Stripe Integration: 2026 Production Guide for Payments

Stripe on Base44 mostly works, until the parts that don't: webhooks fire only when users are active, signature validation is your job, Base44's App Store and Google Play builds cannot sell digital goods through Stripe, and AI agent regenerations can break checkout flows. This guide walks the production-grade integration: Checkout vs. Elements, customer model, subscription lifecycle, webhook signature validation, refund handling, and the app-store billing trap.

Last verified
2026-10-17
Published
2026-05-01
Read time
16 min
Words
3,163
  • STRIPE
  • PAYMENTS
  • SUBSCRIPTIONS
  • WEBHOOKS

Stripe on Base44 works if you treat the platform as a thin client and put all sensitive logic in backend functions. Use Stripe Checkout (full hosted redirect) or Stripe Elements (iframes) for SAQ A PCI scope and never roll your own card form. Create the Stripe customer in a backend function on signup and store the customer_id on the User entity. Validate Stripe-Signature on every webhook before processing. Track every event_id in a WebhookLog entity for idempotency since Stripe retries. Handle Base44's documented webhook-fires-only-when-active limitation with a daily or hourly reconciliation job that polls Stripe's events API and re-applies missed events. Restrict refunds to admin role. In Base44's App Store and Google Play builds, Stripe is allowed for physical goods and services but not for digital goods, because Apple and Google require their own billing: sell subscriptions on the web version of your app and let the mobile app grant access to whoever has paid.

How do you connect Base44 to Stripe?

You connect Base44 to Stripe from the editor's AI chat: ask it to install Stripe, and Base44 sets up a test-mode Stripe sandbox and a checkout flow, which you test on your published app. You then claim the sandbox with your own Stripe account, and Base44 switches the app to live keys. Stripe needs the Builder plan or above.

That flow is documented in Base44's own Setting up Stripe guide, which says it "was released in January 2026 and replaces the old Stripe flow". It gets a checkout working, and you have 60 days to claim the sandbox. It leaves three production jobs to you, and those are what this guide adds:

  1. Webhook signature verification and idempotency. Base44's doc has each flow confirm the payment on the success page "instead of relying on webhooks alone". Renewals, failed payments and cancellations made in Stripe's customer portal never pass through that success page, so a verified, idempotent webhook is still how your app hears about them. See Webhook processing.
  2. Subscription-to-entitlement sync. Stripe is the source of truth, and your User entity has to follow it. That means mapping each subscription event to the user it belongs to, plus a reconciliation job for the events that never arrive.
  3. Test-to-live cutover. Base44 creates your live API keys when you claim the sandbox, or you can enter them manually. If you run your own webhook function, its signing secret changes too; see Testing for what to re-check before real customers pay.

This guide is for setting Stripe up properly. If your Stripe integration was working and broke after a Base44 release, that is a repair job, so go to the fix for Stripe breaking after a Base44 platform update instead.

Why this matters

Base44's editor can install Stripe for you from the AI chat, and 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, written by the lead engineer at Base44Devs, 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.auth.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.filter({ email: me.email }, undefined, 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 base44.auth.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.auth.me();
  if (!me) return new Response("Unauthorized", { status: 401 });

  const userRecord = (await base44.entities.User.filter({ email: me.email }, undefined, 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.filter({ event_id: event.id }, undefined, 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.filter({ stripe_customer_id: sub.customer as string }, undefined, 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.

When that mapping breaks, the customer does not report a webhook problem. They report that they paid and still cannot get in — the four places entitlement gets lost are traced in our fix for a Stripe subscription not granting access.

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. This is platform behaviour rather than a bug in your handler, and we document it separately as Base44 webhooks only firing while a user is active.

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.filter({ event_id: event.id }, undefined, 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.auth.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, including AI agent edits that never mention payments, because an agent regeneration can break the checkout flow.

The test-to-live cutover. When you claim your sandbox, Base44 activates live payments and creates your live API keys for you. If you run your own webhook function, as this guide does, the signing secret is yours to update. Stripe's documentation says: "Stripe generates a unique secret key for each endpoint. If you use the same endpoint for both test and live API keys, the secret is different for each one" (Stripe webhooks docs). If STRIPE_WEBHOOK_SECRET still holds the test-mode secret after you go live, every live event fails signature verification, and the handler above returns 400 on real payments. Swap the secret, then run one small real payment end to end and refund it before customers arrive.

Can a Base44 mobile app take payments through Stripe?

Only for physical goods and services. Stripe cannot sell digital goods inside a Base44 app published to the App Store or Google Play. Base44's store-submission docs put it plainly: "Do not use Stripe for payments inside your mobile app. Apple and Google require their own billing systems for digital content. If your app uses Stripe for digital content, your app is rejected." Android is not the exception: the Google Play build is held to the same rule as the iOS one. Base44's Setting up Stripe guide says the same of digital products in store apps: "the purchase cannot happen inside the mobile app."

Where the line falls, per Base44's docs:

What you sellStripe inside the store app?Where the payment belongs
Premium or subscription tiersNoThe web version of your app
Paid features or content inside the appNoThe web version of your app
Virtual currency, credits or extra AI generationsNoThe web version of your app
A physical product you shipYesIn the app, through Stripe
Booking a real-world service, such as an appointment or a tableYesIn the app, through Stripe
A service delivered outside the app, such as coachingYesIn the app, through Stripe
DonationsYes, per Base44; Apple is narrower for charities (below)In the app, through Stripe, unless Apple's charity rule applies

When you are not sure which side you are on, the docs say to "treat it as digital. Apple in particular rejects borderline cases."

Donations are the one row where Apple's own rules are narrower than Base44's list. Apple's App Review Guidelines (3.2.2(iv)) say that, unless you are an approved nonprofit, apps that raise money for charities and fundraisers "may only collect funds outside of the app, such as via Safari or SMS", and approved nonprofits that fundraise inside the app must "offer Apple Pay support" (3.2.1(vi)).

Base44 does not support StoreKit or Google Play Billing yet; its docs say it is "working on a built-in integration for StoreKit and Google Play Billing". Purchase plugins do not get round it. The store build is "a lightweight native wrapper around your web app that opens only your app's URL", and "RevenueCat's native SDK and Capacitor in-app purchase plugins need access to the native layer that Base44 apps do not expose, so they do not work".

The route Base44 documents reuses the functions in this guide:

  1. Sell the subscription on the web version of your app, through the Checkout Session and webhook functions above.
  2. Keep the paywall in the mobile app, but have it point people to your website. Remove any button that opens a Stripe checkout; any link you keep must open a browser, not a payment flow inside the app.
  3. Have the mobile app check who has paid (the subscription_status your webhook handler writes to the User entity) and show or hide paid content from that.

Step 3 is where the webhook work pays off. The mobile app grants access only as accurately as your webhook handler and reconciliation job keep that field in sync with Stripe.

Check your storefront before relying on step 2. Outside the United States storefront, Apple's Guideline 3.1.3 is narrower than Base44's summary: it says apps under 3.1.3 "cannot, within the app, encourage users to use a purchasing method other than in-app purchase".

If the purchase has to happen inside the app, the remaining options are a native shell you own (StoreKit on iOS, Google Play Billing on Android) or a migration off Base44. The Base44 App Store rejection walkthrough covers Apple's storefront rules, the iOS shell, the migration path and the steps for answering a rejection.

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. The same class of breakage arrives from the platform side too — see a Stripe integration breaking after a Base44 platform update for what to re-verify after each release.

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.
  • No Stripe checkout for digital goods inside the App Store or Google Play build; those sell on the web (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. 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 in a Base44 iOS or Android app?
A.04

Not for digital goods, on either store. Base44's store-submission docs say Apple and Google both require their own billing systems for digital content, and 'If your app uses Stripe for digital content, your app is rejected.' Digital means subscription or premium tiers, paid features or content inside the app, and credits or extra AI generations. Base44 does not support StoreKit or Google Play Billing yet, and its docs say RevenueCat and Capacitor in-app purchase plugins do not work on its builds. The documented route is to sell on the web version of your app with Stripe and have the mobile app check who has paid. Stripe stays fine inside the app for physical goods, real-world bookings and services delivered outside the app. Base44 lists donations there too, but Apple lets only approved nonprofits collect charity donations inside an iOS app. The other options, including a native shell you own, are in our App Store rejection fix.

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.06Which Stripe-on-Base44 production bug should I guard against first?
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

Order a $497 base44 audit.

Delivered one business day after access (two for apps with 15+ tables or several integrations): 12 components, 110 pass/fail checks and a fix, rebuild or migrate verdict. The $497 fee is credited in full against any fix, build, migration or written-recommendation engagement booked within 30 days. If you ordered the audit for a specific bug and we cannot reproduce it, we refund the fee; otherwise it is non-refundable.