BASE44DEVS

FIX · INTEGRATIONS · CRITICAL

Base44 Customer Paid But Has No Access — Stripe Webhook Fix

When a Base44 customer pays but stays locked out, the Stripe webhook is either not firing, not authenticated correctly to your Base44 endpoint, or the role update happens but the user session is cached. Fix sequence: verify webhook delivery in the Stripe dashboard, confirm signature validation, then check the Base44 record update and session invalidation.

Last verified
2026-05-02
Category
INTEGRATIONS
Difficulty
MODERATE
DIY possible
YES

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.

  1. Open Stripe Dashboard → Developers → Webhooks → click your endpoint. Are there Recent attempts in the last hour? If no, you are in failure point 1.
  2. 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.
  3. Click a failed attempt and read the response body. If it says signature verification failed or Invalid Stripe-Signature, you are in failure point 2.
  4. If the response body is a generic 500 or your own error message, you are in failure point 3.
  5. 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.
  6. 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.
  7. Verify the event type. In Stripe Dashboard, Developers, Webhooks, your endpoint, Events. Is checkout.session.completed subscribed? Is invoice.payment_succeeded subscribed? Both should be there for a subscription product.
  8. 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.
  9. 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.
  10. Confirm the STRIPE_WEBHOOK_SECRET environment variable is set in your Base44 production environment, not just local. Open Settings, Environment variables, and verify the value starts with whsec_.

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 success
  • invoice.payment_succeeded — recurring renewal charges
  • customer.subscription.deleted — cancellations and failed-payment cancellations
EventWhen it firesWhat to do
checkout.session.completedCustomer finishes Checkout (first time)Grant role, link customerId to user
invoice.payment_succeededEach renewal succeedsExtend subscriptionEndsAt
invoice.payment_failedRenewal card declinesOptional: warn user, do not revoke yet
customer.subscription.deletedSubscription canceled or hard-failedRevoke role, set ended timestamp
customer.subscription.updatedPlan 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:

  1. 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.
  2. In 5 of 9, the team was only subscribed to checkout.session.completed and renewals were silently failing. New customers had access. Returning customers lost it on day 31.
  3. In 4 of 9, the user lookup ran against the wrong columncustomer_id, customerId, stripeCustomerId, and stripe_customer_id all 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.
  4. 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.
  5. 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.
  6. 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.

QUERIES

Frequently asked questions

Q.01How do I confirm Stripe is actually sending the webhook?
A.01

Open Stripe Dashboard, go to Developers, then Webhooks, then click your endpoint. The Recent attempts panel lists every delivery Stripe has made in the last 30 days with timestamps, HTTP status codes, and full request and response bodies. If you see 200 responses, Stripe sent the event and your function received it — the bug is downstream in your handler. If you see 4xx or 5xx responses, the function received the call but rejected it. If you see no recent attempts at all for a payment that just succeeded, either the event type is not subscribed on this endpoint, or the customer was on a different mode (test versus live). Toggle the View test data switch to compare. Stripe retries failed deliveries for up to 3 days at exponential backoff, so a missing 200 will keep retrying.

Q.02Why does the payment succeed but access stay locked?
A.02

Payment success and access grant are two separate systems. Stripe owns the payment. Your Base44 app owns access. The bridge between them is the webhook, and any of four links in that bridge can fail silently. The webhook may not fire because the event type was never subscribed. It may fire but get rejected because your signature validation is reading the parsed body instead of the raw body. The signature may pass but the Base44 record update fails because the user lookup ran on stripe_customer_id while the column is actually customerId. Or the record updates correctly but the user's already-loaded session is cached client-side and still believes the role is free. None of these surface a user-visible error, which is why customers escalate to support before you notice anything is wrong.

Q.03What's the right webhook event to listen for — checkout.session.completed or invoice.payment_succeeded?
A.03

Both, for different lifecycle moments. Use checkout.session.completed for the first purchase — it fires within roughly 200ms of the customer completing Stripe Checkout in 99.7% of cases and carries the full session including customer, subscription, and line items. Use invoice.payment_succeeded for every subsequent renewal — it fires monthly or yearly when Stripe charges the saved card. If you only listen to checkout.session.completed, your renewals will never extend access and customers will lose entitlement at the end of period one. If you only listen to invoice.payment_succeeded, the very first checkout takes 5 to 15 seconds longer to grant access because the invoice event lags the session event. Subscribe to both, and add customer.subscription.deleted to revoke access on cancellation.

Q.04How do I handle a customer who paid but the webhook silently failed?
A.04

First, identify the gap. In Stripe Dashboard, find the customer and copy their stripe customer ID. Pull the subscription object directly from the API and confirm its status is active. Then query your Base44 user record by that customer ID and check whether the role or entitlement field reflects the active subscription. If Stripe says active but Base44 says free, you have webhook gap. Fix it manually: run the same record-update logic your webhook handler runs, but invoke it from a one-off script with the subscription ID. Then replay the original webhook event from Stripe Dashboard so your audit log records the delivery. Finally, fix the underlying bug so the next customer is not affected. Always patch the customer first, root-cause second.

Q.05Should I poll Stripe instead of relying on webhooks?
A.05

No, with one exception. Webhooks are the right primary mechanism — they are push-based, near-real-time, and Stripe retries them for 3 days. Polling adds latency, costs more API calls, and creates a different class of consistency bugs. The exception is a reconciliation job: run a nightly script that lists every active subscription in Stripe, compares it against the entitlement field on every Base44 user record, and flags any drift. This catches the cases where a webhook silently failed and your monitoring missed it. Treat polling as a safety net behind webhooks, not a replacement for them. The reconciliation job typically takes 5 to 30 seconds per 1,000 customers and runs at 3am.

Q.06How do I test the full flow without real money?
A.06

Use Stripe test mode end to end. In your Stripe dashboard, toggle to Test mode in the top right. Create a test product and price. Use card number 4242 4242 4242 4242 with any future expiry and any CVC for a successful charge, or 4000 0000 0000 0002 for a card decline. The test mode webhook endpoint is configured separately from live, so set up a parallel endpoint pointing at your Base44 staging app. Trigger checkout.session.completed manually from Dashboard, Developers, Webhooks, your endpoint, Send test webhook to verify your handler responds 200. Use the Stripe CLI command stripe listen --forward-to to pipe live test events into your local development environment. Never share test keys with live keys — Stripe explicitly separates the two so you cannot accidentally charge a real customer during development.

NEXT STEP

Need this fix shipped this week?

Book a free 15-minute call or order a $497 audit. We will respond within one business day.