DEV Community

Payout Rail
Payout Rail

Posted on

Building Payment Webhooks: A Developer's Guide to Reliable Event Handling

Building Payment Webhooks: A Developer's Guide to Reliable Event Handling

Webhooks are the backbone of modern payment integrations. Whether you're using Stripe, PayPal, or a custom payment processor, handling webhook events correctly can mean the difference between a seamless user experience and lost transactions.

Why Webhooks Matter in Payment Systems

Payment providers send webhooks to notify your application about transaction events—charges completed, refunds issued, disputes filed. Rather than polling an API constantly, webhooks deliver real-time updates directly to your server. This reduces latency and database load while keeping your payment state synchronized.

The challenge? Webhooks are asynchronous, unordered, and can arrive multiple times. Your implementation needs to handle all three scenarios gracefully.

Core Implementation Principles

1. Verify Webhook Authenticity

Never trust an incoming webhook without verification. Payment processors include signatures in webhook headers—always validate them.

Here's a practical example using HMAC-SHA256 (common across most providers):

const crypto = require('crypto');

function verifyWebhookSignature(payload, signature, secret) {
  const hash = crypto
    .createHmac('sha256', secret)
    .update(payload, 'utf8')
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(hash),
    Buffer.from(signature)
  );
}

app.post('/webhooks/payment', (req, res) => {
  const signature = req.headers['x-signature'];
  const rawBody = req.rawBody; // Must preserve original body

  if (!verifyWebhookSignature(rawBody, signature, process.env.WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Process webhook
  res.status(200).json({ received: true });
});
Enter fullscreen mode Exit fullscreen mode

Critical detail: Always verify against the raw request body, not parsed JSON. Serialization differences will break signature validation.

2. Implement Idempotency

Webhooks can arrive multiple times due to network retries. Your system must handle duplicate events safely.

Use an idempotency key—typically the webhook's event ID:

async function handleWebhook(event) {
  // Check if we've already processed this event
  const existingRecord = await db.webhookEvents.findOne({
    eventId: event.id
  });

  if (existingRecord) {
    return { status: 'already_processed' };
  }

  // Process the event
  const result = await processPaymentEvent(event);

  // Store that we've seen this event
  await db.webhookEvents.create({
    eventId: event.id,
    type: event.type,
    processedAt: new Date(),
    result: result
  });

  return { status: 'processed', result };
}
Enter fullscreen mode Exit fullscreen mode

3. Handle Events Out of Order

Payment events may arrive out of sequence. A refund notification might arrive before the charge confirmation. Design your state machine to handle this:

const paymentStates = {
  PENDING: ['CHARGED', 'FAILED'],
  CHARGED: ['REFUNDED', 'DISPUTED'],
  REFUNDED: [],
  FAILED: ['CHARGED'], // Retry scenario
  DISPUTED: ['RESOLVED', 'REFUNDED']
};

function isValidTransition(currentState, newState) {
  return paymentStates[currentState]?.includes(newState) ?? false;
}

async function updatePaymentState(paymentId, newState) {
  const payment = await db.payments.findById(paymentId);

  if (!isValidTransition(payment.state, newState)) {
    console.warn(
      `Invalid state transition: ${payment.state}${newState}`
    );
    return { error: 'Invalid transition' };
  }

  await db.payments.updateOne(
    { id: paymentId },
    { state: newState, updatedAt: new Date() }
  );
}
Enter fullscreen mode Exit fullscreen mode

Best Practices

Practice Benefit
Async processing Return 200 immediately, process event in background queue
Timeout handling Set 30-second timeout for webhook processing
Logging & monitoring Track all events with request/response logs
Dead letter queues Store failed events for manual review
Webhook replay Test your handler with provider's replay feature

Testing Your Webhooks

Most payment providers offer webhook testing tools. Stripe's CLI, for example, lets you forward events to localhost:

stripe listen --forward-to localhost:3000/webhooks/payment
Enter fullscreen mode Exit fullscreen mode

This eliminates the need for public URLs during development.

Common Pitfalls

  • Forgetting raw body: Parsed JSON won't match the signature
  • No timeout: Slow handlers block webhook delivery

Top comments (0)