Checkout Webhooks
Webhooks are HTTP callbacks from payment gateways (Razorpay) that notify the system about payment status changes. They are critical for order creation, inventory management, and payment reconciliation.
Webhook Overview
What are Webhooks?
Webhooks are real-time notifications sent by Razorpay when payment events occur:
- Payment Captured: Payment successful, order should be created
- Payment Failed: Payment declined, inventory should be released
- Payment Authorized: Payment authorized, awaiting capture
Webhook Flow
Webhook Endpoint
Endpoint Configuration
POST /storefront/payments/razorpay/webhook
Content-Type: application/json
X-Razorpay-Signature: {signature}
Endpoint Security
@Post("razorpay/webhook")
@SetMetadata(IS_PUBLIC_KEY, true) // Public endpoint
async handleWebhook(
@Req() req: RawBodyRequest<Request>,
@Headers("x-razorpay-signature") signature: string,
): Promise<{ processed: boolean; message: string }> {
// Verify webhook signature
// Process webhook event
// Return processing result
}
Signature Verification
HMAC Signature
Razorpay signs webhooks using HMAC-SHA256:
async handleWebhook(
webhookEvent: RazorpayWebhookEventDto,
signature: string,
): Promise<{ processed: boolean; message: string }> {
const webhookSecret = process.env.RAZORPAY_WEBHOOK_SECRET;
if (!webhookSecret) {
throw new BadRequestException(
"Razorpay webhook secret is not configured"
);
}
// Verify webhook signature
const text = JSON.stringify(webhookEvent);
const generatedSignature = crypto
.createHmac("sha256", webhookSecret)
.update(text)
.digest("hex");
if (generatedSignature !== signature) {
throw new BadRequestException("Invalid webhook signature");
}
// Process webhook event
return this.processWebhookEvent(webhookEvent);
}
Signature Verification Flow
Webhook Events
Supported Events
enum RazorpayWebhookEvent {
PAYMENT_CAPTURED = "payment.captured",
PAYMENT_FAILED = "payment.failed",
PAYMENT_AUTHORIZED = "payment.authorized",
ORDER_PAID = "order.paid",
}
Event Processing
switch (eventName) {
case "payment.captured":
await this.handlePaymentCaptured(webhookEvent);
break;
case "payment.failed":
await this.handlePaymentFailed(webhookEvent);
break;
case "payment.authorized":
await this.handlePaymentAuthorized(webhookEvent);
break;
case "order.paid":
await this.handleOrderPaid(webhookEvent);
break;
default:
return {
processed: false,
message: `Event ${eventName} is not handled`,
};
}
Payment Captured Event
Event Handler
async handlePaymentCaptured(
webhookEvent: RazorpayWebhookEventDto,
): Promise<void> {
const paymentData = webhookEvent.payload.payment.entity;
const orderId = paymentData.order_id; // Razorpay order ID
// Find checkout session by payment intent ID
const checkoutSessionId = await this.checkoutStore
.getCheckoutSessionByPaymentIntent(orderId);
if (!checkoutSessionId) {
this.logger.warn(`Checkout session not found for payment intent ${orderId}`);
return;
}
// Create order from checkout session
await this.ordersService.finalizeOrderFromPayment(
checkoutSessionId,
orderId,
"razorpay",
);
}
Order Creation Flow
Idempotency
Order creation is idempotent:
// Check if order already exists
const existingOrderId = await this.checkoutStore.getOrderByPaymentIntent(
provider,
paymentIntentId,
);
if (existingOrderId) {
// Order already exists, return it
return await this.getOrder(existingOrderId);
}
// Create new order
const order = await this.createOrderFromCheckout(...);
Payment Failed Event
Event Handler
async handlePaymentFailed(
webhookEvent: RazorpayWebhookEventDto,
): Promise<void> {
const paymentData = webhookEvent.payload.payment.entity;
const orderId = paymentData.order_id;
// Find checkout session
const checkoutSessionId = await this.checkoutStore
.getCheckoutSessionByPaymentIntent(orderId);
if (!checkoutSessionId) {
return;
}
// Release inventory reservations
const session = await this.checkoutStore.getSession(checkoutSessionId);
await this.inventoryStore.releaseCartReservations(session.cartId);
// Update checkout state to FAILED
await this.checkoutStore.transitionState(
checkoutSessionId,
CheckoutState.FAILED,
);
// Unlock cart
await this.checkoutStore.releaseCheckoutLock(session.cartId);
}
Failure Handling Flow
Payment Authorized Event
Event Handler
async handlePaymentAuthorized(
webhookEvent: RazorpayWebhookEventDto,
): Promise<void> {
const paymentData = webhookEvent.payload.payment.entity;
const orderId = paymentData.order_id;
// Update payment intent status to AUTHORIZED
const checkoutSessionId = await this.checkoutStore
.getCheckoutSessionByPaymentIntent(orderId);
if (checkoutSessionId) {
await this.checkoutStore.updatePaymentIntentStatus(
checkoutSessionId,
PaymentIntentStatus.AUTHORIZED,
);
}
}
Order Paid Event
Event Handler
async handleOrderPaid(
webhookEvent: RazorpayWebhookEventDto,
): Promise<void> {
// Similar to payment.captured
// Some payment methods trigger order.paid instead
await this.handlePaymentCaptured(webhookEvent);
}
Webhook Payload Structure
Payment Captured Payload
{
"event": "payment.captured",
"created_at": 1234567890,
"payload": {
"payment": {
"entity": {
"id": "pay_abc123",
"entity": "payment",
"amount": 100000,
"currency": "INR",
"status": "captured",
"order_id": "order_xyz789",
"method": "card",
"created_at": 1234567890
}
}
}
}
Payment Failed Payload
{
"event": "payment.failed",
"created_at": 1234567890,
"payload": {
"payment": {
"entity": {
"id": "pay_abc123",
"entity": "payment",
"amount": 100000,
"currency": "INR",
"status": "failed",
"order_id": "order_xyz789",
"error_code": "BAD_REQUEST_ERROR",
"error_description": "Payment declined by bank"
}
}
}
}
Idempotency Handling
Duplicate Prevention
// Check if webhook already processed
const webhookId = webhookEvent.id || webhookEvent.created_at;
const processedKey = `webhook:processed:${webhookId}`;
const alreadyProcessed = await this.client.get(processedKey);
if (alreadyProcessed) {
return {
processed: true,
message: "Webhook already processed",
};
}
// Process webhook
await this.processWebhookEvent(webhookEvent);
// Mark as processed
await this.client.set(processedKey, "1", "EX", 86400); // 24 hours
Error Handling
Webhook Processing Errors
try {
await this.handleWebhook(webhookEvent, signature);
} catch (error) {
this.logger.error(
createErrorContext(this.contextService, "webhook", error, {
event: webhookEvent.event,
paymentId: webhookEvent.payload?.payment?.entity?.id,
}),
"Webhook processing failed",
);
// Return 500 to trigger Razorpay retry
throw new InternalServerErrorException("Webhook processing failed");
}
Retry Logic
Razorpay retries failed webhooks:
- Initial Retry: After 1 minute
- Subsequent Retries: Exponential backoff
- Max Retries: 5 attempts
- Final Failure: Manual reconciliation required
Webhook Configuration
Razorpay Dashboard Setup
- Navigate to Razorpay Dashboard → Settings → Webhooks
- Add webhook URL:
https://yourdomain.com/storefront/payments/razorpay/webhook - Select events:
payment.capturedpayment.failedpayment.authorizedorder.paid
- Save webhook secret to environment variables
Environment Variables
RAZORPAY_WEBHOOK_SECRET=your_webhook_secret_here
Testing Webhooks
Local Testing
Use tools like ngrok to expose local server:
ngrok http 3000
# Use ngrok URL in Razorpay webhook configuration
Webhook Testing Tools
- Razorpay Dashboard: Test webhook button
- Postman: Manual webhook simulation
- Webhook.site: Public webhook testing
Monitoring
Webhook Metrics
// Track webhook processing
webhook_received_total: counter
webhook_processed_total: counter
webhook_failed_total: counter
webhook_processing_duration: histogram
Logging
this.logger.info(
createLogContext(this.contextService, "webhook", {
event: webhookEvent.event,
paymentId: paymentData.id,
orderId: paymentData.order_id,
}),
"Webhook processed successfully",
);
Best Practices
- Signature Verification: Always verify webhook signatures
- Idempotency: Handle duplicate webhooks gracefully
- Error Handling: Return appropriate HTTP status codes
- Logging: Log all webhook events for debugging
- Monitoring: Track webhook success/failure rates
Edge Cases
Webhook Received Before Payment Intent
- Payment intent may not exist yet
- Log warning and skip processing
- Order creation will happen on next webhook
Multiple Webhooks for Same Payment
- Use idempotency keys
- Process only once
- Return success for duplicates
Webhook Timeout
- Razorpay expects 200 OK within 5 seconds
- Process webhook asynchronously if needed
- Return 200 OK immediately, process in background