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?
- Reliability: Even if frontend validation fails, webhooks ensure orders are processed correctly
- Real-time: Payment providers can immediately notify your application
- 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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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
- 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- Using Stripe Dashboard:
- Go to Stripe Dashboard > Developers > Webhooks
- Add webhook endpoint
- Send test events
Creem Webhook Testing
- Using Creem Dashboard:
- Go to Creem Dashboard > Settings > Webhooks
- Add webhook URL
- Send test events
- 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 webhookProduction Environment Verification
After your product goes live, you can create a 100% discount code to test whether the payment flow is running correctly.