Intermediate 25 min

Handle Webhook Retries Like a Pro

Set up a webhook endpoint, verify signatures, implement idempotency, and handle retry logic. Required reading before going to production.

Prerequisites
1
Set Up Your Webhook Endpoint
~5 minutes

Create an HTTP endpoint that accepts POST requests at a path like /webhooks/payments. Register this URL in your PaymentsAPI dashboard under Settings → Webhooks → Add Endpoint.

const express = require('express');
const app = express();

// Important: use raw body parser for webhook signature verification
app.post('/webhooks/payments',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    // 1. Verify signature (see Step 2)
    // 2. Parse event
    // 3. Handle event type
    res.status(200).json({ received: true });
  }
);

app.listen(3000);
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/webhooks/payments', methods=['POST'])
def webhook():
    payload = request.get_data()  # raw bytes for signature
    # 1. Verify signature (see Step 2)
    # 2. Parse and handle event
    return jsonify({"received": True}), 200

if __name__ == '__main__':
    app.run(port=3000)
⚡ Always return 200 immediately

Return HTTP 200 as soon as you receive the webhook — before doing any database writes or external calls. The Payments API won't retry a webhook if it gets a 200, but it will if your endpoint times out. Move processing to a background job or queue.

2
Verify Webhook Signatures (HMAC-SHA256)
~5 minutes

Every webhook request includes a PaymentsAPI-Signature header containing an HMAC-SHA256 hash of the raw request body, signed with your webhook secret. Always verify this before processing — otherwise anyone can POST fake events to your endpoint.

const crypto = require('crypto');

function verifySignature(rawBody, signatureHeader, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  const actual = signatureHeader.replace('sha256=', '');

  // Use timingSafeEqual to prevent timing attacks
  const expectedBuf = Buffer.from(expected, 'hex');
  const actualBuf   = Buffer.from(actual,   'hex');

  if (expectedBuf.length !== actualBuf.length) return false;
  return crypto.timingSafeEqual(expectedBuf, actualBuf);
}

// In your route handler:
const sig = req.headers['paymentsapi-signature'];
if (!verifySignature(req.body, sig, process.env.WEBHOOK_SECRET)) {
  return res.status(401).json({ error: 'Invalid signature' });
}
import hashlib, hmac, os

def verify_signature(raw_body, signature_header):
    secret = os.environ["WEBHOOK_SECRET"].encode()
    expected = hmac.new(
        secret,
        raw_body,
        hashlib.sha256
    ).hexdigest()

    actual = signature_header.removeprefix("sha256=")

    # compare_digest prevents timing attacks
    return hmac.compare_digest(expected, actual)

# In your route:
sig = request.headers.get("PaymentsAPI-Signature")
if not verify_signature(payload, sig):
    return jsonify({"error": "Invalid signature"}), 401
3
Implement Idempotency (Prevent Duplicate Processing)
~5 minutes

The Payments API retries webhook delivery up to 5 times with exponential backoff if your endpoint returns a non-200 or times out. This means you may receive the same event multiple times. Your handler must be idempotent — processing the same event twice must produce the same outcome as processing it once.

// Simple in-memory dedup (use Redis or DB in production)
const processedEvents = new Set();

async function handleWebhookEvent(event) {
  const eventId = event.id;

  if (processedEvents.has(eventId)) {
    console.log(`Skipping duplicate event: ${eventId}`);
    return;  // already processed — do nothing
  }

  // Mark processing BEFORE doing work
  processedEvents.add(eventId);

  switch (event.type) {
    case 'transfer.completed':
      await handleTransferCompleted(event.data);
      break;
    case 'transfer.returned':
      await handleTransferReturned(event.data);
      break;
    default:
      console.log(`Unhandled event type: ${event.type}`);
  }
}
# Simple in-memory dedup (use Redis or DB in production)
processed_events = set()

def handle_webhook_event(event):
    event_id = event["id"]

    if event_id in processed_events:
        print(f"Duplicate event: {event_id} — skipping")
        return

    processed_events.add(event_id)

    event_type = event["type"]
    if event_type == "transfer.completed":
        handle_transfer_completed(event["data"])
    elif event_type == "transfer.returned":
        handle_transfer_returned(event["data"])
Production Note

In production, store processed event IDs in a database or Redis with a TTL of 7 days (the maximum retry window). An in-memory set is fine for development but will reset on every server restart.

Next Steps