Features Blog Docs GitHub Get Started

Building Webhook Handlers with flashQ

Webhooks need to be fast, secure, and reliable. Learn how to build production-grade webhook handlers using flashQ with proper signature verification, idempotency, and async processing.

The Webhook Challenge

Webhook providers expect fast responses (typically under 3 seconds). If processing takes longer, you risk timeouts and retries. The solution: immediately queue webhook payloads and process them asynchronously.

┌─────────────┐     POST /webhook      ┌──────────────┐
│   Stripe    │ ─────────────────────→ │  Your API    │
│   (sender)  │                        │              │
└─────────────┘                        │  1. Verify   │
                                       │  2. Queue    │
     ← 200 OK (immediate)              │  3. Return   │
                                       └──────┬───────┘
                                              │
                                              ↓
                                       ┌──────────────┐
                                       │   flashQ     │
                                       │   Worker     │
                                       │              │
                                       │  Process     │
                                       │  webhook     │
                                       │  async       │
                                       └──────────────┘

Basic Webhook Handler

import express from 'express';
import { FlashQ, Worker } from 'flashq';

const app = express();
const client = new FlashQ({ host: 'localhost', port: 6789 });

await client.connect();

// Webhook endpoint - fast response, async processing
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  try {
    const payload = JSON.parse(req.body.toString());

    // Queue for async processing
    await client.push('webhooks', {
      source: 'generic',
      payload,
      receivedAt: Date.now()
    });

    res.status(200).send('OK');
  } catch (error) {
    res.status(400).send('Invalid payload');
  }
});

// Worker processes webhooks asynchronously
const worker = new Worker('webhooks', async (job) => {
  const { source, payload } = job.data;
  console.log(`Processing webhook from ${source}`);
  await processWebhook(payload);
});

app.listen(3000);

Stripe Webhook Handler

Stripe webhooks require signature verification using their stripe-signature header:

import Stripe from 'stripe';
import { FlashQ, Worker } from 'flashq';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const client = new FlashQ({ host: 'localhost', port: 6789 });

await client.connect();

app.post('/webhook/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
  const signature = req.headers['stripe-signature'];

  try {
    // 1. Verify signature
    const event = stripe.webhooks.constructEvent(
      req.body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET
    );

    // 2. Queue with idempotency key (Stripe event ID)
    await client.push('stripe-webhooks', {
      eventId: event.id,
      type: event.type,
      data: event.data.object
    }, {
      unique_key: event.id  // Prevents duplicate processing
    });

    // 3. Acknowledge immediately
    res.status(200).json({ received: true });

  } catch (error) {
    console.error('Webhook error:', error.message);
    res.status(400).send(`Webhook Error: ${error.message}`);
  }
});

// Stripe webhook worker
const stripeWorker = new Worker('stripe-webhooks', async (job) => {
  const { eventId, type, data } = job.data;

  switch (type) {
    case 'payment_intent.succeeded':
      await handlePaymentSuccess(data);
      break;
    case 'customer.subscription.created':
      await handleNewSubscription(data);
      break;
    case 'invoice.payment_failed':
      await handleFailedPayment(data);
      break;
    default:
      console.log(`Unhandled event type: ${type}`);
  }
}, { concurrency: 5 });

GitHub Webhook Handler

GitHub uses HMAC-SHA256 signatures in the x-hub-signature-256 header:

import { createHmac } from 'crypto';
import { FlashQ, Worker } from 'flashq';

const client = new FlashQ({ host: 'localhost', port: 6789 });

function verifyGitHubSignature(payload, signature, secret) {
  const expected = 'sha256=' + createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  return signature === expected;
}

app.post('/webhook/github', express.raw({ type: 'application/json' }), async (req, res) => {
  const signature = req.headers['x-hub-signature-256'];
  const event = req.headers['x-github-event'];
  const deliveryId = req.headers['x-github-delivery'];

  // 1. Verify signature
  if (!verifyGitHubSignature(req.body, signature, process.env.GITHUB_WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  const payload = JSON.parse(req.body.toString());

  // 2. Queue with delivery ID as unique key
  await client.push('github-webhooks', {
    deliveryId,
    event,
    payload
  }, {
    unique_key: deliveryId  // Idempotency
  });

  res.status(200).send('OK');
});

// GitHub webhook worker
const githubWorker = new Worker('github-webhooks', async (job) => {
  const { event, payload } = job.data;

  switch (event) {
    case 'push':
      await handlePush(payload);
      break;
    case 'pull_request':
      await handlePullRequest(payload);
      break;
    case 'issues':
      await handleIssue(payload);
      break;
  }
});

Generic HMAC Verification

For services using standard HMAC-SHA256:

import { createHmac, timingSafeEqual } from 'crypto';

function verifyHmacSignature(payload, signature, secret) {
  const expected = createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  // Extract hex part if prefixed (e.g., "sha256=...")
  const received = signature.replace('sha256=', '');

  // Use timing-safe comparison to prevent timing attacks
  try {
    return timingSafeEqual(
      Buffer.from(expected, 'hex'),
      Buffer.from(received, 'hex')
    );
  } catch {
    return false;
  }
}

Idempotency with unique_key

Webhook providers often retry on failures. Use flashQ's unique_key to prevent duplicate processing:

// Stripe: Use event ID
await client.push('stripe', data, {
  unique_key: event.id
});

// GitHub: Use delivery ID
await client.push('github', data, {
  unique_key: deliveryId
});

// Custom: Generate from payload
const idempotencyKey = createHash('sha256')
  .update(JSON.stringify(payload))
  .digest('hex');

await client.push('webhooks', data, {
  unique_key: idempotencyKey
});

Error Handling & Retries

const worker = new Worker('webhooks', async (job) => {
  const { eventId, type, data } = job.data;

  try {
    await processWebhook(type, data);
  } catch (error) {
    // Log for debugging
    console.error(`Webhook ${eventId} failed:`, error);

    // Check if we should retry
    if (isRetryableError(error)) {
      throw error; // Triggers automatic retry
    }

    // Non-retryable: log and complete
    await logFailedWebhook(eventId, error);
  }
});

function isRetryableError(error) {
  const retryableCodes = [408, 429, 500, 502, 503, 504];
  return error.message.includes('ECONNREFUSED') ||
         error.message.includes('ETIMEDOUT') ||
         retryableCodes.some(code => error.message.includes(String(code)));
}

Webhook Configuration Options

// Configure retry behavior
await client.push('webhooks', payload, {
  unique_key: eventId,        // Idempotency
  max_attempts: 5,            // Retry 5 times
  backoff: 1000,              // Start with 1s delay
  timeout: 30000,             // 30s processing timeout
  priority: 10                // Higher priority
});
Key Takeaways: Always verify signatures, use unique_key for idempotency, respond immediately (200 OK), and process asynchronously with proper retry logic.

Related Resources

Build Reliable Webhooks

Get started with flashQ for secure, reliable webhook processing.

Get Started →