Skip to content

fix(client): verify webhooks signed with Standard Webhooks secrets - #97

Open
formstep wants to merge 1 commit into
convex:mainfrom
formstep:fix/standard-webhooks-secret
Open

formstep wants to merge 1 commit into
convex:mainfrom
formstep:fix/standard-webhooks-secret

Conversation

@formstep

@formstep formstep commented Oct 8, 2026

Copy link
Copy Markdown

Problem

Polar signs webhook deliveries with one of two keys, depending on when the endpoint secret was generated (Polar docs: Handle & monitor webhook deliveries):

  • Before 2026-09-08: the UTF-8 bytes of the whole secret.
  • On or after 2026-09-08: Standard Webhooks, where the key is the base64 part of the whsec_ secret.

registerRoutes verifies with validateEvent from @polar-sh/sdk/webhooks. In every 0.x release (checked 0.47.0 through 0.49.0) it only knows the first key. So any endpoint created since 2026-09-08 gets a 403 with No matching signature found on every delivery. That includes a new organization, a new endpoint, or a regenerated secret. Subscriptions never reach the component, and checkouts never activate anything.

We hit this in production after moving to a new Polar organization. Every subscription.* and order.* delivery to /polar/events returned 403.

Why not bump the SDK

@polar-sh/sdk 1.0 tries both keys, but it no longer exports @polar-sh/sdk/webhooks or @polar-sh/sdk/funcs/*, which this component imports. That is why the Renovate bump in #96 fails CI. Moving to 1.0 is the long-term fix. This PR keeps 0.x working until then.

Fix

A new validatePolarEvent in src/client/webhooks.ts, used by registerRoutes:

  1. Try validateEvent with the secret, exactly as before. Legacy secrets, including legacy ones that start with whsec_, behave as they do today.
  2. If that fails with WebhookVerificationError and the secret starts with whsec_, verify with the Standard Webhooks key via standardwebhooks, which is already a dependency.
  3. If that matches, re-sign the verified body with the legacy key and pass it to validateEvent. That is the SDK's only public event parser, so PolarWebhookEvent and event parsing stay the same.
  4. If neither key matches, rethrow the SDK's WebhookVerificationError, so the route still answers 403, not 500.

The Standard Webhooks key is only tried for whsec_ secrets. A legacy secret that happens to be valid base64 therefore does not gain a second key.

The re-signing step is the unusual part. It exists only because SDK 0.x does not export its parser. Once the component moves to SDK 1.0, this file can go.

Tests

src/client/webhooks.test.ts, with a real customer.deleted payload that the SDK parses:

  • parses a delivery signed with a legacy secret (polar_whs_…)
  • parses a delivery signed with a legacy whsec_ secret that is not base64
  • parses a delivery signed with a Standard Webhooks whsec_ secret
  • rejects another Standard Webhooks secret, another legacy whsec_ secret, and a changed body, each with the SDK's WebhookVerificationError

npm run build, npm run typecheck, npm run lint and npm run test pass (75 tests).

Polar signs deliveries with Standard Webhooks for every endpoint secret
generated on or after 2026-09-08: the key is the base64 part of the whsec_
secret. validateEvent from @polar-sh/sdk 0.x only knows the older key (the
UTF-8 bytes of the whole secret), so registerRoutes answers 403 with "No
matching signature found" for every delivery to a new endpoint, a new
organization, or a regenerated secret.

registerRoutes now tries the legacy key first, as before, and then the
Standard Webhooks key when the secret starts with whsec_. A delivery that
matches the Standard Webhooks key is re-signed with the legacy key and passed
to validateEvent, which is the SDK's only public event parser, so event types
and parsing stay the same. A delivery that matches neither key still throws the
SDK's WebhookVerificationError and gets a 403.
@coderabbitai

coderabbitai Bot commented Oct 8, 2026

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: get-convex/coderabbit/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: ea11350b-a1c8-4512-a1d7-a1452aaad132
📥 Commits

Reviewing files that changed from the base of the PR and between 9ffd196 and 57244b6.

📒 Files selected for processing (3)
  • src/client/index.ts
  • src/client/webhooks.test.ts
  • src/client/webhooks.ts

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Webhook requests now use validatePolarEvent instead of the SDK validator directly. The local validator tries SDK validation first. For a whsec_ secret and a WebhookVerificationError, it checks the Standard Webhooks signature and retries SDK event parsing with a replacement signature if verification succeeds. Tests cover legacy and Standard Webhooks signatures, mismatched secrets, and altered bodies. The route retains its existing verification-error response handling.

Sequence Diagram(s)

sequenceDiagram
  participant WebhookRoute
  participant validatePolarEvent
  participant SDKvalidateEvent
  participant StandardWebhooks
  WebhookRoute->>validatePolarEvent: Pass body, headers, and secret
  validatePolarEvent->>SDKvalidateEvent: Validate event
  SDKvalidateEvent-->>validatePolarEvent: Raise WebhookVerificationError
  validatePolarEvent->>StandardWebhooks: Verify when secret starts with whsec_
  StandardWebhooks-->>validatePolarEvent: Return verification result
  validatePolarEvent->>SDKvalidateEvent: Retry with replacement signature after successful verification
Loading

Priority: ⬇️ Low

Change: Bug fix

Merge Risk: ⚪ Minimal · up to 57244

The webhook compatibility change is ready to merge after normal checks; no actionable risk remains.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: support for verifying webhooks signed with Standard Webhooks secrets.
Description check ✅ Passed The description directly explains the webhook verification problem, the fallback validation logic, affected SDK versions, tests, and reported validation results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants