Menu

Webhook Handling Mechanism

Webhooks are an essential component of the payment system, allowing payment providers to proactively notify your application when payment events occur. This document details the webhook handling mechanism for Stripe and Creem.

Webhook Overview

What is a Webhook?

A webhook is an HTTP POST request sent by payment providers to your application to notify you of payment-related events, such as:

  • Payment success
  • Subscription creation/update/cancellation
  • Refund processing
  • Invoice payment failure

Why Do We Need Webhooks?

  1. Reliability: Even if frontend validation fails, webhooks ensure orders are processed correctly
  2. Real-time: Payment providers can immediately notify your application
  3. Completeness: Handle all payment-related events, including renewals, cancellations, etc.

Stripe Webhook

Webhook Endpoint

URL: /api/stripe/webhook

Method: POST

Signature Verification

All Stripe webhook requests must verify signatures to ensure security:

export async function POST(req: Request) {
  const body = await req.text();
  const headersList = await headers();
  const sig = headersList.get('stripe-signature');
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
 
  if (!sig || !webhookSecret) {
    return apiResponse.badRequest('Webhook secret not configured');
  }
 
  // Verify signature
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
  } catch (err) {
    return apiResponse.badRequest(`Webhook Error: ${err.message}`);
  }
 
  // Process event
  await processWebhookEvent(event);
}

Supported Event Types

The system handles the following Stripe webhook events:

1. checkout.session.completed

Trigger: User completes checkout (one-time payments only)

Processing Logic:

app/api/stripe/webhook/webhook-handlers.ts
export async function handleCheckoutSessionCompleted(
  session: Stripe.Checkout.Session
) {
  // Only process one-time payments
  if (session.mode !== 'payment') return;
 
  const userId = session.metadata?.userId;
  const planId = session.metadata?.planId;
  const priceId = session.metadata?.priceId;
 
  // Create order record
  const orderData = {
    userId,
    provider: 'stripe',
    providerOrderId: session.payment_intent,
    orderType: 'one_time_purchase',
    status: 'succeeded',
    planId,
    priceId,
    amountTotal: toCurrencyAmount(session.amount_total),
    // ... other fields
  };
 
  const { order, existed } = await createOrderWithIdempotency(
    'stripe',
    orderData,
    session.payment_intent
  );
 
  if (!existed && order) {
    // Grant one-time credits
    await upgradeOneTimeCredits(userId, planId, order.id);
  }
}

2. invoice.paid

Trigger: Subscription invoice payment succeeded (including initial payment and renewals)

Processing Logic:

app/api/stripe/webhook/webhook-handlers.ts
export async function handleInvoicePaid(invoice: Stripe.Invoice) {
  const subscriptionId = invoice.subscription as string;
  const customerId = invoice.customer as string;
 
  // Sync subscription data
  await syncSubscriptionData(subscriptionId, customerId);
 
  // If it's a renewal invoice, create order and grant credits
  if (invoice.billing_reason === 'subscription_cycle') {
    const subscription = await stripe.subscriptions.retrieve(subscriptionId);
    const userId = subscription.metadata?.userId;
    const planId = subscription.metadata?.planId;
 
    // Create renewal order
    const orderData = {
      userId,
      provider: 'stripe',
      providerOrderId: invoice.payment_intent,
      orderType: 'subscription_renewal',
      status: 'succeeded',
      planId,
      subscriptionId,
      amountTotal: toCurrencyAmount(invoice.amount_paid),
      // ... other fields
    };
 
    const { order, existed } = await createOrderWithIdempotency(
      'stripe',
      orderData,
      invoice.payment_intent
    );
 
    if (!existed && order) {
      // Grant subscription credits (reset monthly credits)
      const currentPeriodStart = subscription.current_period_start * 1000;
      await upgradeSubscriptionCredits(
        userId,
        planId,
        order.id,
        currentPeriodStart
      );
    }
  }
}

3. customer.subscription.created / customer.subscription.updated

Trigger: Subscription created or updated

Processing Logic:

app/api/stripe/webhook/webhook-handlers.ts
export async function handleSubscriptionUpdate(
  subscription: Stripe.Subscription
) {
  const customerId = typeof subscription.customer === 'string'
    ? subscription.customer
    : subscription.customer.id;
 
  // Sync subscription data to database
  await syncSubscriptionData(subscription.id, customerId);
}

4. customer.subscription.deleted

Trigger: Subscription canceled or deleted

Processing Logic:

app/api/stripe/webhook/webhook-handlers.ts
export async function handleSubscriptionUpdate(
  subscription: Stripe.Subscription,
  isDeleted: boolean = false
) {
  if (isDeleted) {
    // Sync subscription status
    await syncSubscriptionData(subscription.id, customerId);
 
    // Revoke remaining subscription credits
    const userId = subscription.metadata?.userId;
    if (userId) {
      await revokeRemainingSubscriptionCreditsOnEnd(
        'stripe',
        subscription.id,
        userId,
        subscription.metadata
      );
    }
  }
}

5. invoice.payment_failed

Trigger: Subscription invoice payment failed

Processing Logic:

app/api/stripe/webhook/webhook-handlers.ts
export async function handleInvoicePaymentFailed(
  invoice: Stripe.Invoice
) {
  const subscriptionId = invoice.subscription as string;
  const customerId = invoice.customer as string;
 
  // Send payment failure notification email
  await sendInvoicePaymentFailedEmail({
    invoice,
    subscriptionId,
    customerId,
    invoiceId: invoice.id
  });
}

6. charge.refunded

Trigger: Payment refunded

Processing Logic:

app/api/stripe/webhook/webhook-handlers.ts
export async function handleRefund(charge: Stripe.Charge) {
  const paymentIntentId = charge.payment_intent as string;
 
  // Find original order
  const originalOrder = await findOriginalOrderForRefund(
    'stripe',
    paymentIntentId
  );
 
  if (!originalOrder) {
    console.warn(`Original order not found for refund: ${paymentIntentId}`);
    return;
  }
 
  // Check if refund order already exists
  const refundId = charge.refunds.data[0]?.id;
  if (await refundOrderExists('stripe', refundId)) {
    return; // Already processed
  }
 
  // Create refund order
  const refundOrderData = {
    userId: originalOrder.userId,
    provider: 'stripe',
    providerOrderId: refundId,
    orderType: 'refund',
    status: 'succeeded',
    planId: originalOrder.planId,
    amountTotal: `-${toCurrencyAmount(charge.amount_refunded)}`,
    // ... other fields
  };
 
  await createOrderWithIdempotency('stripe', refundOrderData, refundId);
 
  // Update original order status
  await updateOrderStatusAfterRefund(
    originalOrder.id,
    charge.amount_refunded,
    toCents(originalOrder.amountTotal)
  );
 
  // Revoke credits
  if (originalOrder.orderType === 'one_time_purchase') {
    await revokeOneTimeCredits(
      charge.amount_refunded,
      originalOrder,
      refundId
    );
  } else {
    await revokeSubscriptionCredits(originalOrder);
  }
}

7. radar.early_fraud_warning.created

Trigger: Stripe Radar detects potential fraudulent activity

Processing Logic:

app/api/stripe/webhook/webhook-handlers.ts
export async function handleEarlyFraudWarningCreated(
  warning: Stripe.Radar.EarlyFraudWarning
) {
  const chargeId = warning.charge as string;
  const charge = await stripe.charges.retrieve(chargeId);
 
  // Send admin warning email
  await sendFraudWarningAdminEmail({
    warningId: warning.id,
    chargeId,
    customerId: charge.customer as string,
    amount: charge.amount,
    currency: charge.currency,
    fraudType: warning.actionable ? 'Actionable' : 'Informational',
    chargeDescription: charge.description,
    actionsTaken: ['Email sent to admin']
  });
 
  // If confirmed as fraud, automatically refund
  if (warning.actionable) {
    // Execute refund
    const refund = await stripe.refunds.create({ charge: chargeId });
    
    // Send user refund notification
    await sendFraudRefundUserEmail({
      charge,
      refundAmount: refund.amount
    });
  }
}

Creem Webhook

Webhook Endpoint

URL: /api/creem/webhook

Method: POST

Signature Verification

Creem webhooks use HMAC-SHA256 signature verification:

app/api/creem/webhook/route.ts
function verifySignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const crypto = require('crypto');
  const computedSignature = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');
  return computedSignature === signature;
}
 
export async function POST(req: Request) {
  const rawBody = await req.text();
  const headersList = await headers();
  const signature = headersList.get('creem-signature');
  const secret = process.env.CREEM_WEBHOOK_SECRET;
 
  if (!verifySignature(rawBody, signature, secret)) {
    return apiResponse.badRequest('Signature verification failed');
  }
 
  const payload = JSON.parse(rawBody);
  await processWebhookEvent(payload);
}

Supported Event Types

1. checkout.completed

Trigger: One-time payment completed

Processing Logic:

app/api/creem/webhook/handlers.ts
export async function handleCreemPaymentSucceeded(
  payload: CreemCheckoutCompletedEvent
) {
  const payment = payload.object;
  const order = payment.order;
 
  // Only process one-time payments
  if (order.type !== 'onetime') return;
 
  const userId = payment.metadata.userId;
  const planId = payment.metadata.planId;
 
  // Create order record
  const orderData = {
    userId,
    provider: 'creem',
    providerOrderId: order.id,
    orderType: 'one_time_purchase',
    status: payment.status === 'completed' ? 'succeeded' : payment.status,
    planId,
    amountTotal: toCurrencyAmount(order.amount_paid),
    // ... other fields
  };
 
  const { order: insertedOrder, existed } = await createOrderWithIdempotency(
    'creem',
    orderData,
    order.id
  );
 
  if (!existed && insertedOrder) {
    // Grant one-time credits
    await upgradeOneTimeCredits(userId, planId, insertedOrder.id);
  }
}

2. subscription.paid

Trigger: Subscription payment succeeded (including initial payment and renewals)

Processing Logic:

