Webhook Handling
Webhook Handling Guide
Webhooks are critical for maintaining sync between payment providers and your application.
Why Webhooks Matter
┌─────────────┐ ┌─────────────┐│ Stripe │──── webhook ───▶│ Your App ││ MercadoPago │ │ (QZPay) │└─────────────┘ └─────────────┘ │ │ │ Customer pays │ │ Subscription changes │ │ Payment fails │ │ │ └──────────────────────────────┘ Real-time sync via webhooksSetup
Endpoint Configuration
import { Hono } from 'hono';import { createWebhookRouter } from '@qazuor/qzpay-hono';import { billing, stripeAdapter, mpAdapter } from '../billing';
const webhooks = new Hono();
// Stripe webhook with Hono integrationconst stripeWebhook = createWebhookRouter({ billing, paymentAdapter: stripeAdapter, handlers: { 'customer.subscription.created': async (c, event) => { console.log('New subscription:', event.data); }, 'invoice.payment_succeeded': async (c, event) => { console.log('Payment succeeded:', event.data); } }});
webhooks.route('/stripe', stripeWebhook);
// MercadoPago webhookconst mpWebhook = createWebhookRouter({ billing, paymentAdapter: mpAdapter, handlers: { 'payment': async (c, event) => { console.log('Payment event:', event.data); }, 'subscription_preapproval': async (c, event) => { console.log('Subscription event:', event.data); } }});
webhooks.route('/mercadopago', mpWebhook);
export default webhooks;Provider Configuration
-
Stripe Dashboard
Go to Developers → Webhooks → Add endpoint:
- URL:
https://yourdomain.com/webhooks/stripe - Events: Select billing-related events
- URL:
-
MercadoPago Portal
Go to Your applications → Webhooks:
- URL:
https://yourdomain.com/webhooks/mercadopago - Topics: Payments, Subscriptions
- URL:
-
Store Secrets
Terminal window STRIPE_WEBHOOK_SECRET=whsec_xxxMP_WEBHOOK_SECRET=xxx
Security
Signature Verification
The Hono integration automatically verifies webhook signatures via middleware:
// When using createWebhookRouter, signatures are verified automatically// Stripe: Uses HMAC-SHA256// MercadoPago: Uses HMAC signature or fetches from API
// For manual verification, use the payment adapter directly:try { const event = stripeAdapter.webhooks.constructEvent(body, signature); // Process event...} catch (error) { if (error instanceof WebhookSignatureError) { // Log security event await logSecurityEvent('invalid_webhook_signature', { provider: 'stripe', ip: c.req.ip }); return c.json({ error: 'Invalid signature' }, 400); } throw error;}Replay Attack Prevention
Webhook signatures include timestamps that providers use to validate freshness. Each payment provider (Stripe, MercadoPago) handles timestamp validation automatically during signature verification.
Idempotency
Design your webhook handlers to be idempotent:
// Same event received twice? Make sure processing is safebilling.on('payment.succeeded', async (event) => { // Check if already processed const existing = await db.receipts.findByPaymentId(event.data.id); if (existing) { console.log('Payment already processed, skipping'); return; }
// Process payment await processPayment(event.data);
// Mark as processed await db.receipts.create({ paymentId: event.data.id });});Event Handling
Built-in Processing
QZPay automatically processes common events:
| Webhook Event | QZPay Action |
|---|---|
customer.subscription.created | Create subscription record |
customer.subscription.updated | Update subscription status |
customer.subscription.deleted | Mark subscription canceled |
invoice.payment_succeeded | Record payment, update status |
invoice.payment_failed | Update status, increment retry count |
Custom Handlers
Add your business logic:
// After successful subscription creationbilling.on('subscription.created', async (event) => { const { customerId, planId, id } = event.data;
// Provision features const plan = await billing.plans.get(planId); await provisionFeatures(customerId, plan.entitlements);
// Send welcome email await emailService.send(customerId, 'subscription_welcome', { planName: plan.name, features: plan.entitlements });
// Track in analytics await analytics.track('subscription_started', { customerId, planId, subscriptionId: id });});
// After payment failurebilling.on('payment.failed', async (event) => { const { customerId, amount, failureCode } = event.data;
// Notify customer await emailService.send(customerId, 'payment_failed', { amount, reason: getHumanReadableReason(failureCode), updateUrl: `${APP_URL}/billing/payment-method` });
// Alert team for large amounts if (amount > 100000) { // $1000+ await slackNotify(`High-value payment failed: ${amount / 100}`); }});
// After cancellationbilling.on('subscription.canceled', async (event) => { const { customerId, canceledAt, currentPeriodEnd } = event.data;
// Schedule feature revocation await jobQueue.schedule('revoke_features', { customerId, executeAt: currentPeriodEnd });
// Send feedback survey await emailService.send(customerId, 'cancellation_survey', { surveyUrl: `${APP_URL}/feedback/cancellation` });});Error Handling
Response Codes
When using createWebhookRouter, error handling is built-in. For custom handlers:
const webhookRouter = createWebhookRouter({ billing, paymentAdapter: stripeAdapter, handlers: { /* ... */ }, onError: async (error, c) => { if (error instanceof WebhookSignatureError) { // 400 = Bad request, won't retry return c.json({ error: 'Invalid signature' }, 400); }
if (error instanceof WebhookProcessingError) { // Log error but return 200 to prevent retry loops console.error('Processing error:', error); return c.json({ received: true, warning: 'Processing failed' }); }
// 500 = Server error, provider will retry console.error('Unexpected error:', error); return c.json({ error: 'Internal error' }, 500); }});Retry Handling
Providers retry failed webhooks:
| Provider | Retry Schedule |
|---|---|
| Stripe | Up to 72 hours, exponential backoff |
| MercadoPago | Up to 48 hours |
// Design handlers to be idempotentbilling.on('payment.succeeded', async (event) => { // Check if already processed const existing = await db.receipts.findByPaymentId(event.data.id); if (existing) { console.log('Receipt already sent, skipping'); return; }
// Process and mark as done await sendReceipt(event.data); await db.receipts.create({ paymentId: event.data.id });});Testing
Local Development
Use provider CLIs to forward webhooks:
# Stripestripe listen --forward-to localhost:3000/webhooks/stripe
# Trigger test eventsstripe trigger customer.subscription.createdstripe trigger invoice.payment_succeededTest Fixtures
import { createMockWebhookEvent } from '@qazuor/qzpay-core/testing';
describe('Webhook Handlers', () => { it('processes subscription.created', async () => { const event = createMockWebhookEvent('subscription.created', { id: 'sub_test', customerId: 'cus_test', planId: 'price_test', status: 'active' });
// Your handler logic receives the event from the router await subscriptionCreatedHandler(event);
expect(subscriptionCreatedHandler).toHaveBeenCalled(); });});Monitoring
Logging
Use the built-in logger or your own logging solution:
// Configure QZPay loggerconst billing = createQZPayBilling({ storage, paymentAdapter, logger: { debug: (message, context) => console.debug(message, context), info: (message, context) => console.info(message, context), warn: (message, context) => console.warn(message, context), error: (message, context) => console.error(message, context) }});
// Log webhook processing in your handlersconst webhookRouter = createWebhookRouter({ billing, paymentAdapter: stripeAdapter, handlers: { 'invoice.payment_succeeded': async (c, event) => { logger.info('Processing payment success webhook', { invoiceId: event.data.id, customerId: event.data.customer }); // Process event... } }});Alerting
// Alert on payment failuresbilling.on('payment.failed', async (event) => { const { customerId, amount, failureCode } = event.data;
// Alert team for high-value failures if (amount > 100000) { // $1000+ await alertTeam(`High-value payment failed: $${amount / 100}`, { customerId, failureCode }); }});
// Alert on repeated subscription issuesbilling.on('invoice.payment_failed', async (event) => { const subscription = await billing.subscriptions.get(event.data.subscriptionId); if (subscription && subscription.status === 'past_due') { await alertTeam(`Subscription at risk: ${subscription.id}`); }});Best Practices
- Verify all signatures before processing
- Process asynchronously for heavy operations
- Make handlers idempotent to handle retries
- Log all webhook activity for debugging
- Monitor failure rates and alert on anomalies
- Test with provider tools before going live
- Use HTTPS only for webhook endpoints