Next.js Integration Guide#

This guide covers everything you need to know about integrating HookRelay with your Next.js application, from basic setup to advanced patterns.

Overview#

HookRelay provides a Next.js SDK (@hookrelay/next) that makes webhook handling safe, reliable, and simple. The SDK wraps your webhook handlers with:

  • Automatic signature verification
  • Exactly-once execution guarantees
  • Automatic outcome reporting
  • Safe retry handling

Installation#

npm install @hookrelay/next
# or
pnpm add @hookrelay/next
# or
yarn add @hookrelay/next

Basic Setup#

1. Create a Webhook Route#

Create an API route in your Next.js app (App Router):

// app/api/webhooks/stripe/route.ts
import { withHookRelay } from '@hookrelay/next';

export const POST = withHookRelay(
  async (event) => {
    const stripeEvent = event.payload;
    
    // Handle the webhook
    if (stripeEvent.type === 'checkout.session.completed') {
      await fulfillOrder(stripeEvent.data.object);
    }
  },
  { provider: 'stripe' }
);

2. Configure Environment Variables#

Add your HookRelay secret to .env.local:

HOOKRELAY_SECRET=hr_secret_...

You can find your HookRelay secret in the endpoint details page in the HookRelay dashboard.

3. Deploy and Configure#

  1. Deploy your Next.js application
  2. Get your webhook handler URL: https://your-app.com/api/webhooks/stripe
  3. In HookRelay dashboard, set this as your endpoint’s Forward URL
  4. Configure your provider (Stripe, GitHub, etc.) to send webhooks to your HookRelay endpoint URL

Understanding the Event Object#

The event parameter in your handler is a HookRelayEvent object:

interface HookRelayEvent<T extends Provider> {
  id: string;              // HookRelay event ID (hr_...)
  provider: T;             // Provider name ('stripe', 'github', etc.)
  providerEventId: string;  // Original provider event ID
  payload: T extends keyof HookRelayEventMap 
    ? HookRelayEventMap[T] 
    : unknown;             // Typed webhook payload
  receivedAt: string;      // ISO timestamp when received
  attempt: number;         // Delivery attempt number (1-based)
  replayed: boolean;       // Whether this is a replayed event
  acknowledge: () => void; // No-op (acknowledgment is automatic)
}

Accessing Event Properties#

export const POST = withHookRelay(
  async (event) => {
    console.log(`Processing event ${event.id}`);
    console.log(`Provider: ${event.provider}`);
    console.log(`Provider Event ID: ${event.providerEventId}`);
    console.log(`Attempt: ${event.attempt}`);
    console.log(`Replayed: ${event.replayed}`);
    
    // Access the typed payload
    const stripeEvent = event.payload;
    // TypeScript knows this is a StripeEvent
  },
  { provider: 'stripe' }
);

Provider Types#

HookRelay supports multiple providers with type-safe payloads:

Stripe#

export const POST = withHookRelay(
  async (event) => {
    const stripeEvent = event.payload; // Typed as StripeEvent
    
    switch (stripeEvent.type) {
      case 'checkout.session.completed':
        await handleCheckout(stripeEvent);
        break;
      case 'customer.subscription.created':
        await handleSubscription(stripeEvent);
        break;
    }
  },
  { provider: 'stripe' }
);

GitHub#

export const POST = withHookRelay(
  async (event) => {
    const githubEvent = event.payload; // Typed as GitHubEvent
    
    if (githubEvent.action === 'opened' && githubEvent.issue) {
      await handleIssueOpened(githubEvent);
    }
  },
  { provider: 'github' }
);

Custom Types#

For full TypeScript support with Stripe types:

// types/hookrelay.d.ts
import type { Stripe } from 'stripe';

declare module '@hookrelay/next' {
  interface HookRelayEventMap {
    stripe: Stripe.Event;
  }
}

// Now event.payload is fully typed!
export const POST = withHookRelay(
  async (event) => {
    const stripeEvent = event.payload; // Full Stripe.Event type
    // ...
  },
  { provider: 'stripe' }
);

Handler Patterns#

Pattern 1: Quick Acknowledgment with Background Processing#

For long-running operations, acknowledge immediately and process asynchronously:

import { withHookRelay } from '@hookrelay/next';
import { queue } from '@/lib/queue';

export const POST = withHookRelay(
  async (event) => {
    // Queue for background processing
    await queue.add('process-webhook', {
      eventId: event.id,
      payload: event.payload,
    });
    
    // Handler returns immediately (< 1 second)
    // Background job processes the webhook
  },
  { provider: 'stripe' }
);

Pattern 2: Idempotency Checking#

Ensure events are only processed once:

import { withHookRelay } from '@hookrelay/next';
import { db } from '@/lib/db';

export const POST = withHookRelay(
  async (event) => {
    // Check if already processed
    const existing = await db.webhookEvents.findUnique({
      where: { hookRelayEventId: event.id },
    });
    
    if (existing) {
      console.log(`Event ${event.id} already processed`);
      return; // Idempotent - safe to skip
    }
    
    // Process the event
    await processEvent(event.payload);
    
    // Mark as processed
    await db.webhookEvents.create({
      data: {
        hookRelayEventId: event.id,
        providerEventId: event.providerEventId,
        processedAt: new Date(),
      },
    });
  },
  { provider: 'stripe' }
);

Pattern 3: Error Handling#

