feat(webhooks): Wave native webhook receiver + per-business secret storage#113
Conversation
…orage
Adds Wave's native webhook flow to chittyfinance, scoped per
(tenant, business) pair to support tenants with multiple connected Wave
businesses. Wave webhook subscriptions are configured in the Wave
dashboard (no programmatic API exists per Wave's GraphQL schema), so
operators paste the per-business URL into the Wave Webhooks page and
seed the corresponding secret via the admin endpoint.
New endpoints (all under /api/webhooks/wave/:tenantId/:businessId):
- POST — receiver (HMAC verified, dedup'd, audit-logged)
- PUT /secret — admin secret-storage (service token auth)
- GET /secret/exists — admin existence check (never returns secret)
- DELETE /secret — admin secret removal
Verification matches Wave's documented signature scheme:
- Header: x-wave-signature: t=<unix_ts>,v1=<hex_hmac_sha256>
- Signed payload: <timestamp>.<raw_body> (raw bytes, not re-serialized)
- 5-minute replay window enforced
- Constant-time hex compare
Receiver behaviour:
- Skips signature verification when no secret stored (allows initial
Wave dashboard ping during setup)
- Validates payload business_id matches URL parameter (defense in depth)
- KV idempotency keyed on event_id, 7-day TTL
- Audit-logs each recognized event via ledgerLog (ChittyLedger)
- Treats empty body or unrecognized JSON shape as a setup ping (200 ack)
Currently supported event types per Wave's Webhooks Setup Guide:
invoice.overdue, invoice.viewed, invoice.approved
("More supported events will be available soon" — receiver is generic
and audit-logs all conformant events; specific business logic for
future events is added as Wave expands the surface.)
KV layout:
webhook:wave:secret:<tenantId>:<businessId> → HMAC secret
webhook:wave:dedup:<eventId> → 7d TTL idempotency marker
Tests: +28 (16 secret-storage + 12 receiver) — full suite 281 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
chittyfinance | dd153c0 | May 01 2026, 01:17 PM |
|
@coderabbitai review Please evaluate:
|
|
Warning Rate limit exceeded
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 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. 📝 WalkthroughWalkthroughAdds a Wave native webhook receiver and secret-management endpoints with per-(tenant,business) KV secrets, HMAC-SHA256 signature verification with replay protection, KV-backed event deduplication (7-day TTL), Zod envelope validation, audit logging, and two new comprehensive Vitest suites covering receiver behavior and secret CRUD/auth flows. ChangesWave Webhook Integration
Sequence Diagram(s)sequenceDiagram
actor Client
participant Server as Server (Webhook Handler)
participant KV as KV Storage
participant Audit as Audit Log
Client->>Server: POST /api/webhooks/wave/:tenantId/:businessId<br/>with x-wave-signature and raw body
Server->>Server: Read raw body once
Server->>KV: Get secret for (tenantId,businessId)
alt Secret exists
Server->>Server: Parse x-wave-signature (t=...,v1=...)
Server->>Server: Validate timestamp (replay window)
Server->>Server: Compute HMAC-SHA256(t + "." + rawBody)
Server->>Server: Constant-time compare signature
alt Signature invalid or outside window
Server-->>Client: 401 Unauthorized
else Signature valid
Server->>Server: Parse JSON (or treat as ping if empty/non-JSON)
alt Empty / setup ping
Server-->>Client: 200 OK { received: true }
else Valid envelope
Server->>Server: Validate business_id vs URL
alt business_id mismatch
Server-->>Client: 400 Bad Request
else business_id match
Server->>KV: Check dedup key (event_id)
alt Duplicate
Server-->>Client: 202 Accepted { duplicate: true }
else New
Server->>KV: Store dedup key (7-day TTL)
Server->>Audit: Log accepted event
Server-->>Client: 202 Accepted { received:true, eventId, eventType }
end
end
end
end
else No secret configured
Server-->>Client: 401 { error: "webhook_not_configured" } or treat unsigned pings per logic
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
Code Review — PR #113: Wave Native Webhook ReceiverOverall this is a well-structured implementation. The signature verification, KV dedup pattern, and test coverage are all solid. A few items worth addressing before merge, with one security concern that I'd flag as blocking. 🔴 Security — Unauthenticated event injection during setup window
When no secret is stored, signature verification is skipped entirely and the event is processed through the full handler (business_id check, dedup write, The intent — allowing Wave's initial test ping — is reasonable, but the current approach is too permissive. A targeted fix: // Option A: always ack setup-ping 200 when no secret; never reach ledgerLog
if (!secret) {
console.warn('[webhook:wave] No secret stored for', { tenantId, businessId }, '— treating as setup ping');
return c.json({ received: true }, 200);
}
// signature verification from here on is unconditionalThis still ACKs Wave's test ping (200 OK) but prevents audit-log pollution from unauthenticated events. The tradeoff is that the initial test webhook from Wave won't produce a ledger entry, which is fine since it's a synthetic ping rather than a real business event. 🟡 Stale comment in secret-storage blockNear the top of the new secret-storage section (around line 572): The receiver is implemented in this PR, so this comment is outdated. Delete or update it before merge — it will mislead future readers looking for where the receiver lives. 🟡 Dedup key not scoped to tenant/businessconst dedupKey = `webhook:wave:dedup:${event.event_id}`;The dedup key is global across all tenants and businesses. If Wave ever reuses const dedupKey = `webhook:wave:dedup:${tenantId}:${businessId}:${event.event_id}`;The TTL storage cost is minimal and this is unambiguously correct. 🟡
|
There was a problem hiding this comment.
Pull request overview
Adds Wave’s native webhook receiver and per-(tenant, business) KV secret management to the existing server/routes/webhooks.ts webhook surface, with Vitest coverage for admin secret endpoints and receiver behavior.
Changes:
- Added Wave webhook secret CRUD endpoints (PUT/GET exists/DELETE) guarded by service-token auth.
- Added Wave native receiver endpoint with HMAC-SHA256 signature verification, replay-window enforcement, payload validation, KV dedup, and ledger audit logging.
- Added new Vitest suites covering secret storage and receiver behaviors (signature/replay/dedup/business binding/setup ping).
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| server/routes/webhooks.ts | Adds Wave per-business secret storage + native webhook receiver with signature verification and KV dedup/audit logging. |
| server/tests/webhooks-wave-secret.test.ts | Tests Wave secret storage endpoints (auth, validation, KV isolation, idempotency). |
| server/tests/webhooks-wave-receiver.test.ts | Tests Wave receiver signature verification, replay window, payload handling, and dedup behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // subscription is created and must be supplied to the verification step | ||
| // of the receiver (added in a follow-up PR once Wave's signature schema | ||
| // is verified). |
There was a problem hiding this comment.
The block comment says the Wave receiver is “added in a follow-up PR”, but the receiver is implemented later in this same file. Please update/remove this note so the documentation matches the current implementation (and doesn’t mislead operators about where signature verification lives).
| // subscription is created and must be supplied to the verification step | |
| // of the receiver (added in a follow-up PR once Wave's signature schema | |
| // is verified). | |
| // subscription is created and are supplied to the receiver's signature | |
| // verification step implemented later in this file. |
| function isAuthorizedWaveSecretCaller(env: { CHITTY_AUTH_SERVICE_TOKEN?: string }, authHeader: string): boolean { | ||
| const expected = env.CHITTY_AUTH_SERVICE_TOKEN; | ||
| if (!expected) return false; | ||
| const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; | ||
| return token.length > 0 && token === expected; | ||
| } |
There was a problem hiding this comment.
isAuthorizedWaveSecretCaller does a direct string equality check against CHITTY_AUTH_SERVICE_TOKEN. Elsewhere in the codebase, service-token auth uses constant-time comparison via tokenEqual (see server/middleware/auth.ts). Consider reusing serviceAuth middleware for these endpoints or using tokenEqual here to avoid timing leaks and keep auth behavior consistent.
| const [key, ...rest] = part.split('='); | ||
| const value = rest.join('='); | ||
| if (key === 't') timestamp = value; | ||
| if (key === 'v1') signature = value; | ||
| } | ||
|
|
||
| if (!timestamp || !signature) return false; |
There was a problem hiding this comment.
Signature header parsing here doesn’t trim whitespace. If Wave (or an intermediary) formats the header like t=..., v1=... (note the space after the comma), key becomes ' v1' and verification will always fail. Trim each part/key/value before comparison (and consider normalizing hex casing) to make verification robust.
| const [key, ...rest] = part.split('='); | |
| const value = rest.join('='); | |
| if (key === 't') timestamp = value; | |
| if (key === 'v1') signature = value; | |
| } | |
| if (!timestamp || !signature) return false; | |
| const trimmedPart = part.trim(); | |
| const [rawKey, ...rest] = trimmedPart.split('='); | |
| const key = rawKey.trim(); | |
| const value = rest.join('=').trim(); | |
| if (key === 't') timestamp = value; | |
| if (key === 'v1') signature = value; | |
| } | |
| if (!timestamp || !signature) return false; | |
| signature = signature.toLowerCase(); |
| if (secret) { | ||
| if (!signatureHeader || !(await verifyWaveSignature(rawBody, signatureHeader, secret))) { | ||
| return c.json({ error: 'invalid_signature' }, 401); | ||
| } | ||
| } else { | ||
| console.warn('[webhook:wave] No secret stored for', { tenantId, businessId }, '— signature verification skipped'); | ||
| } | ||
|
|
There was a problem hiding this comment.
When no secret is stored, the handler skips signature verification but continues on to parse schema-conformant JSON, write a 7-day dedup marker, and emit an audit ledgerLog. This makes the unauthenticated public endpoint writable during setup (anyone can spam audit logs / burn KV keys) until the secret is configured. Consider short-circuiting to a “setup ping” ack (no side effects) unless a secret exists, or only allowing side effects when a valid signature has been verified.
| if (secret) { | |
| if (!signatureHeader || !(await verifyWaveSignature(rawBody, signatureHeader, secret))) { | |
| return c.json({ error: 'invalid_signature' }, 401); | |
| } | |
| } else { | |
| console.warn('[webhook:wave] No secret stored for', { tenantId, businessId }, '— signature verification skipped'); | |
| } | |
| if (!secret) { | |
| console.warn('[webhook:wave] No secret stored for', { tenantId, businessId }, '— acking setup ping without side effects'); | |
| return c.json({ received: true, setup: true }, 200); | |
| } | |
| if (!signatureHeader || !(await verifyWaveSignature(rawBody, signatureHeader, secret))) { | |
| return c.json({ error: 'invalid_signature' }, 401); | |
| } |
Empirical verification of Wave's webhook surfaceQueried the live Wave GraphQL endpoint ( curl -sS -X POST "https://gql.waveapps.com/graphql/public" \
-H "Content-Type: application/json" \
-d '{"query":"{ __schema { mutationType { fields { name } } } }"}'Result confirmed:
So the dashboard-only design in this PR is the only viable approach — there's no GraphQL surface to add |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@server/routes/webhooks.ts`:
- Around line 779-790: The audit entry currently passes raw event.data to
ledgerLog which may leak sensitive information; instead implement and call a
sanitizer (e.g., sanitizeWebhookData) and/or extract a whitelist of safe
identifiers (invoiceId, customerId, amount, status) and a short summary, mask
any account numbers/API keys, then pass the sanitized object to ledgerLog
(replace event.data with the return of sanitizeWebhookData(event.data) and
include event.event_type, event.event_id, tenantId, businessId as before).
- Around line 739-745: The code currently skips signature verification when the
stored Wave secret is missing, allowing any schema-valid payload to proceed to
deduplication and ledgerLog; change this so that when secret is falsy you only
accept explicit setup/health-check payloads (e.g., an empty body or a known safe
"ping"/"setup" marker) and otherwise return 401. Concretely: in the webhook
handler around verifyWaveSignature/signatureHeader/secret, replace the
unconditional else that logs and proceeds with a guard that inspects
rawBody/parsed body and only allows the specific safe setup payload shape
(explicitly check for empty body or a recognized ping/setup event) to bypass
verification; for all other payloads return c.json({ error: 'invalid_signature'
}, 401) and log the rejection. Apply the same change to the other webhook
handling block that spans the later section (the 755-804 area) so dedup +
ledgerLog are never reached without a secret unless the payload is the allowed
setup ping.
🪄 Autofix (Beta)
✅ Autofix completed
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f721c93b-f404-4335-9313-f2d648220157
📒 Files selected for processing (3)
server/__tests__/webhooks-wave-receiver.test.tsserver/__tests__/webhooks-wave-secret.test.tsserver/routes/webhooks.ts
…usiness Addresses review feedback on PR #113: - **Forgery guard**: when no per-(tenant,business) secret is stored in KV, schema-valid Wave events are now rejected with 401 instead of being ack'd and ledger-logged. The setup-ping bypass is preserved only for non-event probe shapes (empty body, non-JSON, unrecognized JSON). - **Dedup key scoping**: `webhook:wave:dedup:<event_id>` -> `webhook:wave:dedup:<tenantId>:<businessId>:<event_id>`. Wave's event_id is unique per business, not globally; without scoping, the same id across two businesses would silently drop the second event. - **Replay window asymmetry**: tightened to -300s/+60s (was symmetric +-300s). Future-dated timestamps don't occur from Wave's signers and indicate clock drift or tampering. - **Audit-log schema drift**: signed-but-unparseable bodies and signed-but-unrecognized envelopes now ledger-log `webhook.wave.parse_error` / `webhook.wave.unrecognized` so drift surfaces in the audit trail instead of stderr. - **Tests**: drop `vi.mock('../lib/ledger-client', ...)` per project rule (no service-module mocks). Stub global fetch instead so the real ledger-client code path is exercised without outbound HTTP. Added cases for same-length wrong sig (constant-time path), future timestamp, non-numeric timestamp, non-JSON probe body, cross-business dedup isolation, and updated existing tests to use signed requests now that unsigned events are rejected. Tests: 286/286 passing (+5 new); typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Pushed Critical (fixed):
Important (fixed):
Test coverage added (+5): same-length wrong sig (constant-time path), future timestamp, non-numeric timestamp, non-JSON probe body, cross-business dedup isolation. Updated viewed/dedup tests to use signed requests since unsigned events are now rejected. 286/286 passing; typecheck clean. Auto-merge intentionally left disabled until CI greens. |
|
@coderabbitai review Please evaluate:
|
🧩 Analysis chain🏁 Script executed: Length of output: 29364 🌐 Web query: 💡 Result: Wave webhook HMAC-SHA256 verification (and security best practices)1) Verify the signature exactly as Wave specifies
2) Add replay-attack protection (required)
3) Compare signatures safely
4) Make processing idempotent (prevents duplicates from retries/replays)
5) Operational hardening
Sources: Wave Webhooks Setup Guide [1], Wave webhook docs/checklist [2], Webhooks.fyi replay prevention guidance [3], provider-agnostic HMAC/timing-safe/HTTPS guidance [4].
Security Review — Wave Native Webhook Receiver✅ Signature Verification (Well-implemented)The
|
| Issue | Location |
|---|---|
Mercury dedup key not tenant-scoped (webhook:mercury:${event.id}) — cross-tenant collision possible |
webhooks.ts Mercury native handler |
| Stripe dedup stores full raw body in KV — may include sensitive payment data | webhooks.ts Stripe handler |
verifyMercurySignature has no replay-window check |
verifyMercurySignature function |
Would you like me to open separate follow-up issues for any of these?
✅ Actions performed
Review triggered.
Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.
Code Review — PR #113: Wave native webhook receiver + per-business secret storageOverall this is well-designed and well-tested. The security model is sound, the dedup isolation is correct, and the 28-test suite covers the important edge cases. A few things to fix before merge: Bugs / CorrectnessStale comment in the KV layout block ( The receiver with full signature verification is added in this very PR — the "follow-up PR" clause is dead text. Remove it or it will mislead future readers. Inaccurate KV layout comment (same block): The actual implementation scopes the key to Inconsistent error code casing (~line 731): return c.json({ error: 'secret required' }, 400);Every other error code in this file uses underscores ( SecurityNon-constant-time service token comparison ( return token.length > 0 && token === expected;
if (token.length !== expected.length) return false;
let result = 0;
for (let i = 0; i < token.length; i++) {
result |= token.charCodeAt(i) ^ expected.charCodeAt(i);
}
return result === 0;No URL parameter validation on const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const BIZ_RE = /^[a-z0-9-]{1,64}$/i;
if (!UUID_RE.test(tenantId) || !BIZ_RE.test(businessId)) {
return c.json({ error: 'invalid_params' }, 400);
}No minimum secret length on PUT: Logic / Edge Cases
PR description KV layout differs from implementation: Positive observations
Deployment failureCI shows a Cloudflare Workers build failure ( Summary: Fix the three bugs (stale comment, KV layout comment, error code casing), add UUID/businessId param validation, and address the token comparison timing issue. Everything else is solid. |
|
Note Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it. Fixes Applied SuccessfullyFixed 1 file(s) based on 1 unresolved review comment. Files modified:
Commit: The changes have been pushed to the Time taken: |
Fixed 1 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
|
@coderabbitai review Please evaluate:
|
|
Tip For best results, initiate chat on the files or code changes.
Security Review — Wave Native Webhook Receiver✅ Signature Verification (Well-implemented)The
|
| Issue | Location |
|---|---|
Mercury dedup key not tenant-scoped (webhook:mercury:${event.id}) — cross-tenant collision possible |
webhooks.ts Mercury native handler |
| Stripe dedup stores full raw body in KV — may include sensitive payment data | webhooks.ts Stripe handler |
verifyMercurySignature has no replay-window check |
verifyMercurySignature function |
Would you like me to open separate follow-up issues for any of these?
Summary
Adds Wave's native webhook flow to chittyfinance, scoped per (tenant, business) pair. Implements the receiver, full HMAC-SHA256 signature verification per Wave's Webhooks Setup Guide, and admin endpoints for per-business secret storage.
Wave's webhook subscriptions are configured only via the Wave dashboard (no programmatic API exists in Wave's GraphQL schema), so operators paste the per-business URL into the Wave Webhooks page and seed the corresponding secret via the admin endpoint added here.
New endpoints (under
/api/webhooks/wave/:tenantId/:businessId)//secret/secret/exists/secretSignature verification (per Wave's docs)
x-wave-signature: t=<unix_ts>,v1=<hex_hmac_sha256><timestamp>.<raw_body>(raw body as-is — not re-serialized)Receiver behaviour
business_idmatches URL parameter (defense in depth)event_id, 7-day TTLledgerLog(ChittyLedger)Currently supported events (per Wave Webhooks Setup Guide)
invoice.overdueinvoice.viewedinvoice.approvedThe receiver is generic and audit-logs all schema-conformant events; specific business logic for future events is added as Wave expands the surface.
KV layout
Operator setup runbook (post-merge)
For each tenant + Wave business:
https://finance.chitty.cc/api/webhooks/wave/<tenantId>/<businessId>and select trigger eventswebhook.wave.<event_type>Why no
wave-api.tschanges / no auto-register / no registration scriptWave's GraphQL schema has no webhook-related mutations (verified end-to-end against the public Wave schema). All webhook subscription management is dashboard-only. Adding fake registration code would be dead/never-callable.
Test plan
npx tsc --noEmitcleanfinance.chitty.ccwebhook.wave.invoice_*lands when Wave fires a real eventOut of scope (future PRs)
invoice.approved) — invoice events don't currently map cleanly to thetransactionstable, so for now we audit-log onlytransaction:*/account:*events once Wave releases them🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Tests