Building integrations with third-party services means working with webhooks. Whether you're processing Stripe payments, responding to GitHub push events, syncing Shopify orders, or building Slack bots, webhooks are how these platforms communicate changes to your application in real-time.
The challenge? Every platform does webhooks differently. Stripe signs payloads with timestamps to prevent replay attacks. GitHub uses a simpler HMAC scheme. Shopify gives you just 5 seconds to respond. Slack requires you to handle URL verification challenges before receiving any events. Each platform has its own signature algorithm, timeout requirements, retry behavior, and payload format.
This inconsistency creates real problems. Code that works perfectly for Stripe webhooks will fail silently with Shopify's base64-encoded signatures. A handler that processes GitHub events correctly might timeout on Slack's strict 3-second limit. And without proper idempotency handling, you'll process the same payment twice when Stripe retries after a slow response.
This guide provides production-ready implementations for the most popular webhook providers. For each platform, you'll find working code for signature verification, event handling patterns, and the specific gotchas that cause problems in production. The goal is to give you copy-paste-ready code that handles the edge cases you'd otherwise learn about the hard way.
When debugging webhook issues, having the right tools helps. Our HMAC Signature Calculator lets you verify signatures manually, the Base64 Encoder/Decoder handles Shopify's encoding format, and the JSON Formatter makes payload inspection easier. Keep these handy as you work through implementations.
Platform Comparison Overview
Before diving into implementation details, here's a quick reference comparing the key differences between major webhook providers. Understanding these differences upfront will help you design your webhook infrastructure to handle multiple providers without surprises.
┌───────────────────────────────────────────────────────────────────────────────┐
│ WEBHOOK PLATFORM COMPARISON │
├───────────────────────────────────────────────────────────────────────────────┤
│ │
│ Platform │ Signature │ Timeout │ Retries │ Event Format │
│ ───────── │ ───────── │ ─────── │ ─────── │ ──────────── │
│ Stripe │ HMAC-SHA256+time │ 20s │ 3 days │ Versioned API │
│ GitHub │ HMAC-SHA256 │ 10s │ None* │ X-GitHub-Event header │
│ Shopify │ HMAC-SHA256 │ 5s │ 48 hrs │ X-Shopify-Topic │
│ Slack │ HMAC-SHA256+time │ 3s │ 3 tries │ Event wrapper │
│ Twilio │ HMAC-SHA1/256 │ 15s │ Variable│ Form or JSON │
│ PayPal │ Cert verification │ 30s │ 3 days │ Event type in body │
│ Square │ HMAC-SHA256 │ 10s │ 72 hrs │ Event wrapper │
│ │
│ * GitHub requires manual retry via API │
│ │
└───────────────────────────────────────────────────────────────────────────────┘
Stripe Webhooks
Stripe is the gold standard for webhook implementations. Their system includes timestamp-based replay attack prevention, automatic retries for up to 3 days, and excellent developer tooling. The Stripe CLI makes local development painless, and their SDK handles signature verification automatically. If you're only integrating with one webhook provider, Stripe's patterns are worth studying as a reference implementation. When debugging signature issues, you can use our HMAC Signature Calculator to manually verify that your computed signature matches Stripe's.
Signature Verification
Stripe uses a timestamp-based signature to prevent replay attacks:
// stripe-webhooks.ts
import Stripe from 'stripe';
import express from 'express';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
const app = express();
// CRITICAL: Use raw body for signature verification
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
// Using Stripe SDK (recommended)
event = stripe.webhooks.constructEvent(
req.body,
signature,
webhookSecret
);
} catch (err) {
console.error('Signature verification failed:', err);
return res.status(400).send(`Webhook Error: ${err}`);
}
// Handle the event
try {
await handleStripeEvent(event);
res.json({ received: true });
} catch (err) {
console.error('Event handling failed:', err);
res.status(500).json({ error: 'Processing failed' });
}
}
);
// Manual signature verification (for understanding)
function verifyStripeSignature(
payload: string,
signature: string,
secret: string,
tolerance: number = 300
): { timestamp: number; verified: boolean } {
const parts = signature.split(',');
const timestamp = parseInt(parts.find(p => p.startsWith('t='))?.slice(2) || '0');
const signatures = parts
.filter(p => p.startsWith('v1='))
.map(p => p.slice(3));
// Check timestamp tolerance (default 5 minutes)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > tolerance) {
throw new Error('Timestamp outside tolerance');
}
// Compute expected signature
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
const verified = signatures.some(sig =>
crypto.timingSafeEqual(
Buffer.from(sig),
Buffer.from(expectedSignature)
)
);
return { timestamp, verified };
}
Common Stripe Event Handlers
// stripe-handlers.ts
import Stripe from 'stripe';
async function handleStripeEvent(event: Stripe.Event): Promise<void> {
// Check idempotency first
if (await isEventProcessed(event.id)) {
console.log(`Duplicate event: ${event.id}`);
return;
}
switch (event.type) {
// Checkout flow
case 'checkout.session.completed':
await handleCheckoutComplete(event.data.object as Stripe.Checkout.Session);
break;
// Payments
case 'payment_intent.succeeded':
await handlePaymentSucceeded(event.data.object as Stripe.PaymentIntent);
break;
case 'payment_intent.payment_failed':
await handlePaymentFailed(event.data.object as Stripe.PaymentIntent);
break;
// Subscriptions
case 'customer.subscription.created':
case 'customer.subscription.updated':
await handleSubscriptionChange(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.deleted':
await handleSubscriptionCanceled(event.data.object as Stripe.Subscription);
break;
// Invoices
case 'invoice.paid':
await handleInvoicePaid(event.data.object as Stripe.Invoice);
break;
case 'invoice.payment_failed':
await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice);
break;
// Disputes
case 'charge.dispute.created':
await handleDisputeCreated(event.data.object as Stripe.Dispute);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
await markEventProcessed(event.id);
}
async function handleCheckoutComplete(session: Stripe.Checkout.Session): Promise<void> {
const orderId = session.metadata?.order_id;
if (!orderId) {
console.error('No order_id in session metadata');
return;
}
// Fulfill the order
await db.query(`
UPDATE orders
SET status = 'paid',
stripe_session_id = $1,
paid_at = NOW()
WHERE id = $2
`, [session.id, orderId]);
// Send confirmation email
await sendOrderConfirmation(orderId);
}
async function handleSubscriptionChange(subscription: Stripe.Subscription): Promise<void> {
const customerId = subscription.customer as string;
await db.query(`
INSERT INTO subscriptions (
stripe_subscription_id,
stripe_customer_id,
status,
current_period_start,
current_period_end,
cancel_at_period_end
) VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (stripe_subscription_id)
DO UPDATE SET
status = EXCLUDED.status,
current_period_start = EXCLUDED.current_period_start,
current_period_end = EXCLUDED.current_period_end,
cancel_at_period_end = EXCLUDED.cancel_at_period_end,
updated_at = NOW()
`, [
subscription.id,
customerId,
subscription.status,
new Date(subscription.current_period_start * 1000),
new Date(subscription.current_period_end * 1000),
subscription.cancel_at_period_end
]);
}
GitHub Webhooks
GitHub webhooks power CI/CD pipelines, deployment automation, and repository monitoring across millions of projects. Their implementation uses straightforward HMAC-SHA256 signatures without timestamps, making verification simpler than Stripe but without built-in replay protection. One important limitation: GitHub doesn't automatically retry failed deliveries, so you'll need to handle retries manually through their API or build your own retry logic. The HMAC Signature Calculator is useful for verifying GitHub signatures during debugging—just remember to use SHA256 and hex encoding.
Signature Verification
GitHub uses simpler HMAC-SHA256 without timestamp:
// github-webhooks.ts
import crypto from 'crypto';
import express from 'express';
const webhookSecret = process.env.GITHUB_WEBHOOK_SECRET!;
app.post('/webhooks/github',
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.headers['x-hub-signature-256'] as string;
const event = req.headers['x-github-event'] as string;
const deliveryId = req.headers['x-github-delivery'] as string;
// Verify signature
if (!verifyGitHubSignature(req.body, signature, webhookSecret)) {
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(req.body.toString());
// Handle based on event type
try {
await handleGitHubEvent(event, payload, deliveryId);
res.status(200).send('OK');
} catch (err) {
console.error('GitHub webhook error:', err);
res.status(500).send('Processing failed');
}
}
);
function verifyGitHubSignature(
payload: Buffer,
signature: string,
secret: string
): boolean {
if (!signature || !signature.startsWith('sha256=')) {
return false;
}
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
Common GitHub Event Handlers
// github-handlers.ts
interface GitHubWebhookPayload {
action?: string;
repository: {
full_name: string;
default_branch: string;
};
sender: {
login: string;
};
[key: string]: any;
}
async function handleGitHubEvent(
event: string,
payload: GitHubWebhookPayload,
deliveryId: string
): Promise<void> {
// Check idempotency
if (await isDeliveryProcessed(deliveryId)) {
return;
}
console.log(`Processing ${event} from ${payload.repository.full_name}`);
switch (event) {
case 'push':
await handlePush(payload);
break;
case 'pull_request':
await handlePullRequest(payload);
break;
case 'issues':
await handleIssue(payload);
break;
case 'issue_comment':
await handleIssueComment(payload);
break;
case 'release':
await handleRelease(payload);
break;
case 'workflow_run':
await handleWorkflowRun(payload);
break;
case 'check_suite':
await handleCheckSuite(payload);
break;
default:
console.log(`Unhandled GitHub event: ${event}`);
}
await markDeliveryProcessed(deliveryId);
}
async function handlePush(payload: any): Promise<void> {
const { ref, commits, repository, pusher } = payload;
// Only process main branch pushes
if (ref !== `refs/heads/${repository.default_branch}`) {
return;
}
// Trigger deployment
await triggerDeployment({
repo: repository.full_name,
branch: repository.default_branch,
commits: commits.map((c: any) => ({
sha: c.id,
message: c.message,
author: c.author.name
})),
pusher: pusher.name
});
}
async function handlePullRequest(payload: any): Promise<void> {
const { action, pull_request, repository } = payload;
switch (action) {
case 'opened':
case 'synchronize':
// Run CI checks
await triggerCI({
repo: repository.full_name,
prNumber: pull_request.number,
headSha: pull_request.head.sha,
baseBranch: pull_request.base.ref
});
break;
case 'closed':
if (pull_request.merged) {
// Clean up PR environment
await cleanupPREnvironment(pull_request.number);
}
break;
}
}
async function handleRelease(payload: any): Promise<void> {
const { action, release, repository } = payload;
if (action === 'published') {
// Notify team of new release
await notifySlack({
channel: '#releases',
text: `🚀 ${repository.full_name} ${release.tag_name} released by ${release.author.login}`,
attachments: [{
text: release.body,
color: 'good'
}]
});
// Update changelog
await updateChangelog(repository.full_name, release);
}
}
Shopify Webhooks
Shopify webhooks are critical for e-commerce integrations, handling everything from order processing to inventory synchronization. The key challenge with Shopify is their aggressive 5-second timeout—if your endpoint doesn't respond in time, Shopify marks the delivery as failed. This means you must acknowledge webhooks immediately and process the actual business logic asynchronously. Shopify also uses base64-encoded HMAC signatures instead of hex, which catches many developers off guard—use our Base64 Encoder/Decoder to convert between formats when debugging.
Signature Verification
// shopify-webhooks.ts
import crypto from 'crypto';
import express from 'express';
const shopifySecret = process.env.SHOPIFY_WEBHOOK_SECRET!;
app.post('/webhooks/shopify',
express.raw({ type: 'application/json' }),
async (req, res) => {
const hmac = req.headers['x-shopify-hmac-sha256'] as string;
const topic = req.headers['x-shopify-topic'] as string;
const shopDomain = req.headers['x-shopify-shop-domain'] as string;
// Verify signature
if (!verifyShopifySignature(req.body, hmac, shopifySecret)) {
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(req.body.toString());
// IMPORTANT: Respond quickly (5 second timeout)
res.status(200).send('OK');
// Process asynchronously
try {
await handleShopifyEvent(topic, payload, shopDomain);
} catch (err) {
console.error('Shopify webhook error:', err);
}
}
);
function verifyShopifySignature(
body: Buffer,
hmac: string,
secret: string
): boolean {
const calculatedHmac = crypto
.createHmac('sha256', secret)
.update(body)
.digest('base64');
return crypto.timingSafeEqual(
Buffer.from(hmac),
Buffer.from(calculatedHmac)
);
}
Common Shopify Event Handlers
// shopify-handlers.ts
async function handleShopifyEvent(
topic: string,
payload: any,
shopDomain: string
): Promise<void> {
console.log(`Processing ${topic} from ${shopDomain}`);
switch (topic) {
// Orders
case 'orders/create':
await handleOrderCreated(payload, shopDomain);
break;
case 'orders/paid':
await handleOrderPaid(payload, shopDomain);
break;
case 'orders/fulfilled':
await handleOrderFulfilled(payload, shopDomain);
break;
case 'orders/cancelled':
await handleOrderCancelled(payload, shopDomain);
break;
// Customers
case 'customers/create':
await handleCustomerCreated(payload, shopDomain);
break;
case 'customers/update':
await handleCustomerUpdated(payload, shopDomain);
break;
// Products
case 'products/create':
case 'products/update':
await syncProduct(payload, shopDomain);
break;
case 'products/delete':
await deleteProduct(payload.id, shopDomain);
break;
// Inventory
case 'inventory_levels/update':
await handleInventoryUpdate(payload, shopDomain);
break;
// App lifecycle
case 'app/uninstalled':
await handleAppUninstalled(shopDomain);
break;
default:
console.log(`Unhandled Shopify topic: ${topic}`);
}
}
async function handleOrderCreated(order: any, shopDomain: string): Promise<void> {
// Sync order to your system
await db.query(`
INSERT INTO shopify_orders (
shop_domain,
order_id,
order_number,
customer_email,
total_price,
currency,
line_items,
shipping_address,
created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (shop_domain, order_id) DO UPDATE SET
total_price = EXCLUDED.total_price,
line_items = EXCLUDED.line_items,
updated_at = NOW()
`, [
shopDomain,
order.id,
order.order_number,
order.email,
order.total_price,
order.currency,
JSON.stringify(order.line_items),
JSON.stringify(order.shipping_address),
order.created_at
]);
// Notify fulfillment system
if (order.fulfillment_status === null) {
await queueForFulfillment({
orderId: order.id,
shopDomain,
items: order.line_items.map((item: any) => ({
sku: item.sku,
quantity: item.quantity
}))
});
}
}
async function handleInventoryUpdate(payload: any, shopDomain: string): Promise<void> {
const { inventory_item_id, location_id, available } = payload;
// Update inventory in your system
await db.query(`
UPDATE inventory
SET quantity = $1, updated_at = NOW()
WHERE shop_domain = $2
AND inventory_item_id = $3
AND location_id = $4
`, [available, shopDomain, inventory_item_id, location_id]);
// Check for low stock alerts
if (available <= 10) {
await sendLowStockAlert({
shopDomain,
inventoryItemId: inventory_item_id,
available
});
}
}
Slack Webhooks
Slack's webhook system is more complex than other providers because it handles multiple interaction types: event subscriptions, slash commands, interactive components (buttons and menus), and shortcuts. Each type has slightly different payload formats, but they all share the same signature verification scheme. The biggest gotcha is Slack's strict 3-second timeout—the tightest of any major provider. You must also handle Slack's URL verification challenge when first setting up your endpoint.
Event Subscriptions vs Slash Commands
// slack-webhooks.ts
import crypto from 'crypto';
import express from 'express';
const slackSigningSecret = process.env.SLACK_SIGNING_SECRET!;
// Slack requires responding within 3 seconds
app.post('/webhooks/slack/events',
express.raw({ type: 'application/json' }),
async (req, res) => {
// Verify signature
if (!verifySlackSignature(req)) {
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(req.body.toString());
// Handle URL verification challenge
if (payload.type === 'url_verification') {
return res.json({ challenge: payload.challenge });
}
// Acknowledge immediately
res.status(200).send();
// Process asynchronously
if (payload.type === 'event_callback') {
await handleSlackEvent(payload.event, payload.team_id);
}
}
);
function verifySlackSignature(req: express.Request): boolean {
const timestamp = req.headers['x-slack-request-timestamp'] as string;
const signature = req.headers['x-slack-signature'] as string;
// Prevent replay attacks (5 minutes)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
return false;
}
const sigBasestring = `v0:${timestamp}:${req.body.toString()}`;
const expectedSignature = 'v0=' + crypto
.createHmac('sha256', slackSigningSecret)
.update(sigBasestring)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
Slack Event Handlers
// slack-handlers.ts
interface SlackEvent {
type: string;
user?: string;
channel?: string;
text?: string;
ts?: string;
[key: string]: any;
}
async function handleSlackEvent(event: SlackEvent, teamId: string): Promise<void> {
switch (event.type) {
case 'message':
// Ignore bot messages to prevent loops
if (event.subtype === 'bot_message' || event.bot_id) {
return;
}
await handleMessage(event, teamId);
break;
case 'app_mention':
await handleMention(event, teamId);
break;
case 'member_joined_channel':
await handleMemberJoined(event, teamId);
break;
case 'reaction_added':
await handleReactionAdded(event, teamId);
break;
case 'app_home_opened':
await updateAppHome(event.user!, teamId);
break;
default:
console.log(`Unhandled Slack event: ${event.type}`);
}
}
async function handleMention(event: SlackEvent, teamId: string): Promise<void> {
const { text, channel, user, ts } = event;
// Parse command from mention
const command = text?.replace(/<@[A-Z0-9]+>/g, '').trim().toLowerCase();
// Respond in thread
await slackClient.chat.postMessage({
channel: channel!,
thread_ts: ts,
text: await processCommand(command || '', user!, teamId)
});
}
// Interactive Components (buttons, menus)
app.post('/webhooks/slack/interactions',
express.urlencoded({ extended: true }),
async (req, res) => {
const payload = JSON.parse(req.body.payload);
// Verify signature (same as events)
if (!verifySlackSignature(req)) {
return res.status(401).send('Invalid signature');
}
// Handle interaction
switch (payload.type) {
case 'block_actions':
await handleBlockAction(payload);
break;
case 'view_submission':
await handleViewSubmission(payload);
break;
case 'shortcut':
await handleShortcut(payload);
break;
}
res.status(200).send();
}
);
async function handleBlockAction(payload: any): Promise<void> {
const action = payload.actions[0];
const userId = payload.user.id;
switch (action.action_id) {
case 'approve_request':
await approveRequest(action.value, userId);
// Update the original message
await slackClient.chat.update({
channel: payload.channel.id,
ts: payload.message.ts,
text: `✅ Approved by <@${userId}>`,
blocks: [] // Remove buttons
});
break;
case 'deny_request':
await denyRequest(action.value, userId);
break;
}
}
Twilio Webhooks
Twilio webhooks handle SMS, voice calls, and other communication channels. Unlike other providers that expect JSON responses, Twilio uses TwiML (Twilio Markup Language)—an XML-based format that controls call flows and message responses. Twilio's signature verification is URL-based, meaning the full webhook URL (including query parameters) is part of the signature calculation. This can cause verification failures if your URL differs between environments or if a proxy modifies the URL.
Signature Verification
// twilio-webhooks.ts
import twilio from 'twilio';
import express from 'express';
const authToken = process.env.TWILIO_AUTH_TOKEN!;
app.post('/webhooks/twilio/sms',
express.urlencoded({ extended: true }),
async (req, res) => {
const signature = req.headers['x-twilio-signature'] as string;
const url = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
// Verify using Twilio library
const valid = twilio.validateRequest(
authToken,
signature,
url,
req.body
);
if (!valid) {
return res.status(401).send('Invalid signature');
}
await handleTwilioSMS(req.body);
// Respond with TwiML
const twiml = new twilio.twiml.MessagingResponse();
twiml.message('Thanks for your message!');
res.type('text/xml');
res.send(twiml.toString());
}
);
// Twilio Voice webhooks
app.post('/webhooks/twilio/voice',
express.urlencoded({ extended: true }),
async (req, res) => {
const signature = req.headers['x-twilio-signature'] as string;
const url = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
if (!twilio.validateRequest(authToken, signature, url, req.body)) {
return res.status(401).send('Invalid signature');
}
const twiml = new twilio.twiml.VoiceResponse();
// Handle incoming call
if (req.body.CallStatus === 'ringing') {
twiml.say('Hello, you have reached our support line.');
twiml.gather({
numDigits: 1,
action: '/webhooks/twilio/voice/menu'
}).say('Press 1 for sales, press 2 for support.');
}
res.type('text/xml');
res.send(twiml.toString());
}
);
SMS and Voice Handlers
// twilio-handlers.ts
interface TwilioSMSPayload {
MessageSid: string;
From: string;
To: string;
Body: string;
NumMedia?: string;
MediaUrl0?: string;
}
async function handleTwilioSMS(payload: TwilioSMSPayload): Promise<void> {
const { MessageSid, From, To, Body } = payload;
// Log incoming message
await db.query(`
INSERT INTO sms_messages (
message_sid, from_number, to_number, body, direction, received_at
) VALUES ($1, $2, $3, $4, 'inbound', NOW())
`, [MessageSid, From, To, Body]);
// Parse commands
const command = Body.trim().toUpperCase();
switch (command) {
case 'STOP':
await handleOptOut(From);
break;
case 'START':
case 'UNSTOP':
await handleOptIn(From);
break;
case 'HELP':
await sendHelpMessage(From);
break;
default:
// Handle as regular message
await processIncomingMessage(From, Body);
}
}
// Call status webhooks
app.post('/webhooks/twilio/status',
express.urlencoded({ extended: true }),
async (req, res) => {
const { CallSid, CallStatus, CallDuration, From, To } = req.body;
await db.query(`
UPDATE calls
SET status = $1, duration = $2, updated_at = NOW()
WHERE call_sid = $3
`, [CallStatus, CallDuration, CallSid]);
if (CallStatus === 'completed') {
// Process call recording if enabled
if (req.body.RecordingUrl) {
await processRecording(CallSid, req.body.RecordingUrl);
}
}
res.status(200).send();
}
);
Multi-Provider Router
When your application integrates with multiple webhook providers, you'll want a unified architecture that handles routing, verification, and processing consistently. A well-designed multi-provider router abstracts away provider-specific details while ensuring each webhook is verified and processed correctly. This pattern also makes it easier to add new providers and maintain consistent logging, error handling, and idempotency across all integrations. When inspecting webhook payloads from different providers, the JSON Formatter helps you quickly compare payload structures and identify differences.
Unified Webhook Handler
// webhook-router.ts
import express from 'express';
import { verifyStripeSignature } from './providers/stripe';
import { verifyGitHubSignature } from './providers/github';
import { verifyShopifySignature } from './providers/shopify';
import { verifySlackSignature } from './providers/slack';
const app = express();
// Provider-specific middleware
const providers = {
stripe: {
verify: verifyStripeSignature,
getEventType: (req: express.Request, payload: any) => payload.type,
getEventId: (req: express.Request, payload: any) => payload.id
},
github: {
verify: verifyGitHubSignature,
getEventType: (req: express.Request) => req.headers['x-github-event'] as string,
getEventId: (req: express.Request) => req.headers['x-github-delivery'] as string
},
shopify: {
verify: verifyShopifySignature,
getEventType: (req: express.Request) => req.headers['x-shopify-topic'] as string,
getEventId: (req: express.Request, payload: any) => `${payload.id}-${req.headers['x-shopify-topic']}`
},
slack: {
verify: verifySlackSignature,
getEventType: (req: express.Request, payload: any) =>
payload.type === 'event_callback' ? payload.event.type : payload.type,
getEventId: (req: express.Request, payload: any) =>
payload.event_id || payload.event?.ts || crypto.randomUUID()
}
};
// Generic webhook endpoint
app.post('/webhooks/:provider',
express.raw({ type: '*/*' }),
async (req, res) => {
const provider = req.params.provider as keyof typeof providers;
if (!providers[provider]) {
return res.status(404).json({ error: 'Unknown provider' });
}
const config = providers[provider];
// Verify signature
if (!config.verify(req)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const payload = JSON.parse(req.body.toString());
const eventType = config.getEventType(req, payload);
const eventId = config.getEventId(req, payload);
// Check idempotency
if (await isProcessed(provider, eventId)) {
return res.status(200).json({ status: 'duplicate' });
}
// Queue for processing
await webhookQueue.add(`${provider}-webhook`, {
provider,
eventType,
eventId,
payload,
headers: req.headers,
receivedAt: Date.now()
});
res.status(200).json({ received: true });
}
);
Provider Configuration Management
// provider-config.ts
interface ProviderConfig {
name: string;
webhookSecret: string;
enabled: boolean;
endpoints: string[];
events: string[];
}
class WebhookProviderManager {
private configs = new Map<string, ProviderConfig>();
async loadConfigs(): Promise<void> {
const configs = await db.query(`
SELECT * FROM webhook_providers WHERE enabled = true
`);
for (const config of configs.rows) {
this.configs.set(config.name, {
name: config.name,
webhookSecret: config.webhook_secret,
enabled: config.enabled,
endpoints: config.endpoints,
events: config.events
});
}
}
getConfig(provider: string): ProviderConfig | undefined {
return this.configs.get(provider);
}
async updateSecret(provider: string, secret: string): Promise<void> {
await db.query(`
UPDATE webhook_providers
SET webhook_secret = $1, updated_at = NOW()
WHERE name = $2
`, [secret, provider]);
const config = this.configs.get(provider);
if (config) {
config.webhookSecret = secret;
}
}
async enableEvent(provider: string, event: string): Promise<void> {
await db.query(`
UPDATE webhook_providers
SET events = array_append(events, $1)
WHERE name = $2 AND NOT ($1 = ANY(events))
`, [event, provider]);
await this.loadConfigs();
}
}
export const providerManager = new WebhookProviderManager();
Testing Provider Integrations
Testing webhooks locally is notoriously difficult because providers need to reach your development machine. Each provider offers different approaches: CLI tools that forward events, webhook forwarding services, or APIs to trigger test events. Using these tools during development prevents the frustrating cycle of deploying to test webhooks and discovering signature verification failures in production. When writing integration tests, you'll need unique event IDs for idempotency testing—our UUID Generator can create these quickly.
Stripe Testing
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward webhooks to local
stripe listen --forward-to localhost:3000/webhooks/stripe
# Trigger events
stripe trigger payment_intent.succeeded
stripe trigger checkout.session.completed
stripe trigger customer.subscription.created
GitHub Testing
# Using smee.io for webhook forwarding
npx smee-client --url https://smee.io/your-channel --target http://localhost:3000/webhooks/github
# Configure webhook in GitHub repo settings to use smee.io URL
Shopify Testing
// Shopify provides test webhooks via API
const response = await fetch(
`https://${shop}/admin/api/2024-01/webhooks.json`,
{
method: 'POST',
headers: {
'X-Shopify-Access-Token': accessToken,
'Content-Type': 'application/json'
},
body: JSON.stringify({
webhook: {
topic: 'orders/create',
address: 'https://your-tunnel.ngrok.io/webhooks/shopify',
format: 'json'
}
})
}
);
Summary
Each webhook provider has unique characteristics:
- Stripe: Most developer-friendly, excellent CLI tools, timestamp-based signatures
- GitHub: Simple signature scheme, comprehensive event types, manual retry required
- Shopify: Very short timeout (5s), base64 HMAC signatures, topic-based routing
- Slack: Complex interaction model, strict 3-second timeout, URL verification challenge
- Twilio: XML (TwiML) responses, URL-based signature validation
The key to successful multi-provider webhook handling is:
- Use raw body for signature verification before any parsing
- Respond quickly within provider timeouts
- Process asynchronously for anything complex
- Implement idempotency using provider-specific event IDs
- Use official SDKs when available for signature verification