Skip to content

feat(webhooks): add webhook settings CRUD, envelope helpers, and recovery iterator#38

Merged
boorad merged 9 commits into
mainfrom
feat/webhooks
May 14, 2026
Merged

feat(webhooks): add webhook settings CRUD, envelope helpers, and recovery iterator#38
boorad merged 9 commits into
mainfrom
feat/webhooks

Conversation

@boorad

@boorad boorad commented May 14, 2026

Copy link
Copy Markdown
Owner

Summary

Adds first-class webhook support to @spicygolf/ghin so callers don't have to re-implement login + raw fetch against /user/webhook_settings. Covers settings CRUD, delivery listing/replay, and verification helpers for inbound webhooks.

Changes

Models (src/client/ghin/models/webhooks/)

  • settings.tsWebhookSettings, WebhookSettingsPatch (URL-validated), EnsureRegisteredRequest
  • envelope.tsWebhookEnvelope<T>, WebhookEnvelopeObjectType (the 6 settings event types plus 'crs' for Course Rating System deliveries)
  • listing.tsWebhooksListRequest, WebhooksListResponse, WebhookResendRequest, IterateUndeliveredRequest

Inbound helpers (src/webhooks/)

  • parseWebhookEnvelope(payload) — Zod-validates the documented envelope shape, returns a typed Result
  • signWebhookPayload(rawBody, secret) / verifyWebhookSignature(rawBody, header, secret) — HMAC-SHA256, constant-time compare, accepts string | Buffer | Uint8Array

Client namespace (client.webhooks.*)

  • get() / patch(settings) / delete() — settings CRUD
  • test(eventType) — fire a synthetic delivery
  • list(request?) / resend({ webhook_id, is_crs_webhook? }) — listing + replay
  • ensureRegistered({ event, url, dataType?, enabled? }) — idempotent register (GET-then-PATCH-only-if-different), normalizes trailing slashes
  • iterateUndelivered({ object_type?, from_date?, ... }) — async generator that pages status=not sent, capped at 10k pages

Plumbing

  • RequestClient now emits %20 instead of + for spaces in query strings — JAX-RS / java.net.URI#getQuery style parsers don't decode + to space, and GHIN docs canonicalize as %20
  • Slash-command routing note in CLAUDE.md (unrelated drive-by)

Assumptions to confirm with USGA

  • Signature scheme defaults to header X-GHIN-Signature, value sha256=<hex>. Constants exported so a confirmed-different scheme is a one-line change.
  • Inner payload.object shape per object_type is not in Swagger. WebhookEnvelope<T> stays generic until real test-event bodies are captured.

Testing

  • bun run test:run — 280 tests pass (envelope parser for each object_type, signature verifier matching/mismatched/length/missing, settings CRUD mocks, pagination exhaustion, ensureRegistered no-op/url-differs/enabled-differs/missing-leaf paths)
  • ./scripts/code-quality.sh — biome check, lint, build all green
  • Playground smoke (bun run dev against src/playground/webhook-flow.ts) — needs GHIN_WEBHOOK_URL env var pointing at a public URL (e.g. webhook.site)

Summary by CodeRabbit

  • New Features

    • Added comprehensive webhook support including settings management, event testing, delivery listing with pagination, and resend capability.
    • Added webhook envelope parsing and HMAC-SHA256 signature verification utilities.
    • Added idempotent webhook registration and undelivered message iteration helpers.
  • Bug Fixes

    • Fixed query parameter encoding to prevent + character handling issues.
  • Documentation

    • Added slash command configuration guide.
    • Added webhook integration documentation with implementation roadmap.

Review Change Stack

boorad added 5 commits May 13, 2026 21:10
Adds Zod schemas and TypeScript types for the GHIN webhook settings
document (flat per-event-type maps for url, data_type, enabled), the
delivery envelope, and the listing/recovery endpoints.

Ships two standalone helpers usable without the client:
- parseWebhookEnvelope<T>: validates an inbound delivery against the
  documented envelope shape and returns a typed Result.
- verifyWebhookSignature / signWebhookPayload: HMAC-SHA256 with the
  sha256=<hex> prefix and timing-safe comparison. Header and algorithm
  are centralized so the scheme can be swapped from one place once
  USGA confirms the canonical format.
