Skip to content

feat(webhooks): clerk webhooks toolkit (V1)#366

Open
rafa-thayto wants to merge 9 commits into
mainfrom
rafa-thayto/webhooks-listen-v1
Open

feat(webhooks): clerk webhooks toolkit (V1)#366
rafa-thayto wants to merge 9 commits into
mainfrom
rafa-thayto/webhooks-listen-v1

Conversation

@rafa-thayto

@rafa-thayto rafa-thayto commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the clerk webhooks command group (V1) — a PLAPI-free local webhooks toolkit. Every command runs with no auth, no linked project, and no Clerk backend, so a developer can test webhook delivery and signature verification before any dashboard setup.

Three subcommands:

  • clerk webhooks listen --forward-to <url> — opens a standalone Svix relay tunnel and forwards each delivery to a local handler. --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 that survives restarts (persisted in config). Flags: --forward-to (required), --token, --headers, --json.
  • clerk webhooks verify — verifies 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 — generates 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)".

Agent mode

listen emits NDJSON (ready / event / reconnecting lines) under --json or in agent mode; verify and token emit machine-readable output too. Usage/validation errors surface as JSON in agent mode (e.g. a malformed --token or missing --forward-to).

Ship the local webhooks toolkit that needs no Clerk backend, carved off main:

- `clerk webhooks listen` — standalone Svix relay tunnel. Dials the relay,
  prints a stable inbox URL (token persisted under relay.__relay_only__ so it
  survives restarts; --token pins an explicit one), and forwards deliveries to
  --forward-to. No auth, no instance context, no signing secret, no HMAC.
- `clerk webhooks verify` — offline HMAC-SHA256 signature verification from a
  saved listen event line or the four explicit header values. No network.

Relay transport (relay-client/relay-protocol/forward/render) and verify are
ported unchanged from the full webhooks branch; listen is rewritten to the
relay-only path with all PLAPI coupling removed. Additive lib edits: relay
config accessors, INVALID_WEBHOOK_SIGNATURE error code, and a named
cliSigintHandler so listen can hand off SIGINT cleanly. Registered via the
registrant pattern with no group auth gate.

Claude-Session: https://claude.ai/code/session_01SYYJBsRxBQjCAuNbQiiLma
…s group

Ports the registrant-pattern rule from the full webhooks branch. The closing
example is updated for this branch: `users` shows inherited group options via
optsWithGlobals, and the auth-free `webhooks` group (listen + verify) shows a
group that omits the preAction gate.

Claude-Session: https://claude.ai/code/session_01SYYJBsRxBQjCAuNbQiiLma
…roup help

`clerk webhooks token` generates a valid relay token (c_ + 10 base62 chars) for
`listen --token`, removing the guesswork of hand-writing the exact format. The
bare token is the default stdout output (even non-interactively, so command
substitution works) with `--json` opt-in; the usage hint is stderr-only and
interactive-only so it never pollutes a pipe:

  clerk webhooks listen --token "$(clerk webhooks token)"

The `webhooks` group now carries a 3-step examples block (token -> listen ->
verify) shown when run solo or with --help.

Claude-Session: https://claude.ai/code/session_01SYYJBsRxBQjCAuNbQiiLma
- `listen --forward-to` is now a required option (forwarding is the point of
  the command); the examples and help reflect this.
- When `listen` runs WITHOUT `--token`, the ready banner now warns that the
  auto-generated relay token isn't a guaranteed-stable handle (it can differ
  across machines, a cleared config, or a rare collision) and prints the exact
  `--token <c_…>` to pass next time to lock the current URL, plus a pointer to
  `clerk webhooks token`. Passing `--token` suppresses the warning.

Claude-Session: https://claude.ai/code/session_01SYYJBsRxBQjCAuNbQiiLma
…banner

Surface https://dashboard.clerk.com/last-active?path=webhooks in the ready
banner so users can jump straight to adding the relay URL as an endpoint.

Claude-Session: https://claude.ai/code/session_01SYYJBsRxBQjCAuNbQiiLma
expandInputJson auto-reads piped stdin as --input-json for any command in a
non-TTY context. That stole stdin from `webhooks verify --payload -` /
`--delivery -`, whose JSON keys were then mis-parsed into flags (e.g. "unknown
option '--type'"). Skip the auto-stdin expansion when argv contains a bare `-`,
so a command that explicitly claims stdin gets it. Verified: verify --payload -
and --delivery - now succeed.

Claude-Session: https://claude.ai/code/session_01SYYJBsRxBQjCAuNbQiiLma
From a multi-agent adversarial sweep (27/28 findings confirmed). 14 fixes:

listen:
- validate --forward-to as our own usage error (JSON in agent mode) + reject
  non-http(s)/invalid URLs at startup before opening the relay
- warn once when --headers carries a svix-* key (always overridden by delivery)
- emit a structured {"type":"reconnecting"} NDJSON line in agent mode
- drop endpoint_id/events_filter from the ready line (always null in V1)
- banner: remove internal "relay-only" jargon and the always-"all" events row

verify:
- --delivery accepts the full `listen --json` stream (skip the ready line)
- reject garbled/non-base64 secrets (Buffer.from silently truncates them)
- guard against --delivery - and --payload - both consuming stdin
- only show the clock-skew hint for structurally valid (32-byte v1) signatures
- hint when a --payload file has a trailing newline (HMAC is byte-exact)
- read @files directly so character devices like /dev/null work

misc: clearer --headers comma error; delete dead renderVerificationWarning.

Rejected on judgment: routing verify --json failures to stdout (would break the
CLI-wide convention that errors are JSON on stderr; commands throw, never exit).
Deferred: global Commander-error JSON formatting (touches all commands).

Claude-Session: https://claude.ai/code/session_01SYYJBsRxBQjCAuNbQiiLma
The header sweep stepped the cursor up by the body's newline count, which
under-counts any line that wraps past the terminal width. The overshoot left a
duplicate "Next steps" stranded on screen. Extract `cursorRowsBelowHeader` to
add the extra rows each wrapped line spans, and use the live terminal width.

Claude-Session: https://claude.ai/code/session_01Nimc3t87jP8rm8DZ33kpJi
…ect spinner

Collapse the listen delivery path now that V1 is relay-only: extract
`assertForwardTo`, drop the dead PLAPI banner branch, and trim `ReadyInfo` to
the fields the relay flow actually emits. Wrap the relay handshake in
`withSpinner` (a no-op in agent/--json mode) and render the `token` Next steps
through the animated `outro`.

Claude-Session: https://claude.ai/code/session_01Nimc3t87jP8rm8DZ33kpJi
@changeset-bot

changeset-bot Bot commented Jun 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2e61d8f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
clerk Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds the clerk webhooks v1 command group with token, listen, and verify, plus relay protocol, forwarding, rendering, relay-client orchestration, config persistence, and root CLI registration. It also adds a removable SIGINT handler, adjusts stdin auto-expansion when a bare - is present, and updates wrapped-row cursor handling for animated headers.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~90+ minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title accurately summarizes the main change: a new V1 clerk webhooks toolkit.
Description check ✅ Passed The description accurately matches the new clerk webhooks V1 command group and its listen/verify/token changes.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (4)
.claude/rules/command-registration.md (1)

66-66: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Aside omits the new token subcommand.

This PR registers three webhooks subcommands (token, listen, verify), but the example here only mentions listen and verify. Since this rules doc is authored alongside the command, consider listing token too so the description stays accurate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/rules/command-registration.md at line 66, The webhooks group
description is missing the new token subcommand, so update the guidance in the
registerWebhooks example to include token alongside listen and verify. Keep the
structure aligned with registerWebhooks by describing all three .command(...)
entries and the inherited optsWithGlobals() options, while leaving preAction
omitted since this group is auth-free.
packages/cli-core/src/commands/webhooks/verify.ts (1)

113-118: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Catch-all collapses all read failures to FILE_NOT_FOUND.

A permission error (EACCES), a directory path, or a decode failure on Bun.file(path).text() would all surface as "File not found", which can mislead users diagnosing the real cause.

♻️ Optionally preserve the underlying cause
     try {
       return await Bun.file(path).text();
-    } catch {
-      throw new CliError(`File not found: ${path}`, { code: ERROR_CODE.FILE_NOT_FOUND });
+    } catch (err) {
+      const reason = err instanceof Error ? `: ${err.message}` : "";
+      throw new CliError(`Could not read ${path}${reason}`, { code: ERROR_CODE.FILE_NOT_FOUND });
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli-core/src/commands/webhooks/verify.ts` around lines 113 - 118,
The read helper in verify.ts is mapping every failure from Bun.file(path).text()
to FILE_NOT_FOUND, so update the catch path to preserve and classify the real
error instead of treating all cases as missing files. In the function that reads
the file, inspect the thrown error (for example permission, directory, or decode
failures) and either rethrow with the original cause attached or translate it to
a more accurate CliError code/message while keeping the underlying error details
available.
packages/cli-core/src/commands/webhooks/relay-client.ts (1)

120-131: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Handle onTokenRotated rejection so the redial still fires.

If onTokenRotated rejects (e.g. setRelayEntry fails to persist), the .then() never runs, so the setTimeout redial is never scheduled and the client silently stalls after a 1008 collision. The non-collision path at Line 134-135 reconnects unconditionally; the rotation path should be equally resilient.

♻️ Schedule the redial regardless of persistence outcome
-        void this.options.onTokenRotated(this.token).then(() => {
-          if (this.stopped) return;
-          setTimeout(() => this.connect(), RELAY_RECONNECT_DELAY_MS);
-        });
+        void this.options.onTokenRotated(this.token).finally(() => {
+          if (this.stopped) return;
+          setTimeout(() => this.connect(), RELAY_RECONNECT_DELAY_MS);
+        });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli-core/src/commands/webhooks/relay-client.ts` around lines 120 -
131, The token-collision branch in relay-client’s reconnect flow only schedules
the redial inside the onTokenRotated().then() success path, so a rejected
persistence callback can stall reconnection. Update the collision handling in
relay-client to use the same resilient redial behavior as the normal drop path:
always schedule connect() after RELAY_RECONNECT_DELAY_MS once the token is
rotated, and ensure any onTokenRotated failure is caught/logged without
preventing the timeout from firing.
packages/cli-core/src/lib/config.ts (1)

74-76: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: raw.relay is cast without validating entry shape.

typeof raw.relay === "object" also accepts arrays, and inner entries aren't checked to be { token: string }. A hand-edited/corrupted config could surface a malformed RelayEntry to listen without a usage error. Given this is local config, it's low risk — consider a light shape guard if you want defense in depth.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli-core/src/lib/config.ts` around lines 74 - 76, The raw relay
config is being accepted too loosely in config.ts, since raw.relay can be an
array and its entries are cast to RelayEntry without checking they match the
expected shape. Tighten the parsing in the config loading logic by validating
raw.relay is a plain object and confirming each relay entry has the expected
token string before assigning to config.relay, so malformed local config cannot
flow into listen unchecked.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In @.claude/rules/command-registration.md:
- Line 66: The webhooks group description is missing the new token subcommand,
so update the guidance in the registerWebhooks example to include token
alongside listen and verify. Keep the structure aligned with registerWebhooks by
describing all three .command(...) entries and the inherited optsWithGlobals()
options, while leaving preAction omitted since this group is auth-free.

In `@packages/cli-core/src/commands/webhooks/relay-client.ts`:
- Around line 120-131: The token-collision branch in relay-client’s reconnect
flow only schedules the redial inside the onTokenRotated().then() success path,
so a rejected persistence callback can stall reconnection. Update the collision
handling in relay-client to use the same resilient redial behavior as the normal
drop path: always schedule connect() after RELAY_RECONNECT_DELAY_MS once the
token is rotated, and ensure any onTokenRotated failure is caught/logged without
preventing the timeout from firing.

In `@packages/cli-core/src/commands/webhooks/verify.ts`:
- Around line 113-118: The read helper in verify.ts is mapping every failure
from Bun.file(path).text() to FILE_NOT_FOUND, so update the catch path to
preserve and classify the real error instead of treating all cases as missing
files. In the function that reads the file, inspect the thrown error (for
example permission, directory, or decode failures) and either rethrow with the
original cause attached or translate it to a more accurate CliError code/message
while keeping the underlying error details available.

In `@packages/cli-core/src/lib/config.ts`:
- Around line 74-76: The raw relay config is being accepted too loosely in
config.ts, since raw.relay can be an array and its entries are cast to
RelayEntry without checking they match the expected shape. Tighten the parsing
in the config loading logic by validating raw.relay is a plain object and
confirming each relay entry has the expected token string before assigning to
config.relay, so malformed local config cannot flow into listen unchecked.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c28a1b71-572a-45b7-a233-fcaed0bf2e9c

📥 Commits

Reviewing files that changed from the base of the PR and between 8f54a92 and 2e61d8f.

📒 Files selected for processing (29)
  • .changeset/webhooks-listen-v1.md
  • .claude/rules/command-registration.md
  • .oxlintrc.json
  • packages/cli-core/src/cli-program.ts
  • packages/cli-core/src/cli.ts
  • packages/cli-core/src/commands/webhooks/README.md
  • packages/cli-core/src/commands/webhooks/forward.test.ts
  • packages/cli-core/src/commands/webhooks/forward.ts
  • packages/cli-core/src/commands/webhooks/index.ts
  • packages/cli-core/src/commands/webhooks/listen.test.ts
  • packages/cli-core/src/commands/webhooks/listen.ts
  • packages/cli-core/src/commands/webhooks/relay-client.test.ts
  • packages/cli-core/src/commands/webhooks/relay-client.ts
  • packages/cli-core/src/commands/webhooks/relay-protocol.test.ts
  • packages/cli-core/src/commands/webhooks/relay-protocol.ts
  • packages/cli-core/src/commands/webhooks/render.test.ts
  • packages/cli-core/src/commands/webhooks/render.ts
  • packages/cli-core/src/commands/webhooks/shared.ts
  • packages/cli-core/src/commands/webhooks/token.test.ts
  • packages/cli-core/src/commands/webhooks/token.ts
  • packages/cli-core/src/commands/webhooks/verify.test.ts
  • packages/cli-core/src/commands/webhooks/verify.ts
  • packages/cli-core/src/lib/config.ts
  • packages/cli-core/src/lib/errors.ts
  • packages/cli-core/src/lib/gradient.test.ts
  • packages/cli-core/src/lib/gradient.ts
  • packages/cli-core/src/lib/input-json.test.ts
  • packages/cli-core/src/lib/input-json.ts
  • packages/cli-core/src/lib/signals.ts

@rafa-thayto

Copy link
Copy Markdown
Contributor Author

!snapshot

@github-actions

Copy link
Copy Markdown
Contributor

Snapshot failed

The snapshot publish workflow failed. View the workflow run for details.

1 similar comment
@github-actions

Copy link
Copy Markdown
Contributor

Snapshot failed

The snapshot publish workflow failed. View the workflow run for details.

@rafa-thayto rafa-thayto changed the title feat(webhooks): PLAPI-free clerk webhooks toolkit (V1) feat(webhooks): clerk webhooks toolkit (V1) Jun 26, 2026
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.

1 participant