Nurture TechnologiesNurture Tech
Integrations12 min read·July 24, 2026

Payment Succeeded but User Access Was Never Activated

StripeNode.jsTypeScriptPostgreSQLNext.js

Payment goes through on Stripe's end, but the user's account tier never changes. This is almost always a webhook event type mismatch combined with no fallback synchronization. Here is the full diagnosis.

Problem Summary

After a successful payment, your application must receive a Stripe webhook event, map it to the correct user, update the user's access tier, and provision the appropriate permissions all reliably, even if the initial webhook delivery fails. When any step in this chain breaks silently, the user pays but nothing changes on their account. This incident type is particularly painful because the customer sees a success page and expects their upgrade to be instant.

Symptoms

  • User reports being charged but still sees the free tier UI after refreshing
  • Stripe shows payment_intent.succeeded but no corresponding access change in your logs
  • User is looked up by email in webhook handler but lookup returns null due to case sensitivity mismatch
  • Webhook fires, user is found, but database transaction throws and is silently swallowed
  • Access tier updated in database but application server has cached the old tier for 30 minutes
  • Multiple concurrent webhook deliveries result in a race condition some access updates overwrite others
  • Handler processes checkout.session.completed but never reads the correct metadata field to identify the user
  • Access control middleware reads from a stale in-memory cache, not the live database

Business Impact

Users who pay and do not receive access have the worst possible first impression of your premium product. The conversion funnel inverted: you captured their payment intent, closed the sale, and then failed to deliver. Refund rates, chargeback rates, and negative reviews all spike. For SaaS products, this pattern often causes users to request refunds immediately rather than waiting for support to manually provision their account.

Root Cause

The most common root causes, roughly in order of frequency: (1) the webhook handler looks up users by email, but the email in the Stripe event does not match the email in the database (different casing, different account); (2) the handler catches an exception but does not re-throw it, causing Stripe to receive HTTP 200 and never retry even though nothing was provisioned; (3) the access tier is updated in the database but an application-layer cache is not invalidated.

Never look up users by email in webhook handlers. Email addresses can change, can be case-mismatched, or can belong to multiple accounts. Always look up by Stripe customer ID (cus_XXXXX), stored in your database at customer creation time.

Diagnosis

Step 1 Confirm Webhook Is Received and Handler Runs

Add structured logging at the very first line of your webhook handler, before any business logic. This confirms whether the event is arriving at your server.

src/webhooks/access-handler.ts
import Stripe from "stripe";

export async function handlePaymentSucceeded(event: Stripe.Event) {
  // Log immediately  before any logic that could fail
  console.log(
    JSON.stringify({
      level: "info",
      message: "Stripe event received",
      eventId: event.id,
      eventType: event.type,
      timestamp: new Date().toISOString(),
    })
  );

  const paymentIntent = event.data.object as Stripe.PaymentIntent;

  const stripeCustomerId = paymentIntent.customer as string;
  const userId = paymentIntent.metadata?.userId;

  if (!userId) {
    console.error(
      `[Access] No userId in metadata for payment intent ${paymentIntent.id}`
    );
    // Still return 200  this is a data problem, not a delivery problem
    return;
  }

  // Look up by Stripe customer ID or metadata userId  NOT by email
  const user = await db.users.findFirst({
    where: {
      OR: [
        { stripeCustomerId },
        { id: userId },
      ],
    },
  });

  if (!user) {
    console.error(
      `[Access] User not found: customerId=${stripeCustomerId}, userId=${userId}`
    );
    throw new Error(`User not found for payment intent ${paymentIntent.id}`);
    // Throw causes HTTP 500 → Stripe retries delivery
  }

  console.log(`[Access] Provisioning access for user ${user.id}`);
  await provisionAccess(user.id, paymentIntent);
}

The reliable way to map Stripe events to users is to create or retrieve a Stripe Customer at registration time and store the customer ID. Every Stripe event includes the customer ID.

src/services/stripe-customer.ts
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-06-20",
});

export async function getOrCreateStripeCustomer(user: {
  id: string;
  email: string;
  name: string;
}): Promise<string> {
  // Check if we already have a customer ID
  const existing = await db.users.findUnique({
    where: { id: user.id },
    select: { stripeCustomerId: true },
  });

  if (existing?.stripeCustomerId) {
    return existing.stripeCustomerId;
  }

  // Create a new Stripe customer with your internal user ID in metadata
  const customer = await stripe.customers.create({
    email: user.email,
    name: user.name,
    metadata: {
      userId: user.id, // critical  allows webhook → user mapping
    },
  });

  await db.users.update({
    where: { id: user.id },
    data: { stripeCustomerId: customer.id },
  });

  return customer.id;
}

