What's happening
A customer just paid you. Stripe sent them a receipt. Their card was charged. They click back into your Base44 app expecting the dashboard, the course, the gated content — and they see the same paywall they saw before they paid. They email support. You check Stripe and see the payment is real. You check your Base44 user record and the role field still says free. Nothing in your app threw an error. Nothing in your monitoring fired. The only signal that anything is wrong is the customer's frustration.
This is the single most common commercial-pain pattern we see in Base44 rescue work. It shows up across membership sites, course platforms, SaaS apps, and any app where money buys access. The Upwork postings for it are nearly verbatim across listings: "Connect and configure Stripe subscriptions; Set up / verify membership system and gated content; Ensure everything works end-to-end (payments → access → content)." Another: "Payments integration — making sure the checkout site reliably grants access to the course dashboard once payment is completed." The gap between "payment succeeded" and "access granted" is where money goes to die.
The damage compounds quickly. Customers who paid and cannot get in churn within 24 hours. Refund requests stack up. Support time balloons. Reviews mention "took my money and locked me out." All of it is preventable, and all of it is fixed by understanding which of four specific links in the chain is broken.
The four places this breaks
The chain from "Stripe payment succeeds" to "user sees unlocked content" has exactly four points of failure. Almost every Base44 access-not-granted bug we have diagnosed lives in one of them.
1. The webhook never fires. The event your handler is listening for was not configured on the Stripe endpoint, so Stripe never sends it. Common variant: you subscribed to checkout.session.completed but the customer is on a recurring subscription renewal, which fires invoice.payment_succeeded instead — and renewals stop extending access at month two. Another variant: the endpoint is configured in Stripe test mode, but the customer paid in live mode (or vice versa). The Stripe dashboard's Recent attempts panel is empty for the customer's payment, because Stripe genuinely never tried to deliver to your endpoint.
2. The webhook fires but signature validation fails. Stripe sends the event to your Base44 function. Your function reads the body, computes an HMAC against the Stripe-Signature header, and the signatures do not match. The function returns 400. Stripe records the failure and retries. The most common cause is reading request.json() (the parsed body) instead of request.text() (the raw body) — signature verification requires the exact byte string Stripe signed. Second most common: the STRIPE_WEBHOOK_SECRET environment variable is unset in production, so verification runs against an empty string.
3. The signature passes but the Base44 record update silently fails. Your handler verifies the signature, parses the event, looks up the user record by Stripe customer ID, and writes the new role. Something in that chain throws — but your handler swallows the error and still returns 200 to Stripe. Most common cause: looking up the user by stripe_customer_id when the column is customerId, returning zero results, and updating nothing. Stripe sees 200 and stops retrying.
4. The record updates but the cached user session still shows locked. The database is correct. A fresh login would unlock everything. But the user's existing session has an in-memory copy of the user object with role: "free", and your gating logic reads from that cached object. Until the session refreshes, the user remains locked even though their record is paid.
Diagnostic checklist
Run these in order. The first one that returns an unexpected result tells you which of the four failure points you are in.
- Open Stripe Dashboard → Developers → Webhooks → click your endpoint. Are there Recent attempts in the last hour? If no, you are in failure point 1.
- If there are attempts, what are the response codes? If 200s only, the webhook fired and was accepted — skip to step 5. If 4xx or 5xx, you are in failure point 2 or 3.
- Click a failed attempt and read the response body. If it says
signature verification failedorInvalid Stripe-Signature, you are in failure point 2. - If the response body is a generic 500 or your own error message, you are in failure point 3.
- If all attempts are 200, query your Base44 user record by the customer's email or Stripe customer ID. Is the role/entitlement field updated? If no, you are in failure point 3 — the handler returned 200 without writing.
- If the record is updated, ask the customer to fully sign out and sign back in. Does access work after a fresh login? If yes, you are in failure point 4.
- Verify the event type. In Stripe Dashboard, Developers, Webhooks, your endpoint, Events. Is
checkout.session.completedsubscribed? Isinvoice.payment_succeededsubscribed? Both should be there for a subscription product. - Verify mode. Look at the top right of Stripe Dashboard — is the customer's payment in Test or Live mode? Is your endpoint configured in the same mode? Mode mismatches account for roughly 1 in 8 of the cases we triage.
- In your Base44 function logs, search for the Stripe event ID. Does the handler log show the event was received? If yes but not processed, instrument the lookup query to log the customer ID it searched for and what it found.
- Confirm the
STRIPE_WEBHOOK_SECRETenvironment variable is set in your Base44 production environment, not just local. Open Settings, Environment variables, and verify the value starts withwhsec_.
By the end of the checklist you will know which of the four failure points you are in. Fix only that one — guessing across all four wastes hours.
The fix — by failure point
Each failure point has a specific fix. Apply only the one that matches your diagnosis.
Fix 1 — Webhook never fires (subscribe the right events)
Open Stripe Dashboard → Developers → Webhooks → your endpoint → Update details → Select events. Subscribe to all three:
checkout.session.completed— first purchase, fires within ~200ms of payment successinvoice.payment_succeeded— recurring renewal chargescustomer.subscription.deleted— cancellations and failed-payment cancellations
| Event | When it fires | What to do |
|---|---|---|
checkout.session.completed | Customer finishes Checkout (first time) | Grant role, link customerId to user |
invoice.payment_succeeded | Each renewal succeeds | Extend subscriptionEndsAt |
invoice.payment_failed | Renewal card declines | Optional: warn user, do not revoke yet |
customer.subscription.deleted | Subscription canceled or hard-failed | Revoke role, set ended timestamp |
customer.subscription.updated | Plan changed (upgrade/downgrade) | Update tier on user record |
If you only listen to checkout.session.completed, renewals silently stop extending access. If you only listen to invoice.payment_succeeded, first-time access can lag by 5–15 seconds. Subscribe to both.
Fix 2 — Signature validation (verify against the raw body)
The single most common bug in this category: parsing the body before verifying.
// functions/stripe-webhook.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-06-20",
});
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(request: Request) {
// CRITICAL: read the raw body, not request.json().
// Stripe signs the exact byte string and any reparse breaks verification.
const rawBody = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) {
return new Response("Missing signature", { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
} catch (err) {
console.error("Stripe signature verification failed:", err);
return new Response("Invalid signature", { status: 400 });
}
// Now safe to process event.
await handleEvent(event);
return new Response("OK", { status: 200 });
}
If STRIPE_WEBHOOK_SECRET is missing from the production environment, constructEvent will throw with a confusing "No signatures found matching the expected signature" error. Verify the variable is set with whsec_ prefix in Base44 settings, not just in .env.local.
Fix 3 — Record update with idempotency and a transaction
Look up the user by the field you actually wrote at signup time. Add an idempotency check so Stripe retries do not double-process. Wrap the entitlement write so it cannot half-succeed.
async function handleEvent(event: Stripe.Event) {
// Idempotency: skip if we already processed this event.
const existing = await base44.entities.WebhookEvent.list({
filter: { stripeEventId: event.id },
limit: 1,
});
if (existing.length > 0) return;
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const customerId = session.customer as string;
const subscriptionId = session.subscription as string;
// Look up the user. Match the field your signup actually wrote.
const users = await base44.entities.User.list({
filter: { stripeCustomerId: customerId },
limit: 1,
});
if (users.length === 0) {
console.error(`No user for stripeCustomerId=${customerId}`);
throw new Error("User not found"); // do NOT silently 200 here
}
await base44.entities.User.update(users[0].id, {
role: "paid",
subscriptionId,
subscriptionStatus: "active",
sessionVersion: (users[0].sessionVersion ?? 0) + 1, // bumps cache
});
await base44.entities.WebhookEvent.create({
stripeEventId: event.id,
eventType: event.type,
processedAt: new Date().toISOString(),
});
}
}
Three details matter here. First, the lookup field must match what you wrote at signup — a single stripe_customer_id versus stripeCustomerId typo costs every customer their access. Second, throw on user-not-found instead of swallowing — let Stripe retry and let your monitoring fire. Third, increment sessionVersion on the user record. That field is the bridge into Fix 4.
Fix 4 — Invalidate the cached session
The user is paid in the database but their browser still believes they are free. On every authenticated request, compare the session's stored sessionVersion against the user record's current sessionVersion. When they differ, force a refresh.
export async function getCurrentUser() {
const session = await getSession();
if (!session) return null;
const user = await base44.entities.User.get(session.userId);
// Session was issued before the latest entitlement change. Refresh it.
if (user.sessionVersion !== session.sessionVersion) {
await refreshSession({
userId: user.id,
role: user.role,
sessionVersion: user.sessionVersion,
});
}
return user;
}
If you cannot edit session middleware, fall back to forcing a sign-out-and-back-in immediately after granting access — but this is a worse user experience and only works for the first purchase, not for renewals or upgrades.
What we've seen across membership-site engagements
Patterns from our last 9 Stripe-on-Base44 rescue engagements:
- In 6 of 9, the bug was failure point 2 — signature validation against an environment variable (
STRIPE_WEBHOOK_SECRET) that was set locally but never added to the production environment. The handler worked perfectly in dev and broke silently in prod. - In 5 of 9, the team was only subscribed to
checkout.session.completedand renewals were silently failing. New customers had access. Returning customers lost it on day 31. - In 4 of 9, the user lookup ran against the wrong column —
customer_id,customerId,stripeCustomerId, andstripe_customer_idall appeared in different parts of the same codebase, and the webhook handler picked the wrong one. The Base44 AI agent regenerated the column name twice across iterations. - In 3 of 9, mode mismatch — the team had pasted a live webhook secret into a test endpoint or vice versa. Every signature validation failed because the secret did not match the events being delivered.
- In 3 of 9, no idempotency — Stripe's automatic retries (up to 3 days, exponential backoff) had double-granted access when the first delivery threw mid-process and the second succeeded. One team had granted free months to ~40 customers because of double-processing on a partial failure.
- In 2 of 9, session caching — the database was correct from the first webhook, but users had to sign out and back in to see access. Customers reported the bug; the team's diagnostics confirmed "the database is fine" and they closed the ticket without fixing the cache layer.
The lesson is that the bug class is narrow but the specific instance is almost always a small thing: a missing env var, a typo'd column, a missed event subscription. The diagnostic checklist exists to find that small thing in 30 minutes instead of 30 hours.
When to use Stripe Checkout vs Stripe Elements vs Stripe Billing Portal on Base44
For 90% of Base44 membership sites, Stripe Checkout is the right primary purchase flow. It is hosted, PCI-compliant by default, supports Apple Pay and Google Pay out of the box, handles 3D Secure automatically, and reduces your Base44 frontend code to a single redirect. The trade-off is less visual customization — but for membership sites the conversion impact is negligible.
Use Stripe Elements only when you need the checkout UI fully embedded in your branded page and you have the engineering bandwidth to handle PCI scope (your team must keep the page assets compliant) and 3D Secure flows manually. On Base44 specifically, Elements is harder because the platform's frontend constraints make custom payment UI fragile across AI-agent regenerations. Avoid Elements unless you have a specific reason.
The Stripe Billing Portal is a separate decision and the answer is almost always yes. Use it for "Manage your subscription" — cancellations, plan changes, payment method updates, invoice downloads. It is one redirect, costs no engineering time, and removes a class of customer support tickets. On Base44, hand-rolling a billing settings page on top of the Stripe API is rarely worth the effort. Link to the portal from your account page and move on. Combine Checkout for the buy flow, the Billing Portal for management, and webhooks for entitlement — that is the production-ready stack for a Base44 membership site.
Need this fixed this week?
We restore broken Stripe-to-Base44 access flows in 24–72 hours. Standard scope: diagnostic across all four failure points, fix the one that broke, harden the other three, replay missed webhooks, reconcile any customers stuck without access, set up Stripe-side alerting and a nightly reconciliation job. Flat fix-sprint pricing.
Book a fix sprint — or read the full Base44 Stripe integration guide and the Base44 debugging help overview.
Related problems
- Stripe integration breaks after platform updates — when payments worked yesterday and broke after a Base44 update.
- Webhooks require active users — overnight Stripe events that vanish because no user is logged in.
- SSO bypass and auth vulnerabilities — when role and entitlement logic share root causes with auth misconfiguration.