…ting

Adds six methods on GhinClient routed through RequestClient so they
inherit apiAccess login, automatic 401/403 re-login, the GHINcom
source header, and retry/backoff:

- webhooks.get / patch / delete — settings CRUD on the current user
- webhooks.test(type) — fires a synthetic event to the registered URL
- webhooks.list(req) — paged delivery history with status/object_type
  filters; the basis for missed-delivery polling
- webhooks.resend({ webhook_id }) — replays a single delivery; defaults
  is_crs_webhook=false

Adds playground/webhook-flow.ts driving the full flow end-to-end
against the sandbox.
Phase 3 of the webhook plan — two higher-level helpers on top of the
settings CRUD and listing endpoints.

- webhooks.ensureRegistered({ event, url, dataType?, enabled? })
  GETs current settings and only PATCHes when the leaf for the given
  event differs. Trailing-slash differences are treated as a match so
  callers don't fire a spurious PATCH on every boot. Returns
  { changed, reason?, settings }.

- webhooks.iterateUndelivered({ object_type?, from_date?, to_date?,
  per_page? }) → async generator yielding WebhookEnvelope. Pages
  through status=not sent until a partial page signals exhaustion;
  ideal foundation for missed-delivery recovery loops.

Also moves the plan from plans/todo/ to plans/done/.
- envelope: add 'crs' to object_type discriminator so CRS deliveries
  don't fail listing/iterator validation; settings keys stay at 6
- request-client: force %20 over '+' in query strings (JAX-RS and
  java.net.URI#getQuery don't decode '+' to space)
- signature: accept Buffer/Uint8Array for raw bodies, not just string
- settings: PATCH webhook_url validates as URL or empty string
- ensureRegistered: print '(not set)' instead of literal 'undefined'
- iterateUndelivered: max-pages safety cap (10k pages) to bound runaway
  recovery workers from a misconfigured filter
@boorad boorad self-assigned this May 14, 2026
@coderabbitai

coderabbitai Bot commented May 14, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@boorad has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 45 minutes and 34 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d947473c-e4e2-453d-9de9-bbcdf793219c

📥 Commits

Reviewing files that changed from the base of the PR and between ed3f308 and 5987094.

📒 Files selected for processing (9)
  • .changeset/webhooks.md
  • .claude/commands/pr.md
  • src/client/ghin/index.test.ts
  • src/client/ghin/models/webhooks/listing.ts
  • src/client/ghin/models/webhooks/settings.ts
  • src/playground/webhook-flow.ts
  • src/webhooks/parse-envelope.ts
  • src/webhooks/signature.test.ts
  • vitest.config.ts
📝 Walkthrough

Walkthrough

This PR implements a complete webhook integration for the GHIN library, adding webhook settings CRUD operations, delivery history/replay, test endpoints, and low-level signing/verification utilities. The implementation spans data models, signature handling, client API surface, and supporting infrastructure.

Changes

Webhook Integration for GHIN Library

Layer / File(s) Summary
Webhook data models and schemas
src/client/ghin/models/webhooks/settings.ts, src/client/ghin/models/webhooks/envelope.ts, src/client/ghin/models/webhooks/listing.ts, src/client/ghin/models/webhooks/index.ts, src/client/ghin/models/index.ts
Event type and data type enums, per-event webhook settings (URL, data type, enabled state), envelope schema with status/object type/payload, list/resend/ensure-registered request/response schemas, and barrel re-exports expose all webhook types.
Webhook signature and envelope parsing
src/webhooks/signature.ts, src/webhooks/signature.test.ts, src/webhooks/parse-envelope.ts, src/webhooks/parse-envelope.test.ts, src/webhooks/index.ts
HMAC-SHA256 signing/verification with normalized header handling and timing-safe comparison, plus envelope parser validating inbound webhook bodies. Tests cover signature correctness, header normalization, and rejection of invalid payloads.
GhinClient webhooks API surface
src/client/ghin/index.ts, src/client/ghin/index.test.ts
Eight public webhooks methods: get/patch/delete/test for settings management, list with optional filtering, resend for replay, idempotent ensureRegistered (GET-compare-PATCH with URL normalization), and async iterateUndelivered for paginated undelivered deliveries. Includes 461 lines of comprehensive test coverage for success cases, validation, error propagation, and state comparison logic.
Supporting infrastructure
src/client/request-client/index.ts, plans/done/webhooks.md, src/playground/webhook-flow.ts, src/index.ts, CLAUDE.md
Query parameter normalization (convert + to %20), webhook integration plan documenting API contract and phased work, executable playground script demonstrating the webhook workflow, package-level re-exports, and slash commands documentation.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

The PR introduces a substantial new API surface with 8 public methods, comprehensive validation schemas across 4 new model files, low-level signing/verification utilities, and 461 lines of test coverage. While individual pieces follow clear patterns (Zod validation, async/pagination, error handling), the breadth of the feature—spanning models, utilities, client integration, and tests—and the interplay between idempotent registration logic and async iteration require careful review of contract design, state comparison semantics, and pagination boundaries.

🐰 Webhooks now flow through GHIN,
With signatures tight and secrets within,
Settings persist, events are sent,
And replay comes when needed spent!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: webhook settings CRUD operations, envelope parsing helpers, and an iterator for recovery of undelivered webhooks.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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 and usage tips.

@codecov

codecov Bot commented May 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.44134% with 2 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@1e43f60). Learn more about missing BASE report.

