-
Notifications
You must be signed in to change notification settings - Fork 4
feat(webhooks): clerk webhooks toolkit (V1)
#366
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
4c414f7
feat(webhooks): add PLAPI-free `webhooks listen` + `verify` (V1)
rafa-thayto 986434f
docs(rules): add command-registration rule, adapted to the V1 webhook…
rafa-thayto 3a23119
feat(webhooks): add `webhooks token` and surface the listen flow in g…
rafa-thayto 79ee02c
feat(webhooks): require --forward-to and warn on unpinned relay token
rafa-thayto 226075f
feat(webhooks): link the Clerk Dashboard webhooks page in the listen …
rafa-thayto 3affcbd
fix(cli): yield stdin to subcommands that claim it with a bare `-`
rafa-thayto 9c81ac7
fix(webhooks): harden listen/verify from adversarial testing (V1)
rafa-thayto 09ddfc0
fix(ui): correct animated "Next steps" cursor math for wrapped lines
rafa-thayto 8af308f
refactor(webhooks): simplify listen/render/token and add a relay-conn…
rafa-thayto 4386e2e
fix(webhooks): address CodeRabbit review feedback
rafa-thayto aca6c50
fix(webhooks): address wyattjoh review feedback
rafa-thayto ad951c7
fix(webhooks): block hop-by-hop --header overrides and correct README…
rafa-thayto 5c86fb6
feat(webhooks): render runnable examples beneath usage errors
rafa-thayto 4fa52b4
feat(webhooks): mask relay receiver URL behind webhooks.clerk.com
rafa-thayto File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| --- | ||
| "clerk": minor | ||
| --- | ||
|
|
||
| Add the `clerk webhooks` command group (V1): a PLAPI-free local webhooks toolkit. | ||
|
|
||
| - `clerk webhooks listen` — open a standalone Svix relay tunnel and forward deliveries to a local handler. No auth, no linked project, no backend. `--forward-to` is required. Without `--token`, the banner warns that the auto-generated relay token isn't a guaranteed-stable handle and prints the exact `--token` to pin next time; `--token <c_…>` pins an explicit, shareable URL. Flags: `--forward-to` (required), `--token`, `--headers`, `--json`. When `--forward-to` is missing, the usage error prints a runnable example beneath it. | ||
| - `clerk webhooks verify` — verify a webhook signature offline (HMAC-SHA256), from a saved `listen` event line (`--delivery`) or the four explicit header values. No network calls. | ||
| - `clerk webhooks token` — generate a valid relay token (`c_` + 10 base62 chars) for `listen --token`. Prints the bare token to stdout so it pipes: `clerk webhooks listen --token "$(clerk webhooks token)"`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| --- | ||
| description: Command registration conventions — every command group registers via register<Name>(program) from its index.ts, listed in the registrants array | ||
| paths: | ||
| - "packages/cli-core/src/cli-program.ts" | ||
| - "packages/cli-core/src/commands/*/index.ts" | ||
| alwaysApply: false | ||
| --- | ||
|
|
||
| Every command group is wired into the root program through a **registrant function**, never inline in `createProgram()`. | ||
|
|
||
| ## The pattern | ||
|
|
||
| 1. Each command group exports `register<Name>(program: Program): void` from `packages/cli-core/src/commands/<name>/index.ts`. It builds the whole `program.command("<name>")` subtree (options, arguments, `.setExamples()`, subcommands) and wires each `.action()` to the handler functions in sibling files. | ||
| 2. `cli-program.ts` imports that function and adds it to the `registrants: CommandRegistrant[]` array. `createProgram()` only configures the root program + global hooks, then loops `for (const register of registrants) register(program)`. | ||
|
|
||
| **Do not** build a command tree inline inside `createProgram()`. If you're adding a `program.command(...)` (or `webhooks`-style group) directly in `cli-program.ts`, stop — move it to a `register<Name>` in the group's `index.ts` and append the function to `registrants` instead. | ||
|
|
||
| ## `index.ts` shape | ||
|
|
||
| ```ts | ||
| import type { Program } from "../../cli-program.ts"; | ||
| import { list } from "./list.ts"; | ||
| import { create } from "./create.ts"; | ||
|
|
||
| export function registerApps(program: Program): void { | ||
| const apps = program.command("apps").description("Manage your Clerk applications"); | ||
|
|
||
| apps | ||
| .command("list") | ||
| .description("List your Clerk applications") | ||
| .option("--json", "Output as JSON") | ||
| .setExamples([{ command: "clerk apps list", description: "List all applications" }]) | ||
| .action(list); | ||
|
|
||
| apps.command("create").argument("<name>", "Application name").action(create); | ||
| } | ||
| ``` | ||
|
|
||
| - Import the `Program` type from `../../cli-program.ts` (type-only — no runtime cycle, this is the established pattern). | ||
| - Keep handler _logic_ in sibling files (`list.ts`, `create.ts`, …); `index.ts` is wiring only. A handler-map object (e.g. `const handlers = { list, create }`) is fine when actions need typed `Parameters<typeof handlers.x>[0]` casts. | ||
|
|
||
| ## `cli-program.ts` shape | ||
|
|
||
| ```ts | ||
| import { registerApps } from "./commands/apps/index.ts"; | ||
| // … | ||
| const registrants: CommandRegistrant[] = [ | ||
| registerInit, | ||
| registerApps, | ||
| // … one entry per command group, in display order … | ||
| registerExtras, | ||
| ]; | ||
|
|
||
| export function createProgram(): Program { | ||
| const program = new Command() /* … global options … */ as Program; | ||
| program.hook("preAction" /* … */); | ||
| for (const register of registrants) register(program); | ||
| return program; | ||
| } | ||
| ``` | ||
|
|
||
| Helpers used by only one group (e.g. `createOption`, `parseIntegerOption`, `getAuthToken`) belong in that group's `index.ts`, not imported into `cli-program.ts`. | ||
|
|
||
| ## Groups with global options, an optional group-level hook, and subcommands | ||
|
|
||
| Build the group exactly as above and attach its concerns inside the same `register<Name>`: parent `.option(...)` flags inherited via `optsWithGlobals()`, an optional group `.hook("preAction", …)` for shared gating (e.g. auth), and one `.command(...)` per subcommand. See `commands/users/index.ts` (`registerUsers`) for a multi-subcommand group with a handler-map and options inherited via `optsWithGlobals()`. A group only needs a `preAction` gate when its subcommands require shared setup; auth-free groups like `commands/webhooks/index.ts` (`registerWebhooks`) — `token` (relay token generator), `listen` (relay tunnel), and `verify` (offline HMAC) — omit it entirely. | ||
|
|
||
| Related: [commands.md](./commands.md) (per-command directory + README + agent-mode rules) and [completion.md](./completion.md) (keep `.choices()` / `__complete.ts` in sync when adding commands or options). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| # `clerk webhooks` (V1) | ||
|
|
||
| The PLAPI-free slice of the webhooks toolkit: a local relay tunnel plus offline | ||
| signature verification. Neither subcommand calls the Clerk API, requires auth, or | ||
| needs a linked project. | ||
|
|
||
| > **No Clerk API calls.** `listen` talks only to the Svix relay | ||
| > (`wss://api.relay.svix.com`); `verify` is pure local HMAC. There is no | ||
| > PLAPI/BAPI dependency in this command group. | ||
|
|
||
| ## The flow | ||
|
|
||
| ```sh | ||
| clerk webhooks token # 1. mint a stable token | ||
| clerk webhooks listen --token "$(clerk webhooks token)" --forward-to … # 2. stream to your app | ||
| clerk webhooks verify --secret whsec_... --delivery @event.json # 3. verify a delivery | ||
| ``` | ||
|
|
||
| ## `clerk webhooks token` | ||
|
|
||
| Generate a valid relay token (`c_` + 10 base62 chars) for `listen --token`. The | ||
| bare token prints to **stdout** so it pipes cleanly; human mode adds a usage hint | ||
| on stderr (which never pollutes the pipe). | ||
|
|
||
| ```sh | ||
| clerk webhooks token # → c_AbCd123456 | ||
| clerk webhooks listen --token "$(clerk webhooks token)" # generate + pin in one step | ||
| clerk webhooks token --json # → {"token":"c_AbCd123456"} | ||
| ``` | ||
|
|
||
| Why it exists: the `--token` format is exact (`c_` + **10** base62 chars), so this | ||
| removes the guesswork of hand-writing one. | ||
|
|
||
| ## `clerk webhooks listen` | ||
|
|
||
| Open a standalone Svix relay tunnel, print a stable inbox URL, and forward each | ||
| delivery to a local handler. | ||
|
|
||
| ```sh | ||
| clerk webhooks listen --forward-to http://localhost:3000/api/webhooks | ||
| ``` | ||
|
|
||
| | Option | Description | | ||
| | -------------------- | ----------------------------------------------------------------------------------------------------------------------- | | ||
| | `--forward-to <url>` | **Required.** Local URL to POST deliveries to. | | ||
| | `--token <c_token>` | Pin the relay token so the inbox URL stays fixed across machines. `c_` + 10 base62 chars (gen with `webhooks token`). | | ||
| | `--header <k:v>` | Extra header for the forwarded request. Repeat the flag to send multiple headers. `svix-*` headers can't be overridden. | | ||
| | `--json` | Emit NDJSON: one `ready` line then one `event` line per delivery (pipe into a file for `webhooks verify --delivery`). | | ||
|
|
||
| **Pin your URL.** Without `--token`, the relay token is generated for you and | ||
| persisted locally — but it isn't a guaranteed-stable handle (it can differ on a | ||
| new machine, a cleared config, or a rare token collision). When you run `listen` | ||
| **without** `--token`, the banner warns you and prints the exact `--token` to pass | ||
| next time. For a fixed, shareable URL, always pin it: | ||
|
|
||
| ```sh | ||
| clerk webhooks listen --token "$(clerk webhooks token)" --forward-to http://localhost:3000/api/webhooks | ||
| ``` | ||
|
|
||
| **No verification.** Without the backend there is no per-endpoint signing secret, | ||
| so deliveries are forwarded as-is. The original `svix-*` headers are preserved on | ||
| the forwarded request, so your handler can still verify against the signing secret | ||
| of the dashboard endpoint you point at the inbox URL. | ||
|
|
||
| **Ready line schema (`--json`):** | ||
| `{ "type": "ready", "relay_url": "https://webhooks.clerk.com/in/c_AbCd123456/", "forward_to": "http://localhost:3000/api/webhooks" }` — emitted once, then one | ||
| `event` line per delivery (and a `{ "type": "reconnecting" }` line if the relay | ||
| connection drops). | ||
|
rafa-thayto marked this conversation as resolved.
|
||
|
|
||
| ## `clerk webhooks verify` | ||
|
|
||
| Verify a Svix webhook signature locally — HMAC-SHA256 over | ||
| `{id}.{timestamp}.{payload}`, constant-time matched against every `v1,<base64>` | ||
| entry in the header (any match wins, covering the 24h rotation grace window). | ||
|
|
||
| ```sh | ||
| # From a saved `listen` event line: | ||
| clerk webhooks verify --secret whsec_... --delivery @event.json | ||
|
|
||
| # From the four explicit header values: | ||
| clerk webhooks verify --secret whsec_... --payload @body.json \ | ||
| --id msg_2xyz --timestamp 1717935000 --signature v1,abc... | ||
| ``` | ||
|
|
||
| `--secret` is always required. `--payload`/`--delivery` take `@file` or `-` for | ||
| stdin (inline values get mangled by shells). Explicit flags override `--delivery` | ||
| fields. | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.