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 →