From e1854285d7cacad861682703d41b37a65af58bec Mon Sep 17 00:00:00 2001 From: Michael Novotny Date: Wed, 23 Sep 2026 16:09:03 -0500 Subject: [PATCH 1/4] feat(backend): return event timestamp from verifyWebhook() Co-Authored-By: Claude Opus 5.5 --- .changeset/verify-webhook-timestamp.md | 5 ++++ .../backend/src/__tests__/webhooks.test.ts | 25 +++++++++++++++++++ .../backend/src/api/resources/Webhooks.ts | 8 +++++- packages/backend/src/webhooks.ts | 1 + 4 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 .changeset/verify-webhook-timestamp.md diff --git a/.changeset/verify-webhook-timestamp.md b/.changeset/verify-webhook-timestamp.md new file mode 100644 index 00000000000..2060ad71168 --- /dev/null +++ b/.changeset/verify-webhook-timestamp.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': minor +--- + +`verifyWebhook()` now returns the event's top-level `timestamp` (milliseconds since epoch when the event occurred), so webhook handlers can use it to order events. diff --git a/packages/backend/src/__tests__/webhooks.test.ts b/packages/backend/src/__tests__/webhooks.test.ts index b1ce678a927..2f5f0b0fa66 100644 --- a/packages/backend/src/__tests__/webhooks.test.ts +++ b/packages/backend/src/__tests__/webhooks.test.ts @@ -248,4 +248,29 @@ describe('verifyWebhook', () => { expect(result).toHaveProperty('event_attributes.http_request.client_ip', '127.0.0.1'); expect(result).toHaveProperty('event_attributes.http_request.user_agent', 'Mozilla/5.0 (Test)'); }); + + it('should parse timestamp', async () => { + const clerkPayload = JSON.stringify({ + type: 'user.created', + data: { id: 'user_123', email: 'test@example.com' }, + timestamp: 1654012591835, + }); + const svixId = 'msg_123'; + const svixTimestamp = (Date.now() / 1000).toString(); + const validSignature = createValidSignature(svixId, svixTimestamp, clerkPayload); + + const mockRequest = new Request('https://clerk.com/webhooks', { + method: 'POST', + body: clerkPayload, + headers: new Headers({ + 'svix-id': svixId, + 'svix-timestamp': svixTimestamp, + 'svix-signature': validSignature, + }), + }); + + const result = await verifyWebhook(mockRequest, { signingSecret: mockSecret }); + expect(result).toHaveProperty('type', 'user.created'); + expect(result).toHaveProperty('timestamp', 1654012591835); + }); }); diff --git a/packages/backend/src/api/resources/Webhooks.ts b/packages/backend/src/api/resources/Webhooks.ts index 5d0bcf927a9..6eea607147f 100644 --- a/packages/backend/src/api/resources/Webhooks.ts +++ b/packages/backend/src/api/resources/Webhooks.ts @@ -25,7 +25,13 @@ type WebhookEventAttributes = { }; }; -type Webhook = { type: EvtType; object: 'event'; data: Data; event_attributes: WebhookEventAttributes }; +type Webhook = { + type: EvtType; + object: 'event'; + data: Data; + event_attributes: WebhookEventAttributes; + timestamp: number; +}; export type UserWebhookEvent = | Webhook<'user.created' | 'user.updated', UserJSON> diff --git a/packages/backend/src/webhooks.ts b/packages/backend/src/webhooks.ts index 3f1e7c69392..470bc9a8da3 100644 --- a/packages/backend/src/webhooks.ts +++ b/packages/backend/src/webhooks.ts @@ -135,6 +135,7 @@ export async function verifyWebhook(request: Request, options: VerifyWebhookOpti object: 'event', data: payload.data, event_attributes: payload.event_attributes, + timestamp: payload.timestamp, } as WebhookEvent; } catch (e) { return errorThrower.throw(`Unable to verify incoming webhook: ${e instanceof Error ? e.message : 'Unknown error'}`); From 72aa6b037e45192215629508265e6a9285fba798 Mon Sep 17 00:00:00 2001 From: Michael Novotny Date: Wed, 23 Sep 2026 16:13:53 -0500 Subject: [PATCH 2/4] feat(backend): return instance_id from verifyWebhook() Co-Authored-By: Claude Opus 5.5 --- .changeset/verify-webhook-timestamp.md | 2 +- packages/backend/src/__tests__/webhooks.test.ts | 4 +++- packages/backend/src/api/resources/Webhooks.ts | 1 + packages/backend/src/webhooks.ts | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.changeset/verify-webhook-timestamp.md b/.changeset/verify-webhook-timestamp.md index 2060ad71168..06d87a371f7 100644 --- a/.changeset/verify-webhook-timestamp.md +++ b/.changeset/verify-webhook-timestamp.md @@ -2,4 +2,4 @@ '@clerk/backend': minor --- -`verifyWebhook()` now returns the event's top-level `timestamp` (milliseconds since epoch when the event occurred), so webhook handlers can use it to order events. +`verifyWebhook()` now returns the event's top-level `timestamp` (milliseconds since epoch when the event occurred) and `instance_id`, so webhook handlers can order events and identify the instance that sent them. diff --git a/packages/backend/src/__tests__/webhooks.test.ts b/packages/backend/src/__tests__/webhooks.test.ts index 2f5f0b0fa66..353cd554e15 100644 --- a/packages/backend/src/__tests__/webhooks.test.ts +++ b/packages/backend/src/__tests__/webhooks.test.ts @@ -249,11 +249,12 @@ describe('verifyWebhook', () => { expect(result).toHaveProperty('event_attributes.http_request.user_agent', 'Mozilla/5.0 (Test)'); }); - it('should parse timestamp', async () => { + it('should parse timestamp and instance_id', async () => { const clerkPayload = JSON.stringify({ type: 'user.created', data: { id: 'user_123', email: 'test@example.com' }, timestamp: 1654012591835, + instance_id: 'ins_123', }); const svixId = 'msg_123'; const svixTimestamp = (Date.now() / 1000).toString(); @@ -272,5 +273,6 @@ describe('verifyWebhook', () => { const result = await verifyWebhook(mockRequest, { signingSecret: mockSecret }); expect(result).toHaveProperty('type', 'user.created'); expect(result).toHaveProperty('timestamp', 1654012591835); + expect(result).toHaveProperty('instance_id', 'ins_123'); }); }); diff --git a/packages/backend/src/api/resources/Webhooks.ts b/packages/backend/src/api/resources/Webhooks.ts index 6eea607147f..f9f3ea08d4d 100644 --- a/packages/backend/src/api/resources/Webhooks.ts +++ b/packages/backend/src/api/resources/Webhooks.ts @@ -31,6 +31,7 @@ type Webhook = { data: Data; event_attributes: WebhookEventAttributes; timestamp: number; + instance_id: string; }; export type UserWebhookEvent = diff --git a/packages/backend/src/webhooks.ts b/packages/backend/src/webhooks.ts index 470bc9a8da3..4f8976cbc8c 100644 --- a/packages/backend/src/webhooks.ts +++ b/packages/backend/src/webhooks.ts @@ -136,6 +136,7 @@ export async function verifyWebhook(request: Request, options: VerifyWebhookOpti data: payload.data, event_attributes: payload.event_attributes, timestamp: payload.timestamp, + instance_id: payload.instance_id, } as WebhookEvent; } catch (e) { return errorThrower.throw(`Unable to verify incoming webhook: ${e instanceof Error ? e.message : 'Unknown error'}`); From 832033f90b7425724223c7ac43647c0746c74f4a Mon Sep 17 00:00:00 2001 From: Michael Novotny Date: Wed, 23 Sep 2026 16:24:16 -0500 Subject: [PATCH 3/4] test(tanstack-react-start): match reworded missing secret key error Co-Authored-By: Claude Opus 5.5 --- .../src/server/__tests__/loadOptions.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tanstack-react-start/src/server/__tests__/loadOptions.test.ts b/packages/tanstack-react-start/src/server/__tests__/loadOptions.test.ts index 09ff5aa0294..bcd979fe440 100644 --- a/packages/tanstack-react-start/src/server/__tests__/loadOptions.test.ts +++ b/packages/tanstack-react-start/src/server/__tests__/loadOptions.test.ts @@ -12,6 +12,6 @@ describe('loadOptions', () => { ['only the secret key is missing', { publishableKey: 'pk_test_Zm9vLWJhci0xMi5jbGVyay5hY2NvdW50cy5kZXYk' }], ['both keys are missing', {}], ])('throws the CLI-pointing error when %s', (_, overrides) => { - expect(() => loadOptions(request, overrides)).toThrow(/Missing secretKey[\s\S]*npx clerk@latest init/); + expect(() => loadOptions(request, overrides)).toThrow(/Missing secretKey[\s\S]*npx clerk@latest link/); }); }); From 372e6708ecf6efbbe86c4abd2972d4d7015ad9d3 Mon Sep 17 00:00:00 2001 From: Michael Novotny Date: Wed, 23 Sep 2026 16:29:21 -0500 Subject: [PATCH 4/4] Revert "test(tanstack-react-start): match reworded missing secret key error" This reverts commit 832033f90b. The fix moves to its own PR. Co-Authored-By: Claude Opus 5.5 --- .../src/server/__tests__/loadOptions.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tanstack-react-start/src/server/__tests__/loadOptions.test.ts b/packages/tanstack-react-start/src/server/__tests__/loadOptions.test.ts index bcd979fe440..09ff5aa0294 100644 --- a/packages/tanstack-react-start/src/server/__tests__/loadOptions.test.ts +++ b/packages/tanstack-react-start/src/server/__tests__/loadOptions.test.ts @@ -12,6 +12,6 @@ describe('loadOptions', () => { ['only the secret key is missing', { publishableKey: 'pk_test_Zm9vLWJhci0xMi5jbGVyay5hY2NvdW50cy5kZXYk' }], ['both keys are missing', {}], ])('throws the CLI-pointing error when %s', (_, overrides) => { - expect(() => loadOptions(request, overrides)).toThrow(/Missing secretKey[\s\S]*npx clerk@latest link/); + expect(() => loadOptions(request, overrides)).toThrow(/Missing secretKey[\s\S]*npx clerk@latest init/); }); });