Best Practices
This guide covers production-ready best practices for using HookRelay with Next.js applications. Follow these recommendations to ensure reliable, secure, and maintainable webhook handling.
Handler Design
1. Return Quickly
Your webhook handler should return a 2xx status code as quickly as possible. HookRelay has a 30-second timeout, but you should aim for sub-5-second responses.
❌ Bad: Long-running operation blocks response
export const POST = withHookRelay(
async (event) => {
// This takes 45 seconds - will timeout!
await processLargeFile(event.payload);
await sendEmails(event.payload);
await updateDatabase(event.payload);
},
{ provider: 'stripe' }
);
✅ Good: Queue for background processing
import { queue } from '@/lib/queue';
export const POST = withHookRelay(
async (event) => {
// Queue immediately, return quickly
await queue.add('process-webhook', {
eventId: event.id,
payload: event.payload,
});
// Handler completes in < 1 second
},
{ provider: 'stripe' }
);
2. Implement Idempotency
Ensure your handlers are idempotent - processing the same event multiple times should have the same effect as processing it once.
❌ Bad: No idempotency check
export const POST = withHookRelay(
async (event) => {
// This will process the event every time, even if already processed
await chargeCustomer(event.payload.customerId);
},
{ provider: 'stripe' }
);
✅ Good: Check if already processed
import { db } from '@/lib/db';
export const POST = withHookRelay(
async (event) => {
// Check if already processed
const processed = await db.webhookEvents.findUnique({
where: { hookRelayEventId: event.id },
});
if (processed) {
console.log(`Event ${event.id} already processed`);
return; // Idempotent - safe to skip
}
// Process the event
await chargeCustomer(event.payload.customerId);
// Mark as processed
await db.webhookEvents.create({
data: {
hookRelayEventId: event.id,
providerEventId: event.providerEventId,
processedAt: new Date(),
},
});
},
{ provider: 'stripe' }
);
3. Handle Errors Gracefully
Catch and handle errors appropriately. Determine whether errors are retryable or permanent.
✅ Good: Proper error handling
export const POST = withHookRelay(
async (event) => {
try {
await processEvent(event.payload);
} catch (error) {
// Log the error
console.error('Webhook processing failed:', {
eventId: event.id,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
// Determine if error is retryable
if (isRetryableError(error)) {
// Re-throw to trigger retry
throw error;
} else {
// Non-retryable - log and continue
await logError(event.id, error);
// Don't throw - event is marked as processed
}
}
},
{ provider: 'stripe' }
);
function isRetryableError(error: unknown): boolean {
if (error instanceof Error) {
// Retry on network errors, timeouts, etc.
return error.message.includes('timeout') ||
error.message.includes('ECONNREFUSED') ||
error.message.includes('ENOTFOUND') ||
error.message.includes('ETIMEDOUT');
}
return false;
}
4. Validate Payloads
Validate that the payload structure matches what you expect, but don’t reject invalid payloads (log them instead).
✅ Good: Validate but don’t reject
export const POST = withHookRelay(
async (event) => {
const payload = event.payload;
// Validate structure
if (!payload.data || !payload.data.object) {
console.warn('Invalid payload structure:', {
eventId: event.id,
payload: payload,
});
// Don't reject - log and continue
return;
}
// Process valid payload
await processEvent(payload);
},
{ provider: 'stripe' }
);
Security
1. Never Expose Secrets
Never commit API keys or secrets to version control.
❌ Bad: Hardcoded secrets
const HOOKRELAY_SECRET = 'hr_secret_abc123...'; // Never do this!
✅ Good: Use environment variables
const HOOKRELAY_SECRET = process.env.HOOKRELAY_SECRET;
if (!HOOKRELAY_SECRET) {
throw new Error('HOOKRELAY_SECRET is not configured');
}
2. Use Environment-Specific Secrets
Use different secrets for development, staging, and production.
# .env.local (development)
HOOKRELAY_SECRET=hr_secret_dev_...
# .env.production (production)
HOOKRELAY_SECRET=hr_secret_prod_...
3. Rotate Secrets Regularly
Rotate your HookRelay secrets periodically:
- Create a new endpoint with a new secret
- Update your environment variables
- Update your provider’s webhook URL
- Delete the old endpoint after verifying the new one works
4. Verify Signatures
Always verify webhook signatures. The withHookRelay wrapper does this automatically, but if you’re not using it:
✅ Good: Verify signatures
import crypto from 'crypto';
function verifySignature(
payload: string,
timestamp: string,
signature: string,
secret: string
): boolean {
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
return signature === `sha256=${expectedSignature}`;
}
Monitoring & Observability
1. Log Everything
Log important information for debugging and auditing:
export const POST = withHookRelay(
async (event) => {
console.log('Processing webhook:', {
eventId: event.id,
providerEventId: event.providerEventId,
provider: event.provider,
eventType: event.payload.type,
attempt: event.attempt,
replayed: event.replayed,
timestamp: new Date().toISOString(),
});
try {
await processEvent(event.payload);
console.log('Webhook processed successfully:', event.id);
} catch (error) {
console.error('Webhook processing failed:', {
eventId: event.id,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
},
{ provider: 'stripe' }
);
2. Use Structured Logging
Use a structured logging format for better querying and analysis:
import { logger } from '@/lib/logger';
export const POST = withHookRelay(
async (event) => {
logger.info('webhook.received', {
eventId: event.id,
provider: event.provider,
eventType: event.payload.type,
attempt: event.attempt,
});
await processEvent(event.payload);
logger.info('webhook.processed', {
eventId: event.id,
});
},
{ provider: 'stripe' }
);
3. Set Up Alerts
Set up alerts for:
- High failure rates
- Events stuck in retrying state
- Repeated failures for the same endpoint
4. Monitor Dashboard Regularly
Regularly check the HookRelay dashboard for:
- Failed events that need attention
- High retry rates (indicates issues)
- Slow response times
- Unusual patterns
Testing
1. Test Locally with ngrok
Use ngrok to test webhooks locally:
# Start your Next.js app
npm run dev
# In another terminal, start ngrok
ngrok http 3000
# Update your HookRelay endpoint Forward URL to the ngrok URL
# Send test webhooks from your provider
2. Use Replay for Testing
Use the replay feature to test handler changes:
- Find a previously received event
- Make changes to your handler
- Replay the event to verify the fix
3. Write Unit Tests
Write unit tests for your handlers:
import { describe, it, expect, vi } from 'vitest';
import { POST } from './route';
describe('webhook handler', () => {
it('processes checkout.session.completed', async () => {
const mockProcessEvent = vi.fn();
const request = new Request('http://localhost/api/webhooks/stripe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-HookRelay-Event-Id': 'hr_test_123',
'X-HookRelay-Provider': 'stripe',
'X-HookRelay-Signature': 'sha256=...',
'X-HookRelay-Timestamp': String(Date.now()),
},
body: JSON.stringify({
type: 'checkout.session.completed',
data: { object: { id: 'cs_test_123' } },
}),
});
const response = await POST(request);
expect(response.status).toBe(202);
});
});
Performance
1. Optimize Database Queries
Use proper indexes and optimize queries:
// ✅ Good: Use indexed queries
const event = await db.webhookEvents.findUnique({
where: { hookRelayEventId: event.id }, // Indexed field
});
// ❌ Bad: Full table scan
const event = await db.webhookEvents.findFirst({
where: { hookRelayEventId: event.id }, // Not indexed
});
2. Use Connection Pooling
Use connection pooling for database connections:
import { PrismaClient } from '@prisma/client';
const db = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL,
},
},
});
3. Cache When Appropriate
Cache frequently accessed data:
import { cache } from '@/lib/cache';
export const POST = withHookRelay(
async (event) => {
// Cache customer data
const customer = await cache.getOrSet(
`customer:${event.payload.customerId}`,
async () => await fetchCustomer(event.payload.customerId),
{ ttl: 3600 } // 1 hour
);
await processEvent(event.payload, customer);
},
{ provider: 'stripe' }
);
Error Recovery
1. Implement Dead Letter Queue
For events that fail after all retries, implement a dead letter queue:
export const POST = withHookRelay(
async (event) => {
try {
await processEvent(event.payload);
} catch (error) {
// If this is a replayed event and it's still failing,
// send to dead letter queue
if (event.attempt >= 5 && event.replayed) {
await deadLetterQueue.add({
eventId: event.id,
payload: event.payload,
error: error instanceof Error ? error.message : String(error),
});
}
throw error;
}
},
{ provider: 'stripe' }
);
2. Manual Recovery Process
Have a process for manually recovering failed events:
- Review failed events in the dashboard
- Identify the root cause
- Fix the issue
- Replay the events
Documentation
1. Document Handler Logic
Document what each handler does:
/**
* Handles Stripe checkout.session.completed events.
*
* When a checkout session is completed:
* 1. Fulfills the order
* 2. Sends confirmation email
* 3. Updates customer record
*
* @param event - HookRelay event containing Stripe webhook payload
*/
export const POST = withHookRelay(
async (event) => {
// Implementation
},
{ provider: 'stripe' }
);
2. Document Error Handling
Document how errors are handled:
/**
* Error handling:
* - Network errors: Retried automatically by HookRelay
* - Validation errors: Logged but not retried
* - Business logic errors: Logged and sent to dead letter queue
*/
Summary
Follow these best practices to ensure reliable, secure, and maintainable webhook handling:
- ✅ Return quickly from handlers
- ✅ Implement idempotency
- ✅ Handle errors gracefully
- ✅ Never expose secrets
- ✅ Log everything
- ✅ Set up monitoring and alerts
- ✅ Test thoroughly
- ✅ Optimize performance
- ✅ Document your code
For more information, see: