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
- Deploy your Next.js application
- Get your webhook handler URL:
https://your-app.com/api/webhooks/stripe - In HookRelay dashboard, set this as your endpoint’s Forward URL
- 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 nametimeoutMs(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
-
Use ngrok to expose your local server:
ngrok http 3000 -
Update your HookRelay endpoint Forward URL to the ngrok URL
-
Send test webhooks from your provider
Testing with Replay
Use the HookRelay dashboard replay feature:
- Find a previously received event
- Click “Replay Event”
- 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
- Return Quickly - Acknowledge receipt immediately, process asynchronously
- Use Idempotency - Check if event was already processed
- Handle Errors - Use try/catch and determine if errors are retryable
- Log Everything - Log event IDs, processing status, and errors
- Test Thoroughly - Use replay to test handler changes
- Monitor Failures - Set up alerts for failed webhooks
- Use TypeScript - Leverage type safety for payloads
Next Steps
- Learn about Events and their lifecycle
- Understand Delivery & Retries behavior
- Review Failure Classification for debugging
- Check out Best Practices for production deployments