Skip to content

feat(api): v1 webhooks — durable signed delivery over the RealtimeBus (T1) - #174

Merged
arkadianet merged 3 commits into
mainfrom
feat/v1-api-webhooks
Jul 8, 2026
Merged

feat(api): v1 webhooks — durable signed delivery over the RealtimeBus (T1)#174
arkadianet merged 3 commits into
mainfrom
feat/v1-api-webhooks

Conversation

@arkadianet

@arkadianet arkadianet commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Stacked on #172#171#170#169#168. Merge those first.

What this is

The webhooks subsystem — the durable, retried outbound sibling of the WS surface, built as an internal subscriber to the same RealtimeBus. Completes the design's real-time step.

Endpoints (all T1 — registration is an outbound-request lever)

POST /webhooks (201, secret echoed once, never again) · GET /webhooks + /{id} · PATCH /{id} pause/resume · DELETE /{id} · GET /{id}/deliveries (attempt log: status, code, retry count, next_retry). Subsystem-off → webhooks_disabled (409), never a bare 404. First real call site of the G2 require_tier(Operator) extractor.

Delivery semantics

  • Signature: X-Ergo-Signature: sha256=<hex HMAC-SHA256(secret, "{timestamp}.{raw_body}")> + the X-Ergo-* header set; delivery_id stable across retries (verification recipe documented).
  • Dedupe on (webhook_id, event_seq)event_seq is the SAME global cursor the WS surface uses.
  • At-least-once, exponential backoff 2s·2^(n-1) capped 1h + jitter, 12 attempts → parked failed (no head-of-line blocking); auto-disable at 20 consecutive failures; per-webhook inflight cap 4, global 64 — a broken endpoint can never stall the bus or other webhooks.
  • SSRF guard: literal-host reject for loopback/RFC1918/ULA/link-local/CGNAT/unspecified/multicast; https by default; embedded credentials rejected. Documented limit: DNS-rebinding is not caught by literal validation.
  • HMAC via the existing workspace hmac+sha2zero new compiled crates.

⚠️ Two deliberate deferrals (operator decisions, not oversights)

  1. No production network sink yet. The workspace has no TLS-capable HTTP client (no reqwest, no TLS stack; hyper only transitive), and adding one is a real supply-chain/binary-size decision — so the transport is an injected WebhookSink trait, the full engine/retry/signing state machine is tested end-to-end over the real bus with a deterministic in-process sink, and no worker spawns in production: deliveries enqueue and are queryable as pending. Recommendation: reqwest with rustls-tls as a follow-up commit once approved. No faked network tests.
  2. In-memory persistence. Registry + delivery log are bounded in-memory; registrations are lost on restart until a *-db schema lands (documented — schema deliberately not invented here).

Natural follow-ups on the same engine (fragment lists them, out of §4.1 scope): rotate-secret, test, redeliver.

Test plan

cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace

36 webhook tests: register/list/detail/pause/delete (+T1 auth rejects), known-vector HMAC, retry/backoff state machine over an injected transport, dedupe, SSRF matrix, secret-never-echoed. Compat untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BUh2DBnAPqThdYFZW5D8wx

Summary by CodeRabbit

  • New Features

    • Added webhook management endpoints for operators, including create, view, list, update, and delete actions.
    • Added outbound webhook delivery support with retries, signatures, and delivery history.
    • Added filtering for matching events so webhooks are triggered only for relevant updates.
  • Bug Fixes

    • Improved handling when the webhook system is unavailable, returning a consistent error response.
    • Added safeguards to reduce duplicate deliveries and improve delivery reliability.

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@arkadianet, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 30 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0134121f-ff93-46ac-a61c-1f82d06ecae3

📥 Commits

Reviewing files that changed from the base of the PR and between 4149235 and b84dd11.

📒 Files selected for processing (2)
  • ergo-api/src/server.rs
  • ergo-api/src/v1/webhooks/mod.rs
📝 Walkthrough

Walkthrough

This PR adds a new webhooks subsystem to ergo-api: a data model with HMAC signing and SSRF URL validation, an in-memory delivery engine with retry/backoff and auto-disable logic, a worker consuming realtime events for at-least-once delivery, operator-gated HTTP routes, and integration into v1 module exports and server routing. New hmac and sha2 dependencies were added.