Step 3 Implement Access Provisioning with Idempotency

src/services/access-provisioning.ts
import Stripe from "stripe";

type Tier = "free" | "pro" | "enterprise";

const PRICE_TO_TIER: Record<string, Tier> = {
  [process.env.PRICE_ID_PRO!]: "pro",
  [process.env.PRICE_ID_ENTERPRISE!]: "enterprise",
};

export async function provisionAccess(
  userId: string,
  paymentIntent: Stripe.PaymentIntent
): Promise<void> {
  const priceId = paymentIntent.metadata?.priceId;

  if (!priceId) {
    throw new Error(
      `No priceId in payment intent metadata: ${paymentIntent.id}`
    );
  }

  const tier = PRICE_TO_TIER[priceId];

  if (!tier) {
    throw new Error(`Unknown priceId: ${priceId}`);
  }

  // Idempotency: check if this payment intent was already processed
  const alreadyProcessed = await db.accessGrants.findUnique({
    where: { stripePaymentIntentId: paymentIntent.id },
  });

  if (alreadyProcessed) {
    console.log(
      `[Access] Already provisioned for payment intent ${paymentIntent.id}`
    );
    return;
  }

  await db.$transaction(async (tx) => {
    await tx.users.update({
      where: { id: userId },
      data: {
        tier,
        tierActivatedAt: new Date(),
      },
    });

    await tx.accessGrants.create({
      data: {
        userId,
        tier,
        stripePaymentIntentId: paymentIntent.id,
        grantedAt: new Date(),
      },
    });
  });

  // Invalidate any cached session / access tokens
  await invalidateUserCache(userId);

  console.log(`[Access] User ${userId} upgraded to ${tier}`);
}

Step 4 Attach PriceId and UserId to PaymentIntent Metadata

src/api/create-payment-intent.ts
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-06-20",
});

export async function createUpgradePaymentIntent(
  userId: string,
  priceId: string
): Promise<Stripe.PaymentIntent> {
  const stripeCustomerId = await getOrCreateStripeCustomer({ id: userId });
  const price = await stripe.prices.retrieve(priceId);

  const intent = await stripe.paymentIntents.create({
    amount: price.unit_amount!,
    currency: price.currency,
    customer: stripeCustomerId,
    metadata: {
      userId,
      priceId,
    },
    automatic_payment_methods: {
      enabled: true,
    },
  });

  return intent;
}

Step 5 Invalidate Application Caches After Provisioning

Even with a correctly updated database, users can still see stale access levels if your application caches session data. Invalidate any Redis, in-memory, or session-store cached access after provisioning.

src/services/cache-invalidation.ts
import { redis } from "../lib/redis";

export async function invalidateUserCache(userId: string): Promise<void> {
  // Invalidate Redis-cached user session
  const sessionKey = `user:session:${userId}`;
  const tierKey = `user:tier:${userId}`;

  await redis.del(sessionKey, tierKey);

  console.log(`[Cache] Invalidated cache for user ${userId}`);
}

Production Failure Scenarios

Scenario 1 Exception Swallowed, HTTP 200 Returned

The webhook handler wraps business logic in try/catch and catches the database exception, logs it, but still returns HTTP 200. Stripe receives 200 and considers the delivery successful. No retry is scheduled. The user never gets access, and the system shows no evidence of a problem.

Scenario 2 Email Lookup Fails Due to Case Mismatch

User registered with 'User@Example.com'. Stripe stores the customer email as 'user@example.com'. Webhook handler does a case-sensitive SQL WHERE email = ? and finds nothing. Handler logs 'user not found' and returns 200. Access is never granted.

Scenario 3 Cache Not Invalidated After Access Grant

Database is updated correctly. User refreshes the page. The Next.js server reads from a 30-minute Redis cache that was populated before the upgrade. The user still sees the free tier. They think the upgrade failed, open a support ticket, and consider requesting a refund.

Prevention Checklist

  • Always store the Stripe customer ID against your user record at registration time
  • Look up users by stripeCustomerId or metadata.userId never by email
  • Attach userId and priceId to every PaymentIntent's metadata field
  • Re-throw exceptions in webhook handlers so Stripe receives a non-2xx and retries
  • Implement idempotency by recording processed payment intent IDs
  • Invalidate all user-related caches immediately after provisioning access
  • Run a daily reconciliation job comparing Stripe charges to access grants
  • Log the event.id, user.id, and outcome of every provisioning attempt

