Skip to content

✨ server: webhook#926

Draft
nfmelendez wants to merge 16 commits intobetter-authfrom
webhook
Draft

✨ server: webhook#926
nfmelendez wants to merge 16 commits intobetter-authfrom
webhook

Conversation

@nfmelendez
Copy link
Copy Markdown
Contributor

@nfmelendez nfmelendez commented Mar 30, 2026

Summary by CodeRabbit

  • New Features

    • Org-scoped webhook management: create/read/delete endpoints with per-webhook secrets, optional transaction receipts, and persistent webhook configs.
  • Security

    • Outbound webhooks signed with HMAC-SHA256; URL validation blocks private IPs and redirects.
  • Reliability

    • Delivery retries with backoff and timeouts; receipt-aware dispatch for transaction flows.
  • Documentation

    • Comprehensive webhook docs and an authenticated webhook flow example.
  • Tests

    • New end-to-end and unit tests covering signing, delivery, DNS validation, retries, permissions, and logging.
  • Chores

    • Access-control updated to include webhook permissions; publish metadata entries added.

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 30, 2026

🦋 Changeset detected

Latest commit: 2598ece

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

This PR includes changesets to release 2 packages
Name Type
@exactly/server Patch
@exactly/docs Patch

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
Copy link
Copy Markdown

coderabbitai bot commented Mar 30, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a webhook subsystem: documentation, a new sources DB table, a per-organization CRUD webhook API, URL validation, HMAC-SHA256-signed deliveries with retries/timeouts, publisher integration into event flows (including onReceipt), access-control updates, and tests.

Changes