Files with missing lines Patch % Lines
src/client/ghin/index.ts 99.06% 2 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main      #38   +/-   ##
=======================================
  Coverage        ?   95.73%           
=======================================
  Files           ?       53           
  Lines           ?     2484           
  Branches        ?      421           
=======================================
  Hits            ?     2378           
  Misses          ?      106           
  Partials        ?        0           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@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.

Actionable comments posted: 4

🧹 Nitpick comments (1)
src/webhooks/signature.test.ts (1)

61-73: ⚡ Quick win

Add binary-body coverage for the string | Buffer | Uint8Array contract.

Current tests exercise string inputs only. Add Buffer/Uint8Array verification cases to lock in the expanded signature API behavior.

Suggested tests
 describe('signWebhookPayload', () => {
@@
   it('changes when secret changes', () => {
     expect(signWebhookPayload(BODY, SECRET)).not.toBe(signWebhookPayload(BODY, 'other-secret'))
   })
+
+  it('verifies Buffer bodies', () => {
+    const body = Buffer.from(BODY)
+    const sig = signWebhookPayload(body, SECRET)
+    expect(verifyWebhookSignature(body, sig, SECRET)).toEqual({ ok: true })
+  })
+
+  it('verifies Uint8Array bodies', () => {
+    const body = new Uint8Array(Buffer.from(BODY))
+    const sig = signWebhookPayload(body, SECRET)
+    expect(verifyWebhookSignature(body, sig, SECRET)).toEqual({ ok: true })
+  })
 })
🤖 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 `@src/webhooks/signature.test.ts` around lines 61 - 73, Add tests in the
signWebhookPayload suite to cover Buffer and Uint8Array inputs in addition to
strings: for both a Buffer created from BODY and a Uint8Array derived from BODY,
assert determinism (signWebhookPayload(BUF, SECRET) === signWebhookPayload(BUF,
SECRET)), that changing the body bytes produces a different signature (e.g.,
altered Buffer/Uint8Array vs original), and that changing SECRET still changes
the signature; use the existing BODY and SECRET constants and the
signWebhookPayload function name to locate where to add these cases.
🤖 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.

Inline comments:
In `@src/client/ghin/models/webhooks/listing.ts`:
- Line 12: The per_page field in the Zod schema inside
src/client/ghin/models/webhooks/listing.ts currently allows any positive
integer; update the schema for the per_page property to enforce the same upper
bound used elsewhere by adding a maximum of 100 (e.g., change the validator for
per_page to use .max(100) so it becomes an int, positive, default 25, and capped
at 100) to prevent oversized list requests from slipping through.

In `@src/client/ghin/models/webhooks/settings.ts`:
- Around line 38-42: The current .refine predicate only checks for presence of
top-level keys so inputs like { webhook_url: {} } pass; change the predicate
used in the .refine call to treat empty objects/empty strings as "not provided":
for value.webhook_url and value.webhook_data_type, if they are objects ensure
Object.keys(...) .length > 0 (and if strings ensure .trim().length > 0), and for
value.webhook_enabled keep the existing undefined check; update the predicate to
return true only if at least one of these is actually non-empty (non-empty
object/string or defined webhook_enabled) so no-op PATCH payloads with empty
event maps are rejected.

