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:

  1. Create a new endpoint with a new secret
  2. Update your environment variables
  3. Update your provider’s webhook URL
  4. 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:

  1. Find a previously received event
  2. Make changes to your handler
  3. 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:

  1. Review failed events in the dashboard
  2. Identify the root cause
  3. Fix the issue
  4. 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:

  1. ✅ Return quickly from handlers
  2. ✅ Implement idempotency
  3. ✅ Handle errors gracefully
  4. ✅ Never expose secrets
  5. ✅ Log everything
  6. ✅ Set up monitoring and alerts
  7. ✅ Test thoroughly
  8. ✅ Optimize performance
  9. ✅ Document your code

For more information, see: