Skip to content

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 webhooks

Setup

Endpoint Configuration

src/routes/webhooks.ts
import { Hono } from 'hono';
import { createWebhookRouter } from '@qazuor/qzpay-hono';
import { billing, stripeAdapter, mpAdapter } from '../billing';
const webhooks = new Hono();
// Stripe webhook with Hono integration
const 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 webhook
const 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

  1. Stripe Dashboard

    Go to Developers → Webhooks → Add endpoint:

    • URL: https://yourdomain.com/webhooks/stripe
    • Events: Select billing-related events
  2. MercadoPago Portal

    Go to Your applications → Webhooks:

    • URL: https://yourdomain.com/webhooks/mercadopago
    • Topics: Payments, Subscriptions
  3. Store Secrets

    Terminal window
    STRIPE_WEBHOOK_SECRET=whsec_xxx
    MP_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 safe
billing.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 EventQZPay Action
customer.subscription.createdCreate subscription record
customer.subscription.updatedUpdate subscription status
customer.subscription.deletedMark subscription canceled
invoice.payment_succeededRecord payment, update status
invoice.payment_failedUpdate status, increment retry count

Custom Handlers

Add your business logic:

// After successful subscription creation
billing.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 failure
billing.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 cancellation
billing.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:

ProviderRetry Schedule
StripeUp to 72 hours, exponential backoff
MercadoPagoUp to 48 hours
// Design handlers to be idempotent
billing.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:

Terminal window
# Stripe
stripe listen --forward-to localhost:3000/webhooks/stripe
# Trigger test events
stripe trigger customer.subscription.created
stripe trigger invoice.payment_succeeded

Test 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 logger
const 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 handlers
const 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 failures
billing.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 issues
billing.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

  1. Verify all signatures before processing
  2. Process asynchronously for heavy operations
  3. Make handlers idempotent to handle retries
  4. Log all webhook activity for debugging
  5. Monitor failure rates and alert on anomalies
  6. Test with provider tools before going live
  7. Use HTTPS only for webhook endpoints