Resolution Summary

Store Stripe customer IDs at registration. Pass userId and priceId in PaymentIntent metadata. Look up users by customer ID in webhook handlers. Re-throw exceptions to trigger Stripe retries. Implement idempotency on payment intent IDs. Invalidate caches after provisioning. Add a daily reconciliation check as a safety net.

Lessons Learned

  • Email is a mutable, case-insensitive, non-unique identifier never use it as a Stripe-to-user mapping key
  • Swallowed exceptions that return HTTP 200 are the most dangerous bug pattern in webhook handlers
  • Access control caches must be invalidated synchronously as part of the provisioning transaction
  • Metadata on PaymentIntents is the most reliable way to carry context from payment creation to webhook delivery
  • Idempotency + retry = exactly-once semantics, which is what payment systems require

Conclusion

The gap between 'payment succeeded' and 'user upgraded' is bridged by reliable webhook delivery, correct user lookup, exception propagation, idempotency, and cache invalidation. Each of these is a distinct failure point. Address all five and the access provisioning pipeline becomes robust enough to handle Stripe retries, concurrent deliveries, and deployment-time cache staleness.

Nurture Technologies

NEED HELP WITH YOUR STACK?

Nurture Technologies builds and maintains production-quality software for startups and businesses. If engineering problems are slowing you down, our team can help.

Talk to our team →
FAQ

FREQUENTLY ASKED QUESTIONS

Why is looking up users by email in a webhook handler unreliable?+

Email addresses can change over time, can be case-mismatched between Stripe and your database, can belong to multiple accounts, or can be absent from certain Stripe event payloads. The Stripe customer ID (cus_XXXXX) is immutable and unique always use it for lookups.

How do I attach my user ID to a Stripe payment?+

When creating a PaymentIntent or Checkout Session, pass metadata: { userId: 'your-user-id' }. This metadata is included verbatim in the webhook event payload. For subscriptions, add the metadata to the subscription creation call as well.

Should my webhook handler throw exceptions or catch them?+

Throw exceptions (or return a non-2xx HTTP response) for any transient failure you want Stripe to retry database unavailable, external API timeout, etc. Only return HTTP 200 when you have confirmed the business logic completed successfully. Catch and suppress only permanent failures (e.g., duplicate event you already processed).

What is the safest way to implement idempotency for access grants?+

Create an access_grants table with a unique constraint on stripe_payment_intent_id. Before processing, check if a row exists with that ID. If yes, return early. If no, insert the row and update the user's access tier in a single database transaction.

How do I invalidate a Next.js session cache after access provisioning?+

It depends on your session strategy. For JWT sessions, issue a new token with the updated tier and set it as a cookie. For database sessions (NextAuth), update the session record. For Redis caches, delete all keys associated with the user. The success page should force a session refresh.

What if a user pays but never receives access and requests a refund?+

Provision the access manually via your admin tools, then investigate the root cause in your webhook logs. Do not issue a refund for a successful delivery failure on your side instead, fix the bug and provision what the user paid for. Refund only if you cannot deliver.

Should I use payment_intent.succeeded or checkout.session.completed for access grants?+

Use whichever event corresponds to your integration. If using Stripe Checkout, use checkout.session.completed. If using Payment Intents directly (custom payment form), use payment_intent.succeeded. Do not listen to both for the same purchase without idempotency, or you will double-provision.

How can I monitor for users who paid but were not provisioned?+

Run a daily query comparing your payment records (or Stripe charges) to your access_grants table. Any charge with no corresponding access grant is a provisioning failure. Alert on this query and investigate immediately.

What happens if Stripe sends the webhook before my database commit finishes?+

This is extremely rare since Stripe sends the webhook after confirming the charge, giving your server time to process. But if it occurs, the webhook lookup will find the user record. The access provisioning runs and commits. Return HTTP 500 if the database is not ready; Stripe will retry.

How do I test access provisioning end-to-end in development?+

Use the Stripe CLI: stripe listen --forward-to localhost:3000/api/webhooks, then stripe trigger payment_intent.succeeded. Verify your handler fires, finds the test user (pre-seed test data with a known Stripe customer ID), and updates the access tier. Check the database directly to confirm.