Skip to content

Commit 01f501d

Browse files
authored
chore(nextjs): Add support for pages router when verifying webhooks (#5618)
1 parent bae9741 commit 01f501d

File tree

3 files changed

+217
-0
lines changed

3 files changed

+217
-0
lines changed

.changeset/free-actors-prove.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@clerk/nextjs': patch
3+
---
4+
5+
Add support for webhook verification with Next.js Pages Router.
6+
7+
```ts
8+
// Next.js Pages Router
9+
import type { NextApiRequest, NextApiResponse } from 'next'
10+
import { verifyWebhook } from '@clerk/nextjs/webhooks';
11+
12+
export const config = {
13+
api: {
14+
bodyParser: false,
15+
},
16+
}
17+
18+
export default async function handler(
19+
req: NextApiRequest,
20+
res: NextApiResponse
21+
) {
22+
try {
23+
const evt = await verifyWebhook(req);
24+
// Handle webhook event
25+
res.status(200).json({ received: true });
26+
} catch (err) {
27+
res.status(400).json({ error: 'Webhook verification failed' });
28+
}
29+
}
30+
31+
// tRPC
32+
import { verifyWebhook } from '@clerk/nextjs/webhooks';
33+
34+
const webhookRouter = router({
35+
webhook: publicProcedure
36+
.input(/** schema */)
37+
.mutation(async ({ ctx }) => {
38+
const evt = await verifyWebhook(ctx.req);
39+
// Handle webhook event
40+
return { received: true };
41+
}),
42+
});
43+
```
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import type { NextApiRequest } from 'next';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import type { RequestLike } from '../server/types';
5+
import { verifyWebhook } from '../webhooks';
6+
7+
const mockSuccessResponse = {
8+
type: 'user.created',
9+
data: { id: 'user_123' },
10+
object: 'event',
11+
} as any;
12+
13+
const mockError = new Error('Missing required Svix headers: svix-id, svix-timestamp, svix-signature');
14+
15+
vi.mock('@clerk/backend/webhooks', () => ({
16+
verifyWebhook: vi.fn().mockImplementation((request: Request) => {
17+
const svixId = request.headers.get('svix-id');
18+
const svixTimestamp = request.headers.get('svix-timestamp');
19+
const svixSignature = request.headers.get('svix-signature');
20+
21+
if (!svixId || !svixTimestamp || !svixSignature) {
22+
throw mockError;
23+
}
24+
25+
return mockSuccessResponse;
26+
}),
27+
}));
28+
29+
describe('verifyWebhook', () => {
30+
const mockSecret = 'test_signing_secret';
31+
const mockBody = JSON.stringify(mockSuccessResponse);
32+
const mockHeaders = {
33+
'svix-id': 'msg_123',
34+
'svix-timestamp': '1614265330',
35+
'svix-signature': 'v1,test_signature',
36+
};
37+
38+
beforeEach(() => {
39+
process.env.CLERK_WEBHOOK_SIGNING_SECRET = mockSecret;
40+
});
41+
42+
describe('with standard Request', () => {
43+
it('verifies webhook with standard Request object', async () => {
44+
const mockRequest = new Request('https://clerk.com/webhooks', {
45+
method: 'POST',
46+
body: mockBody,
47+
headers: new Headers(mockHeaders),
48+
}) as unknown as RequestLike;
49+
50+
const result = await verifyWebhook(mockRequest);
51+
expect(result).toEqual(mockSuccessResponse);
52+
});
53+
});
54+
55+
describe('with NextApiRequest', () => {
56+
it('verifies webhook with NextApiRequest object', async () => {
57+
const mockNextApiRequest = {
58+
method: 'POST',
59+
url: '/webhooks',
60+
headers: {
61+
...mockHeaders,
62+
host: 'clerk.com',
63+
'x-forwarded-proto': 'https',
64+
},
65+
body: JSON.parse(mockBody),
66+
query: {},
67+
cookies: {},
68+
env: {},
69+
aborted: false,
70+
} as unknown as NextApiRequest;
71+
72+
const result = await verifyWebhook(mockNextApiRequest);
73+
expect(result).toEqual(mockSuccessResponse);
74+
});
75+
76+
it('throws error when headers are missing', async () => {
77+
const mockNextApiRequest = {
78+
method: 'POST',
79+
url: '/webhooks',
80+
headers: {
81+
host: 'clerk.com',
82+
'x-forwarded-proto': 'https',
83+
},
84+
body: JSON.parse(mockBody),
85+
query: {},
86+
cookies: {},
87+
env: {},
88+
aborted: false,
89+
} as unknown as NextApiRequest;
90+
91+
await expect(verifyWebhook(mockNextApiRequest)).rejects.toThrow(mockError);
92+
});
93+
});
94+
});

packages/nextjs/src/webhooks.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,81 @@
1+
/* eslint-disable import/export */
2+
import type { VerifyWebhookOptions } from '@clerk/backend/webhooks';
3+
import { verifyWebhook as verifyWebhookBase } from '@clerk/backend/webhooks';
4+
5+
import { getHeader, isNextRequest, isRequestWebAPI } from './server/headers-utils';
6+
import type { RequestLike } from './server/types';
7+
// Ordering of exports matter here since
8+
// we're overriding the base verifyWebhook
19
export * from '@clerk/backend/webhooks';
10+
11+
const SVIX_ID_HEADER = 'svix-id';
12+
const SVIX_TIMESTAMP_HEADER = 'svix-timestamp';
13+
const SVIX_SIGNATURE_HEADER = 'svix-signature';
14+
15+
/**
16+
* Verifies the authenticity of a webhook request using Svix.
17+
*
18+
* @param request - The incoming webhook request object
19+
* @param options - Optional configuration object
20+
* @param options.signingSecret - Custom signing secret. If not provided, falls back to CLERK_WEBHOOK_SIGNING_SECRET env variable
21+
* @throws Will throw an error if the webhook signature verification fails
22+
* @returns A promise that resolves to the verified webhook event data
23+
*
24+
* @example
25+
* ```typescript
26+
* import { verifyWebhook } from '@clerk/nextjs/webhooks';
27+
*
28+
* export async function POST(req: Request) {
29+
* try {
30+
* const evt = await verifyWebhook(req);
31+
*
32+
* // Access the event data
33+
* const { id } = evt.data;
34+
* const eventType = evt.type;
35+
*
36+
* // Handle specific event types
37+
* if (evt.type === 'user.created') {
38+
* console.log('New user created:', evt.data.id);
39+
* // Handle user creation
40+
* }
41+
*
42+
* return new Response('Success', { status: 200 });
43+
* } catch (err) {
44+
* console.error('Webhook verification failed:', err);
45+
* return new Response('Webhook verification failed', { status: 400 });
46+
* }
47+
* }
48+
* ```
49+
*/
50+
export async function verifyWebhook(request: RequestLike, options?: VerifyWebhookOptions) {
51+
if (isNextRequest(request) || isRequestWebAPI(request)) {
52+
return verifyWebhookBase(request, options);
53+
}
54+
55+
const webRequest = nextApiRequestToWebRequest(request);
56+
return verifyWebhookBase(webRequest, options);
57+
}
58+
59+
function nextApiRequestToWebRequest(req: RequestLike): Request {
60+
const headers = new Headers();
61+
const svixId = getHeader(req, SVIX_ID_HEADER) || '';
62+
const svixTimestamp = getHeader(req, SVIX_TIMESTAMP_HEADER) || '';
63+
const svixSignature = getHeader(req, SVIX_SIGNATURE_HEADER) || '';
64+
65+
headers.set(SVIX_ID_HEADER, svixId);
66+
headers.set(SVIX_TIMESTAMP_HEADER, svixTimestamp);
67+
headers.set(SVIX_SIGNATURE_HEADER, svixSignature);
68+
69+
// Create a dummy URL to make a Request object
70+
const protocol = getHeader(req, 'x-forwarded-proto') || 'http';
71+
const host = getHeader(req, 'x-forwarded-host') || 'clerk-dummy';
72+
const dummyOriginReqUrl = new URL(req.url || '', `${protocol}://${host}`);
73+
74+
const body = 'body' in req && req.body ? JSON.stringify(req.body) : undefined;
75+
76+
return new Request(dummyOriginReqUrl, {
77+
method: req.method,
78+
headers,
79+
body,
80+
});
81+
}

0 commit comments

Comments
 (0)