In `@src/playground/webhook-flow.ts`:
- Around line 16-23: The GhinClient is being instantiated outside the guarded
flow so constructor/config errors can escape the try/catch; move the new
GhinClient({...}) call into the try block inside fn and perform upfront
validation of required env vars (e.g., GHIN_PASSWORD, GHIN_USERNAME,
GHIN_API_ACCESS, GHIN_API_VERSION, GHIN_BASE_URL) before constructing; update
any other identical instantiation in the same file (the block around lines
31-60) to follow the same pattern so constructor errors are caught and logged by
the existing error handler.

In `@src/webhooks/parse-envelope.ts`:
- Line 20: The ValidationError currently includes the full inbound webhook
payload (seen where err(new ValidationError(`Invalid webhook envelope:
${parsed.error.message}`, undefined, undefined, payload))) which risks leaking
sensitive data; change this to stop passing the raw payload into the
ValidationError (e.g., remove the fourth argument or replace it with a minimal
non-sensitive marker), leaving the message using parsed.error.message and keep
the call site in parse-envelope.ts (the err(...) and ValidationError
construction) but do not attach the variable payload to the error object.

---

Nitpick comments:
In `@src/webhooks/signature.test.ts`:
- Around line 61-73: Add tests in the signWebhookPayload suite to cover Buffer
and Uint8Array inputs in addition to strings: for both a Buffer created from
BODY and a Uint8Array derived from BODY, assert determinism
(signWebhookPayload(BUF, SECRET) === signWebhookPayload(BUF, SECRET)), that
changing the body bytes produces a different signature (e.g., altered
Buffer/Uint8Array vs original), and that changing SECRET still changes the
signature; use the existing BODY and SECRET constants and the signWebhookPayload
function name to locate where to add these cases.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2d771d70-b9c6-4038-bb2d-c9a2c24fc8d6

📥 Commits

Reviewing files that changed from the base of the PR and between 1e43f60 and ed3f308.

📒 Files selected for processing (17)
  • CLAUDE.md
  • plans/done/webhooks.md
  • src/client/ghin/index.test.ts
  • src/client/ghin/index.ts
  • src/client/ghin/models/index.ts
  • src/client/ghin/models/webhooks/envelope.ts
  • src/client/ghin/models/webhooks/index.ts
  • src/client/ghin/models/webhooks/listing.ts
  • src/client/ghin/models/webhooks/settings.ts
  • src/client/request-client/index.ts
  • src/index.ts
  • src/playground/webhook-flow.ts
  • src/webhooks/index.ts
  • src/webhooks/parse-envelope.test.ts
  • src/webhooks/parse-envelope.ts
  • src/webhooks/signature.test.ts
  • src/webhooks/signature.ts

Comment thread src/client/ghin/models/webhooks/listing.ts Outdated
Comment thread src/client/ghin/models/webhooks/settings.ts
Comment thread src/playground/webhook-flow.ts Outdated
Comment thread src/webhooks/parse-envelope.ts Outdated
@boorad

boorad commented May 14, 2026

Copy link
Copy Markdown
Owner Author

Addressed the outside-diff nitpick: added signature.test.ts cases verifying Buffer and Uint8Array inputs produce signatures matching the string form and round-trip through verifyWebhookSignature.

- listing: cap per_page at 100 to match iterateUndelivered schema
- settings: PATCH refine rejects empty event maps so {webhook_url: {}}
  no longer slips through as a silent no-op
- parse-envelope: stop attaching raw webhook payload to ValidationError
  (envelopes can carry PII — golfer IDs, scores, names)
- playground/webhook-flow: move GhinClient construction inside try/catch
- signature tests: add Buffer/Uint8Array coverage for the widened API
@boorad

boorad commented May 14, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai approve

@coderabbitai

coderabbitai Bot commented May 14, 2026

Copy link
Copy Markdown
✅ Actions performed

Comments resolved and changes approved.

@boorad boorad merged commit 2ab3a9f into main May 14, 2026
7 checks passed
@boorad boorad deleted the feat/webhooks branch May 14, 2026 02:36
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