Changes

Webhooks Subsystem

Layer / File(s) Summary
Subscription/Delivery model, signing, SSRF validation
ergo-api/src/v1/webhooks/model.rs
Defines Subscription, Delivery, WebhookHealth, DeliveryStatus, AutoDisabledReason DTOs, HMAC-SHA256 sign_body(), and validate_url() with default-deny UrlPolicy, plus unit tests.
WebhookEngine: registration, scheduling, retry/backoff
ergo-api/src/v1/webhooks/engine.rs
Implements in-memory WebhookEngine for subscription CRUD, event dedupe/enqueue, due-delivery scheduling with in-flight caps, jittered exponential backoff, auto-disable, and secret generation, with extensive tests.
Delivery worker: bus subscription and dispatch loop
ergo-api/src/v1/webhooks/worker.rs
Adds spawn_webhook_worker consuming RealtimeBus events, draining due deliveries via injected WebhookSink, with fake-sink and end-to-end tests.
Operator HTTP routes for webhook management
ergo-api/src/v1/webhooks/routes.rs
Adds WebhooksState/WebhooksHandle and Axum routes for register/list/detail/patch/delete/deliveries gated by operator tier, returning 409 webhooks_disabled when unwired, with integration tests.
Module exports and server wiring
ergo-api/src/v1/webhooks/mod.rs, ergo-api/src/v1/mod.rs, ergo-api/src/server.rs, ergo-api/Cargo.toml
Exposes the webhooks module/re-exports, constructs WebhookEngine/WebhooksState in server.rs, mounts webhooks_router under the operator API-key gate, and adds hmac/sha2 dependencies.

Estimated code review effort: 4 (Complex) | ~75 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RealtimeBus
  participant WebhookEngine
  participant Worker
  participant Sink

  RealtimeBus->>WebhookEngine: enqueue_matches(event)
  Worker->>WebhookEngine: take_due(now)
  WebhookEngine-->>Worker: PreparedRequest list
  Worker->>Sink: post(request)
  Sink-->>Worker: DeliveryOutcome
  Worker->>WebhookEngine: record_result(delivery_id, outcome)
  WebhookEngine->>WebhookEngine: schedule retry or auto-disable
Loading
sequenceDiagram
  participant Client
  participant WebhooksRouter
  participant WebhookEngine
  participant RealtimeBus

  Client->>WebhooksRouter: POST /api/v1/webhooks
  WebhooksRouter->>WebhooksRouter: validate_url + channel checks
  WebhooksRouter->>RealtimeBus: check channel liveness
  WebhooksRouter->>WebhookEngine: register(url, channels, secret)
  WebhookEngine-->>WebhooksRouter: Subscription
  WebhooksRouter-->>Client: 201 with secret echoed once
Loading

Possibly related PRs

  • arkadianet/ergo#168: Introduces the tier-based v1 auth (require_tier, V1AuthConfig) that the new webhooks routes reuse, and extends the same ergo-api/src/v1/mod.rs surface.
  • arkadianet/ergo#169: Modifies the same router_with_mempool_and_wallet_and_security in ergo-api/src/server.rs to compose /api/v1 route groups, directly related to the new webhooks router mounting.
🚥 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 change: a new v1 webhooks subsystem with signed durable delivery over the RealtimeBus.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/v1-api-webhooks

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.

@arkadianet
arkadianet force-pushed the feat/v1-api-webhooks branch from a04e0ad to f3171fe Compare July 7, 2026 18:16
@arkadianet
arkadianet changed the base branch from main to feat/v1-api-realtime July 7, 2026 22:23
@arkadianet
arkadianet changed the base branch from feat/v1-api-realtime to main July 7, 2026 23:27
arkadianet and others added 2 commits July 8, 2026 18:57
Add the durable, retried, signed outbound-delivery sibling of the WS
RealtimeBus. Webhooks are an internal subscriber to the SAME bus (one
event source, one global seq, one channel vocabulary), so a webhook
consumer and a WS consumer agree on ordering and identity.