Cohort / File(s) Summary
Documentation
docs/src/content/docs/webhooks.md, docs/src/content/docs/organization-authentication.md, docs/astro.config.ts
New webhook docs, SIWE-authenticated webhook example, and sidebar entry added.
API Routes
server/api/webhook.ts, server/api/index.ts
New Hono router exposing GET/POST/DELETE /webhook with auth/permission checks, Valibot validation, per-org mutexed config upsert/delete; route registered in API index.
Database Schema
server/database/schema.ts
Added exported sources table (id: text PK, config: jsonb) and added source one-relation to credentialsRelations.
Publisher & Integration
server/hooks/panda.ts
Implemented webhook publish flow: URL validation, HMAC-SHA256 Signature header, 60s timeout, retry/backoff, debug logging and Sentry capture; integrated publish calls into transaction/card/user flows; extended webhook/receipt payload schemas.
Utilities
server/utils/webhook.ts, cspell.json
Added isValid(raw: string) URL validator (DNS resolution + private-address checks); added "hmac" to spellcheck allowlist.
Access Control & Keeper
server/utils/auth.ts, server/utils/keeper.ts
Added webhook: ["create","delete","read"] permissions to roles; added optional onReceipt callback to exaSend and invoke it after receipt resolution with error capture.
Tests
server/test/api/webhook.test.ts, server/test/hooks/panda.test.ts, server/test/utils/webhook.test.ts
Added E2E tests for webhook CRUD and auth, updated Panda tests to verify HMAC signatures, redirects, DNS/private-IP blocking and outbound logging, and added unit tests for URL validation.
Releases / Changesets
.changeset/*
Multiple new Changesets added to mark patch releases and webhook-related notes.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant API as Exactly API
    participant DB as Database (sources)
    participant Panda as Panda Publisher
    participant Webhook as External Endpoint
    participant Sentry

    Client->>API: trigger event (e.g., transaction)
    API->>DB: persist state/event
    API->>Panda: invoke publish(payload[, receipt])
    Panda->>DB: load org sources.config
    Panda->>Panda: build JSON body & compute HMAC-SHA256(secret, body)
    loop retry attempts (exponential backoff)
        Panda->>Webhook: POST body with Signature header
        alt 2xx response
            Webhook-->>Panda: response (text/JSON)
            Panda->>Panda: debug log response
        else timeout or non-2xx
            Webhook-->>Panda: error/timeout
            Panda->>Sentry: captureException(error)
            Note right of Panda: wait backoff then retry
        end
    end
    Panda-->>API: publish completed (errors logged)
    API-->>Client: reply to original request
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • cruzdanilo
  • dieguezguille
🚥 Pre-merge checks | ✅ 1 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The title '✨ server: webhook' uses a generic emoji and vague phrasing that doesn't clearly convey the specific implementation details. While it references 'webhook', it provides minimal insight into what webhook functionality was added (API endpoints, security, documentation, etc.). Consider a more specific title like 'Add webhook API endpoints with HMAC signing and retry logic' or 'Implement webhook management system for transaction events' to better describe the primary changes.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch webhook

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.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a comprehensive webhook system, including a new API for managing webhook configurations, database schema updates, and a publishing mechanism integrated into the transaction lifecycle. The implementation features HMAC SHA256 signing for security, an exponential backoff retry policy, and extensive documentation. Review feedback identifies a critical validation error in the card status schema where the 'INACTIVE' state was omitted, as well as several documentation improvements including a broken markdown tag, a typo, and the need for clearer example URLs.

@sentry
Copy link
Copy Markdown

sentry bot commented Mar 30, 2026

✅ All tests passed.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 12


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2d79b368-fdf5-44b0-be33-4c4d1d3a8448

📥 Commits

Reviewing files that changed from the base of the PR and between 128b4ec and 8b0564f.

📒 Files selected for processing (19)
  • .changeset/dry-peas-ring.md
  • .changeset/fifty-friends-bet.md
  • .changeset/long-moons-brake.md
  • .changeset/loud-shoes-visit.md
  • .changeset/olive-onions-tan.md
  • .changeset/quick-ants-write.md
  • .changeset/violet-plums-move.md
  • cspell.json
  • docs/astro.config.ts
  • docs/src/content/docs/organization-authentication.md
  • docs/src/content/docs/webhooks.md
  • server/api/index.ts
  • server/api/webhook.ts
  • server/database/schema.ts
  • server/hooks/panda.ts
  • server/test/api/webhook.test.ts
  • server/test/hooks/panda.test.ts
  • server/utils/auth.ts
  • server/utils/keeper.ts

@cruzdanilo cruzdanilo changed the title Webhook ✨ server: webhook Mar 31, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

♻️ Duplicate comments (2)
server/hooks/panda.ts (1)

1322-1328: ⚠️ Potential issue | 🟠 Major

The outbound card webhook schema is narrower than the values you emit.

publish() maps notActivated to "INACTIVE", and the inbound Card schema also allows allTime and perAuthorization, but Webhook only accepts ACTIVE|FROZEN|DELETED and four frequency values. Those legitimate Panda updates will fail v.parse(Webhook, ...) and no webhook will be sent.

🛠️ Proposed fix
       limit: v.object({
         amount: v.number(),
-        frequency: v.picklist(["per24HourPeriod", "per7DayPeriod", "per30DayPeriod", "perYearPeriod"]),
+        frequency: v.picklist([
+          "per24HourPeriod",
+          "per7DayPeriod",
+          "per30DayPeriod",
+          "perYearPeriod",
+          "allTime",
+          "perAuthorization",
+        ]),
       }),
-      status: v.picklist(["ACTIVE", "FROZEN", "DELETED"]),
+      status: v.picklist(["ACTIVE", "FROZEN", "DELETED", "INACTIVE"]),

Also applies to: 1439-1447

server/api/webhook.ts (1)

14-21: ⚠️ Potential issue | 🟠 Major

Validate webhook URLs before storing them.

These fields accept arbitrary strings, and server/hooks/panda.ts later passes them straight to fetch(). That makes it possible to persist malformed or internal destinations and turn webhook delivery into SSRF. Require a valid https:// URL and reject local/private hosts.

Also applies to: 149-161


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 04b5f99d-29af-4493-b6f7-dafe9ae83cba

📥 Commits

Reviewing files that changed from the base of the PR and between 8b0564f and 43424cd.

📒 Files selected for processing (16)
  • .changeset/dry-peas-ring.md
  • .changeset/fifty-friends-bet.md
  • .changeset/long-moons-brake.md
  • .changeset/loud-shoes-visit.md
  • .changeset/olive-onions-tan.md
  • .changeset/quick-ants-write.md
  • docs/astro.config.ts
  • docs/src/content/docs/organization-authentication.md
  • docs/src/content/docs/webhooks.md
  • server/api/index.ts
  • server/api/webhook.ts
  • server/hooks/panda.ts
  • server/test/api/webhook.test.ts
  • server/test/hooks/panda.test.ts
  • server/utils/auth.ts
  • server/utils/keeper.ts

@nfmelendez nfmelendez force-pushed the webhook branch 2 times, most recently from a45122d to 149c02f Compare April 1, 2026 20:15
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (7)
docs/src/content/docs/organization-authentication.md (1)

163-209: ⚠️ Potential issue | 🟡 Minor

Show the organization prerequisite in this example.

This flow authenticates and then calls GET /api/webhook / POST /api/webhook, but it never creates or selects an organization. Copied in isolation, it will hit 403 { code: "no organization" }. Either add the organization step here or call out the prerequisite explicitly.

server/utils/keeper.ts (1)

151-153: ⚠️ Potential issue | 🟠 Major

Defer onReceipt before wrapping it in a promise.

Promise.resolve(options?.onReceipt?.(receipt)) evaluates the callback before the promise exists, so a synchronous throw escapes this .catch() and can still fail exaSend() after the receipt was already obtained. Queue the callback first, then attach the error handler.

🛠️ Proposed fix
-            Promise.resolve(options?.onReceipt?.(receipt)).catch((error: unknown) =>
-              captureException(error, { level: "error" }),
-            );
+            void Promise.resolve()
+              .then(() => options?.onReceipt?.(receipt))
+              .catch((error: unknown) => captureException(error, { level: "error" }));

Run this to confirm the synchronous-throw behavior difference:

#!/bin/bash
node <<'NODE'
const cb = () => {
  throw new Error("sync throw");
};

try {
  Promise.resolve(cb()).catch(() => console.log("wrapped"));
} catch (error) {
  console.log("escaped:", error.message);
}

Promise.resolve()
  .then(() => cb())
  .catch((error) => console.log("deferred:", error.message));
NODE
server/test/hooks/panda.test.ts (1)

2806-2808: 🧹 Nitpick | 🔵 Trivial

Match the debug mock by namespace instead of call order.

This mockReturnValueOnce() chain only works while server/hooks/panda.ts creates exactly two debug instances in the current order. Adding another namespace or reordering them will silently wire webhookLogger to the wrong logger.

♻️ Suggested refactor
 vi.mock("debug", () => {
-  const createDebug = vi.fn().mockReturnValueOnce(vi.fn()).mockReturnValueOnce(webhookLogger);
+  const createDebug = vi.fn().mockImplementation((namespace: string) =>
+    namespace === "exa:webhook" ? webhookLogger : vi.fn(),
+  );
   return { default: createDebug };
 });

Run this to inspect the current namespace/count assumption:

#!/bin/bash
sed -n '70,75p' server/hooks/panda.ts
sed -n '2804,2809p' server/test/hooks/panda.test.ts
docs/src/content/docs/webhooks.md (1)

393-423: ⚠️ Potential issue | 🟠 Major

Remove authorizedAmount from the force-capture payload.

Force capture is the completed flow without a prior authorization. Keeping authorizedAmount here documents the settlement/over-capture contract instead, so consumers may build the wrong parser.

server/hooks/panda.ts (1)

1249-1257: ⚠️ Potential issue | 🟠 Major

Bound the retry window here.

delay: ({ count }) => 2^count * 500ms with retryCount: 20 pushes the last retry out to ~72 hours and keeps a failed delivery alive for nearly a week. At the same time, plain fetch() network failures still skip retries because only "WebhookFailed" and "TimeoutError" are whitelisted. Cap the delay and include transient network errors if this path is meant to be resilient.

server/api/webhook.ts (2)

14-21: ⚠️ Potential issue | 🟠 Major

Validate webhook targets before persisting them.

These schemas accept any string, and server/hooks/panda.ts later passes the stored value straight to fetch(). That allows webhook configs pointing at localhost, link-local, or other internal hosts. Parse the URL here and reject non-HTTPS/private targets before storing them.

Also applies to: 149-160


175-186: ⚠️ Potential issue | 🟠 Major

This read-modify-write still races across instances.

The in-memory mutex only serializes one process. Two app instances can both read sources.config, apply different mutations, and write whole-document updates that clobber each other. Move this into a DB transaction with row locking or an atomic jsonb update.

Also applies to: 255-266


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: d26e278a-58bf-48d3-9902-656e2f07f45c

📥 Commits

Reviewing files that changed from the base of the PR and between 43424cd and a45122d.

📒 Files selected for processing (16)
  • .changeset/dry-peas-ring.md
  • .changeset/fifty-friends-bet.md
  • .changeset/long-moons-brake.md
  • .changeset/loud-shoes-visit.md
  • .changeset/olive-onions-tan.md
  • .changeset/quick-ants-write.md
  • docs/astro.config.ts
  • docs/src/content/docs/organization-authentication.md
  • docs/src/content/docs/webhooks.md
  • server/api/index.ts
  • server/api/webhook.ts
  • server/hooks/panda.ts
  • server/test/api/webhook.test.ts
  • server/test/hooks/panda.test.ts
  • server/utils/auth.ts
  • server/utils/keeper.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (5)
server/hooks/panda.ts (2)

1250-1251: ⚠️ Potential issue | 🟠 Major

Cap the retry backoff.

With retryCount: 20, this reaches 262,144,000ms on the last delay, which is roughly 72 hours. That makes failed deliveries linger for days and ties the retry window to process lifetime.

🛠️ Suggested cap
-          delay: ({ count }) => Math.trunc(1 << count) * 500,
+          delay: ({ count }) => Math.min(Math.trunc(1 << count) * 500, 60_000),

1442-1445: ⚠️ Potential issue | 🟠 Major

Allow the full upstream limit frequency set.

Lines 176-183 accept "allTime" and "perAuthorization" on inbound card.updated events, but the outbound webhook schema rejects both here. v.parse(Webhook, ...) will fail and skip delivery for valid card limit updates.

🛠️ Suggested fix
       limit: v.object({
         amount: v.number(),
-        frequency: v.picklist(["per24HourPeriod", "per7DayPeriod", "per30DayPeriod", "perYearPeriod"]),
+        frequency: v.picklist([
+          "per24HourPeriod",
+          "per7DayPeriod",
+          "per30DayPeriod",
+          "perYearPeriod",
+          "allTime",
+          "perAuthorization",
+        ]),
       }),
docs/src/content/docs/webhooks.md (3)

29-33: ⚠️ Potential issue | 🟠 Major

Use the webhook secret in the verification snippet.

Outbound signatures are generated with the per-webhook secret, not a generic API key. Copying this example as-is will fail against real deliveries.

🛠️ Suggested correction
-const signature = createHmac("sha256", <YOUR_API_KEY>)
+const signature = createHmac("sha256", "<YOUR_WEBHOOK_SECRET>")

393-420: ⚠️ Potential issue | 🟠 Major

Make the force-capture example a true no-authorization settlement.

The force-capture path is the completed flow with no prior authorized hold. Keeping authorizedAmount here models a settled authorization instead of a force capture.

🛠️ Suggested correction
-      "authorizedAmount": 10000,

641-653: ⚠️ Potential issue | 🟠 Major

Document card.updated as a general lifecycle event.

server/hooks/panda.ts forwards generic card updates, including non-wallet states like canceled. Describing this as only digital-wallet provisioning will make consumers ignore valid events.

🛠️ Suggested wording
-This webhook is currently triggered when a user adds their card to a digital wallet.
+This webhook is sent whenever Exa receives a card lifecycle update, including wallet provisioning and status changes such as cancellation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 84db6815-a17c-450d-9e1c-a996d1ceb7db

📥 Commits

Reviewing files that changed from the base of the PR and between a45122d and 149c02f.

📒 Files selected for processing (6)
  • .changeset/dry-peas-ring.md
  • .changeset/fifty-friends-bet.md
  • docs/src/content/docs/webhooks.md
  • server/hooks/panda.ts
  • server/test/api/webhook.test.ts
  • server/test/hooks/panda.test.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (2)
server/hooks/panda.ts (1)

1252-1254: ⚠️ Potential issue | 🟠 Major

Cap the backoff before one bad webhook hangs around for days.

With retryCount: 20, Math.trunc(1 << count) * 500 reaches 262,144,000ms (~72h) on the last delay. That is long enough to keep this fire-and-forget delivery task alive for multiple days.

⏱️ Minimal fix
         {
-          delay: ({ count }) => Math.trunc(1 << count) * 500,
+          delay: ({ count }) => Math.min(Math.trunc(1 << count) * 500, 60_000),
           retryCount: domain === "base-sepolia.exactly.app" ? 3 : 20,
           shouldRetry: ({ error }) => {
server/api/webhook.ts (1)

29-34: ⚠️ Potential issue | 🟠 Major

This read-modify-write still loses webhook updates across instances.

The Map<string, Mutex> only serializes requests handled by one Node process. Two instances can both read the same sources.config, apply different mutations, and the last write wins; if both see no row, one of the inserts can also fail on the sources.id primary key. Use a database transaction with row locking or an atomic jsonb update instead.

Also applies to: 206-224, 286-300


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 60b544ed-273f-4d14-a251-3274e2013603

📥 Commits

Reviewing files that changed from the base of the PR and between 149c02f and 4b7a21c.

📒 Files selected for processing (6)
  • server/api/webhook.ts
  • server/hooks/panda.ts
  • server/test/api/webhook.test.ts
  • server/test/hooks/panda.test.ts
  • server/test/utils/webhook.test.ts
  • server/utils/webhook.ts

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.

3 participants