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:
- 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.
- 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.
- 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
WebhookLogentity. - Map Stripe customer to Base44 user. Use the
stripe_customer_idfield 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 4242for successful payments. - Test card
4000 0000 0000 9995for declined payments. - Test card
4000 0000 0000 0341for 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 sell | Stripe inside the store app? | Where the payment belongs |
|---|---|---|
| Premium or subscription tiers | No | The web version of your app |
| Paid features or content inside the app | No | The web version of your app |
| Virtual currency, credits or extra AI generations | No | The web version of your app |
| A physical product you ship | Yes | In the app, through Stripe |
| Booking a real-world service, such as an appointment or a table | Yes | In the app, through Stripe |
| A service delivered outside the app, such as coaching | Yes | In the app, through Stripe |
| Donations | Yes, 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:
- Sell the subscription on the web version of your app, through the Checkout Session and webhook functions above.
- 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.
- Have the mobile app check who has paid (the
subscription_statusyour 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.
Related reading
- Base44 Webhooks Complete Guide — the broader webhook patterns that compose with the Stripe-specific guidance.
- Base44 Authentication Patterns — auth flows that interact with the customer creation flow.
- Add subscription billing to your Base44 app — going beyond one-time payments to recurring plans on top of this Stripe foundation.
- Base44 Error Reference — the integration-specific errors you'll hit during Stripe development.