Why this shape: the delivery transport is abstracted behind an injected
WebhookSink trait. The node's lock ships no HTTP client and no TLS stack
(reqwest/hyper-rustls absent; hyper is only a transitive axum dep), so a
real HTTPS client would be the heavy new dependency the conventions warn
against adding unilaterally. The full engine + worker + retry discipline
are therefore built transport-free and tested against a deterministic
in-process sink (no network, nothing faked); the concrete TLS-capable
sink is a documented follow-up. Persistence is likewise in-memory and
bounded this PR — durable-across-restart registration needs a *-db
schema and is deferred (no schema invented here).

- model: Subscription/Delivery records + wire DTOs (secret never echoed
  after create), HMAC-SHA256 signing recipe (hmac+sha2, known-vector
  tested), SSRF URL policy (literal-host loopback/private/link-local
  reject; https-default; embedded-credentials reject).
- engine: transport-free registry + bounded delivery log + retry/backoff
  (exp base 2s cap 1h, max 12 attempts) + dedupe on (webhook_id, seq) +
  per-webhook & global in-flight caps + auto-disable at 20 consecutive
  failures. Clock-injected — the whole state machine is unit-tested.
- worker: the RealtimeBus-subscriber loop driving the injected sink;
  tested end-to-end over the real bus with a fake sink.
- routes: POST/GET/GET{id}/PATCH/DELETE /api/v1/webhooks +
  GET .../deliveries, all T1 (require_tier Operator), subsystem-off ⇒
  webhooks_disabled (never a bare 404).

Reuses the v1 primitives verbatim: error envelope + Reason enum, cursor
page builder, the realtime channel parser + channel_unavailable liveness
gate. Adds hmac + sha2 to ergo-api (both already in the lock via the
crypto stack — zero new compiled crates).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BUh2DBnAPqThdYFZW5D8wx
Wire the webhook subsystem into the assembled router: construct the
WebhookEngine + WebhooksHandle sharing the same RealtimeBus the WS
surface uses, and merge webhooks_router under the operator api-key gate
(the V1AuthConfig captured from the same `security` the wallet surface
uses, before it is consumed by the native wallet mount).

No delivery worker is spawned yet: the concrete outbound HTTP(S) sink is
a deferred dependency decision, so registration + the delivery log are
live and correct while deliveries enqueue and are queryable as `pending`
until a WebhookSink is wired — no in-flight state leaks. Persistence is
in-memory: registrations are lost on restart until a durable store lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BUh2DBnAPqThdYFZW5D8wx
@arkadianet
arkadianet force-pushed the feat/v1-api-webhooks branch from f3171fe to 4149235 Compare July 8, 2026 09:01
@arkadianet

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@ergo-api/src/server.rs`:
- Around line 1252-1268: Clarify the webhook flow in the initialization around
WebhooksState/WebhookEngine so the comment and behavior do not imply deliveries
are enqueued yet. Update the wording near v1_webhooks_state and
WebhookEngine::new to state that registrations are stored and queryable, but no
pending deliveries are created until a WebhookSink/worker is actually wired in.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 13c37e50-b56a-4809-a44d-39d6d3abeac8

📥 Commits

Reviewing files that changed from the base of the PR and between bbaf6b7 and 4149235.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • ergo-api/Cargo.toml
  • ergo-api/src/server.rs
  • ergo-api/src/v1/mod.rs
  • ergo-api/src/v1/webhooks/engine.rs
  • ergo-api/src/v1/webhooks/mod.rs
  • ergo-api/src/v1/webhooks/model.rs
  • ergo-api/src/v1/webhooks/routes.rs
  • ergo-api/src/v1/webhooks/worker.rs

Comment thread ergo-api/src/server.rs
…orker is wired

enqueue_matches only runs inside spawn_webhook_worker, which the server
never spawns, so "deliveries enqueue and are queryable as pending" was
false. State what actually holds: registrations are stored and queryable;
no pending deliveries exist until a WebhookSink + worker land.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BUh2DBnAPqThdYFZW5D8wx
@arkadianet
arkadianet merged commit 1cb522f into main Jul 8, 2026
9 checks passed
@arkadianet
arkadianet deleted the feat/v1-api-webhooks branch July 9, 2026 06:40
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