Handle errors gracefully:

import { withHookRelay } from '@hookrelay/next';

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),
      });
      
      // 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');
  }
  return false;
}

Pattern 4: Multiple Event Types#

Handle different event types in one handler:

import { withHookRelay } from '@hookrelay/next';

export const POST = withHookRelay(
  async (event) => {
    const stripeEvent = event.payload;
    
    switch (stripeEvent.type) {
      case 'checkout.session.completed':
        await handleCheckoutCompleted(stripeEvent);
        break;
        
      case 'customer.subscription.created':
        await handleSubscriptionCreated(stripeEvent);
        break;
        
      case 'customer.subscription.updated':
        await handleSubscriptionUpdated(stripeEvent);
        break;
        
      case 'customer.subscription.deleted':
        await handleSubscriptionDeleted(stripeEvent);
        break;
        
      case 'invoice.payment_succeeded':
        await handlePaymentSucceeded(stripeEvent);
        break;
        
      case 'invoice.payment_failed':
        await handlePaymentFailed(stripeEvent);
        break;
        
      default:
        console.log(`Unhandled event type: ${stripeEvent.type}`);
    }
  },
  { provider: 'stripe' }
);

Pattern 5: Replay Detection#

Handle replayed events differently:

import { withHookRelay } from '@hookrelay/next';

export const POST = withHookRelay(
  async (event) => {
    if (event.replayed) {
      console.log(`Replayed event: ${event.id}`);
      // You might want to handle replayed events differently
      // e.g., skip idempotency checks, log for audit, etc.
    }
    
    await processEvent(event.payload);
  },
  { provider: 'stripe' }
);

SDK Options#

The withHookRelay function accepts options:

export const POST = withHookRelay(
  async (event) => {
    // Your handler
  },
  {
    provider: 'stripe',
    timeoutMs: 8000,           // Handler timeout (default: 8000ms)
    enforceIdempotency: true,  // Enforce idempotency (default: true)
    allowReplay: true,         // Allow replayed events (default: true)
  }
);

Options Reference#

  • provider (required) - The webhook provider name
  • timeoutMs (optional) - Timeout for handler execution (default: 8000ms)
  • enforceIdempotency (optional) - Enforce idempotency checks (default: true)
  • allowReplay (optional) - Allow replayed events (default: true)

Advanced Patterns#

Using with Vercel Queue#

import { withHookRelay } from '@hookrelay/next';
import { Queue } from '@vercel/queue';

const queue = new Queue('webhook-processor');

export const POST = withHookRelay(
  async (event) => {
    await queue.enqueue('process', {
      eventId: event.id,
      payload: event.payload,
    });
  },
  { provider: 'stripe' }
);

Using with Inngest#

import { withHookRelay } from '@hookrelay/next';
import { inngest } from '@/lib/inngest';

export const POST = withHookRelay(
  async (event) => {
    await inngest.send({
      name: 'webhook/process',
      data: {
        eventId: event.id,
        payload: event.payload,
      },
    });
  },
  { provider: 'stripe' }
);

Using with Database Transactions#

import { withHookRelay } from '@hookrelay/next';
import { db } from '@/lib/db';

export const POST = withHookRelay(
  async (event) => {
    await db.$transaction(async (tx) => {
      // Mark event as received
      await tx.webhookEvents.create({
        data: {
          hookRelayEventId: event.id,
          providerEventId: event.providerEventId,
          status: 'processing',
        },
      });
      
      // Process the event
      await processEvent(event.payload, tx);
      
      // Mark as completed
      await tx.webhookEvents.update({
        where: { hookRelayEventId: event.id },
        data: { status: 'completed' },
      });
    });
  },
  { provider: 'stripe' }
);

Testing#

Testing Locally#

  1. Use ngrok to expose your local server:

    ngrok http 3000
  2. Update your HookRelay endpoint Forward URL to the ngrok URL

  3. Send test webhooks from your provider

Testing with Replay#

Use the HookRelay dashboard replay feature:

  1. Find a previously received event
  2. Click “Replay Event”
  3. Verify your handler processes it correctly

Unit Testing#

import { describe, it, expect } from 'vitest';
import { POST } from './route';

describe('webhook handler', () => {
  it('processes checkout.session.completed', async () => {
    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',
        // ... test payload
      }),
    });
    
    const response = await POST(request);
    expect(response.status).toBe(202);
  });
});

Common Issues#

”HOOKRELAY_SECRET environment variable is not configured”#

Solution: Set the HOOKRELAY_SECRET environment variable in your .env.local file or deployment environment.

”Invalid signature”#

Solution: Verify your HOOKRELAY_SECRET matches the secret shown in the endpoint details page in the HookRelay dashboard.

Handler times out#

Solution: Move long-running operations to background jobs. Return quickly from your handler.

Events processed multiple times#

Solution: Implement idempotency checks using the event.id to track processed events.

Best Practices#

  1. Return Quickly - Acknowledge receipt immediately, process asynchronously
  2. Use Idempotency - Check if event was already processed
  3. Handle Errors - Use try/catch and determine if errors are retryable
  4. Log Everything - Log event IDs, processing status, and errors
  5. Test Thoroughly - Use replay to test handler changes
  6. Monitor Failures - Set up alerts for failed webhooks
  7. Use TypeScript - Leverage type safety for payloads

Next Steps#