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 Stripe 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' }
);

Stripe Event Types#

HookRelay provides type-safe payloads for Stripe webhooks:

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' }
);

Full Stripe Types#

For full TypeScript support with official 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#