app/api/creem/webhook/handlers.ts
export async function handleCreemInvoicePaid(
  payload: CreemSubscriptionPaidEvent
) {
  const subscription = payload.object;
  const lastTransaction = subscription.last_transaction;
  const orderId = lastTransaction.order;
 
  const userId = subscription.metadata.userId;
  const planId = subscription.metadata.planId;
 
  // Create order record
  const orderData = {
    userId,
    provider: 'creem',
    providerOrderId: orderId,
    orderType: 'recurring',
    status: lastTransaction.status === 'paid' ? 'succeeded' : lastTransaction.status,
    planId,
    subscriptionId: subscription.id,
    amountTotal: toCurrencyAmount(lastTransaction.amount_paid),
    // ... other fields
  };
 
  const { order: insertedOrder, existed } = await createOrderWithIdempotency(
    'creem',
    orderData,
    orderId
  );
 
  if (!existed && insertedOrder) {
    // Grant subscription credits
    const currentPeriodStart = new Date(subscription.current_period_start_date).getTime();
    await upgradeSubscriptionCredits(
      userId,
      planId,
      insertedOrder.id,
      currentPeriodStart
    );
  }
}

3. subscription.active / subscription.update

Trigger: Subscription activated or updated

Processing Logic:

app/api/creem/webhook/handlers.ts
export async function handleCreemSubscriptionUpdated(
  payload: CreemSubscriptionUpdateEvent | CreemSubscriptionActiveEvent
) {
  const subscription = payload.object;
  
  // Sync subscription data
  await syncCreemSubscriptionData(
    subscription.id,
    subscription.metadata
  );
}

4. subscription.canceled / subscription.expired

Trigger: Subscription canceled or expired

Processing Logic:

app/api/creem/webhook/handlers.ts
export async function handleCreemSubscriptionUpdated(
  payload: CreemSubscriptionCanceledEvent | CreemSubscriptionExpiredEvent,
  isDeleted: boolean = false
) {
  const subscription = payload.object;
 
  // Sync subscription status
  await syncCreemSubscriptionData(subscription.id, subscription.metadata);
 
  if (isDeleted) {
    // Revoke remaining subscription credits
    let userId = subscription.metadata?.userId;
    if (!userId) {
      // Query user ID from database
      const storeSubscription = await db
        .select({ userId: subscriptionsSchema.userId })
        .from(subscriptionsSchema)
        .where(eq(subscriptionsSchema.subscriptionId, subscription.id))
        .limit(1);
      userId = storeSubscription[0]?.userId;
    }
 
    if (userId) {
      await revokeRemainingSubscriptionCreditsOnEnd(
        'creem',
        subscription.id,
        userId,
        subscription.metadata
      );
    }
  }
}

5. refund.created

Trigger: Refund created

Processing Logic:

app/api/creem/webhook/handlers.ts
export async function handleCreemPaymentRefunded(
  payload: CreemRefundCreatedEvent
) {
  const refund = payload.object;
  const orderId = refund.order;
 
  // Check if refund order already exists
  if (await refundOrderExists('creem', refund.id)) {
    return; // Already processed
  }
 
  // Find original order
  const originalOrder = await findOriginalOrderForRefund('creem', orderId);
 
  if (!originalOrder) {
    console.warn(`Original order not found for refund: ${orderId}`);
    return;
  }
 
  // Create refund order
  const refundOrderData = {
    userId: originalOrder.userId,
    provider: 'creem',
    providerOrderId: refund.id,
    orderType: 'refund',
    status: 'succeeded',
    planId: originalOrder.planId,
    amountTotal: `-${toCurrencyAmount(refund.amount)}`,
    // ... other fields
  };
 
  await createOrderWithIdempotency('creem', refundOrderData, refund.id);
 
  // Update original order status
  await updateOrderStatusAfterRefund(
    originalOrder.id,
    refund.amount,
    toCents(originalOrder.amountTotal)
  );
 
  // Revoke credits
  if (originalOrder.orderType === 'one_time_purchase') {
    await revokeOneTimeCredits(
      refund.amount,
      originalOrder,
      refund.id
    );
  } else {
    await revokeSubscriptionCredits(originalOrder);
  }
}

Webhook Testing

Stripe Webhook Testing

  1. Using Stripe CLI:
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
 
# Login
stripe login
 
# Forward webhooks to local
stripe listen --forward-to localhost:3000/api/stripe/webhook
 
# Trigger test event
stripe trigger checkout.session.completed
  1. Using Stripe Dashboard:
  • Go to Stripe Dashboard > Developers > Webhooks
  • Add webhook endpoint
  • Send test events

Creem Webhook Testing

  1. Using Creem Dashboard:
  • Go to Creem Dashboard > Settings > Webhooks
  • Add webhook URL
  • Send test events
  1. Local Testing:

Use VS Code/Cursor's Ports feature or tools like ngrok to expose your local service to the public network:

ngrok http 3000
# Use the URL provided by ngrok to configure the webhook

Production Environment Verification

After your product goes live, you can create a 100% discount code to test whether the payment flow is running correctly.