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
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.
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 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. 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:
- Subscriptions purchased in the iOS app must use StoreKit.
- Subscriptions purchased on the web (and accessed via the iOS app) can continue to use Stripe.
- 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.
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.
- Base44 Error Reference — the integration-specific errors you'll hit during Stripe development.