Skip to content

feat: webhooks - #270

Merged
Zingzy merged 18 commits into
mainfrom
feat/webhooks-system
Jul 24, 2026
Merged

feat: webhooks#270
Zingzy merged 18 commits into
mainfrom
feat/webhooks-system

Conversation

@Zingzy

@Zingzy Zingzy commented Jul 22, 2026

Copy link
Copy Markdown
Member

What

Real-time event deliveries to subscriber URLs, following the Standard Webhooks specification. When something happens to your links, spoo.me tells your other tools: your own server, a Zapier hook, anything that accepts an HTTPS POST.

Fully opt-in and dark by default: WEBHOOKS_ENABLED=false wires a null sink and mounts nothing, and access is additionally gated per user by the webhooks feature flag (default deny, 403 when not granted).

Events

Five event types, one rule: events split by who caused them. Actor edits ride link.updated and its changes map (status included); system-discovered facts get named events.

Event Fires when
link.created a link is created
link.updated a link is edited; changes maps each field to old/new
link.deleted a link is deleted
link.clicked every tracked click, bots included (is_bot in payload)
link.expired expiry is discovered (max clicks or time), once per link

Payloads carry the full public link snapshot plus event context. link.clicked is flat and dimensional: country/city, browser/os/device, bounded raw user agent, referrer, UTM, long_url, running click total. Bulk operations emit per item, indistinguishable from a loop. Nothing password-shaped, IP-shaped, or internal ever rides the wire: password fields become presence booleans, meta tags reduce to their four public fields, and payload validation is pinned to the registry models by test.

GET /api/v1/webhooks/event-types is a public catalog with exact sample payloads; the same fixtures power test sends, so docs and behavior cannot drift.

Delivery

  • Standard Webhooks signing: webhook-id / webhook-timestamp / webhook-signature headers, HMAC-SHA256, whsec_ secrets shown once and stored AES-GCM encrypted (the server signs at delivery time, so hash-only storage is not an option). Verified against the published reference test vector.
  • At-least-once with retries: immediate, 5s, 5m, 30m, 2h, 5h, 10h. webhook-id is stable across retries and bodies are frozen after first render, so consumer dedup works on either.
  • 410 Gone disables an endpoint immediately; ten consecutive exhausted deliveries disable it too.
  • Per-endpoint pending cap (default 1000): beyond it deliveries are dropped and counted, and the next successful delivery carries dropped_since_last as a reconcile signal.
  • Delivery log per endpoint: every attempt with status, latency, error, response snippet, and the exact body sent. 30-day TTL, coupled to the event store so a delivery can never outlive its event.

Architecture

Two Redis Streams feed a dispatcher: the existing click stream gains a webhooks consumer group (reader plus claimer pair, DLQ guard, same machinery as the stats and hotness groups), and a new low-volume events:domain stream carries link lifecycle facts from UrlService and the bulk operations. The dispatcher matches subscriptions (owner-level Redis cache in front, so accounts without webhooks cost nothing on the click path) and records delivery intent in Mongo. A claim-loop executor owns delivery: atomic findOneAndUpdate claims with leases make N concurrent executors safe, and the loop needs only Mongo, so it runs in the click worker in production or embedded in the app process for self-hosts without a queue Redis. Deployments without any Redis still work: dispatch happens inline at emit time.

Events are stored once per fact; delivery rows are thin references. Rendering happens at first attempt, keeping dispatch insert-only on the consumer ack path.

Security

  • Endpoint URLs are SSRF-checked at registration and re-resolved before every delivery (public addresses only, connection pinned to the resolved IP, redirects refused), reusing the existing safe_fetch hardening.
  • Registration requires a verified email plus the feature flag; webhooks:manage and webhooks:read API key scopes; five endpoints per user (config), 20KB payload cap, tight rate limits on create and test sends.

Testing

Full suite green (2815). New coverage: signing vectors, registry/wildcard expansion, matcher scoping, dispatcher fan-out and cap, executor retry/disable paths, route integration with frozen wire shapes, bulk emission parity. Also verified end to end against live Mongo and Redis with a real signed HTTPS delivery to a public echo endpoint.

Rollout

Merges dark. Enabling requires WEBHOOKS_ENABLED=true plus seeding the webhooks feature flag. Env reference is in .env.example; an Axiom dashboard for dispatch/delivery health ships in axiom/dashboards/. Follow-ups tracked separately: endpoint auto-disable email notification, Discord/Slack payload flavors, dashboard UI.

Summary by CodeRabbit

  • New Features
    • Added webhook endpoint management API (create/list/get/update/delete), event catalog, synchronous test sends, delivery log listing, and manual redelivery retries with scoped event subscriptions.
    • Added signed webhook deliveries with endpoint health tracking, retry/backoff handling, and auto-disable on repeated failures.
    • Added link lifecycle/click webhook payloads with wildcard subscriptions and new Discord/Slack rendering flavors.
    • Added dispatch/delivery execution for worker and embedded modes.
  • Documentation
    • Updated the example environment file with optional Webhooks configuration (off by default), including timeouts, caps, and secret requirements.
  • Monitoring / Tests
    • Added a webhooks performance dashboard and expanded webhook test coverage.

Closes #271.

Zingzy added 7 commits July 23, 2026 02:34
Domain-event backbone (events:domain + sinks with inline/null fallback),
event registry, Standard Webhooks signing with AES-GCM secret storage,
matcher with owner cache, dispatcher with pending cap, Mongo claim-loop
delivery executor with retry ladder and auto-disable, full API surface
gated by the webhooks flag, worker consumer groups + embedded runtime,
producers in UrlService and CustomDomainService.
…rface

link.expired now fires from both discovery sites (max-clicks branch in
the click handler, lazy time-expiry flip on resolve), gated once per
link by the existing atomic updates. bot.detected and key.created are
cut from the catalog: tracked bots already ride link.clicked as is_bot,
and blocked bots produce no click by design. Adds per-delivery outcome
logs, the webhooks Axiom dashboard, and .env.example entries. Comments
rewritten to be self-contained.
meta_tags changes now carry only the four public fields, dropping the
internal updated_ip/updated_at/image_meta bookkeeping, and datetimes in
changes serialize as ISO 8601 like every other wire timestamp. Password
redaction unchanged.
domain.verified fails the catalog's own admission test (verification is
user-initiated, the owner is watching the dashboard) and
domain.suspended belongs to first-party email when the reverify worker
ships, not to subscriber-built webhook infrastructure. Catalog is six
link events; adding a domain category back later is a registry entry.
… ops

Events split by causer, not by field: actor edits ride link.updated and
its changes map (status included), system-discovered facts get named
events (link.expired today, link.blocked when the safety framework
ships a producer). status_changed only ever fired for owner edits and
double-fired alongside updated. Bulk ops now emit per item, keeping the
promise that subscribers cannot tell bulk from a loop; the changes-map
builder moved to webhooks.payloads so both producers share one shape.
Snapshots now carry geo_rules and meta_tags (document projection, no
entitlement lookups), the changes map speaks the snapshot's public
vocabulary (expire_after surfaces as expires_at), link.clicked gains
long_url from the cached data, and the envelope carries the event id
so consumers can correlate across endpoints and the delivery log.
Alongside the parsed browser/os/device fields: receiver-side debugging
and bot forensics need the original string. Visitor-controlled input,
so it is control-char-stripped and bounded to 512 chars before riding
the wire, same posture as the UTM bounds.
Copilot AI review requested due to automatic review settings July 22, 2026 21:04

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9739996d-6fe5-407b-8746-e2f2affed052

📥 Commits

Reviewing files that changed from the base of the PR and between deec3e3 and 161c039.

📒 Files selected for processing (17)
  • infrastructure/safe_fetch.py
  • repositories/webhook_endpoint_repository.py
  • routes/api_v1/webhooks.py
  • schemas/dto/responses/webhook.py
  • schemas/enums/webhook.py
  • services/webhooks/dispatcher.py
  • services/webhooks/executor.py
  • services/webhooks/renderers/__init__.py
  • services/webhooks/renderers/copy.py
  • services/webhooks/renderers/discord.py
  • services/webhooks/renderers/protocol.py
  • services/webhooks/renderers/slack.py
  • services/webhooks/service.py
  • tests/integration/api_v1/test_webhooks.py
  • tests/unit/infrastructure/test_safe_fetch_post.py
  • tests/unit/services/webhooks/test_executor.py
  • tests/unit/services/webhooks/test_renderers.py
🚧 Files skipped from review as they are similar to previous changes (7)
  • services/webhooks/renderers/protocol.py
  • schemas/enums/webhook.py
  • repositories/webhook_endpoint_repository.py
  • services/webhooks/dispatcher.py
  • infrastructure/safe_fetch.py
  • services/webhooks/service.py
  • services/webhooks/executor.py

📝 Walkthrough

Walkthrough

Adds an opt-in webhook platform with endpoint CRUD, event subscriptions, encrypted signing secrets, durable delivery records, retries, signed HTTP delivery, worker and inline runtimes, API routes, domain-event fanout, indexes, metrics, and integration/unit coverage.

Changes

Webhook platform

Layer / File(s) Summary
Contracts, persistence, and delivery safety
.env.example, config.py, schemas/..., infrastructure/..., repositories/...
Defines webhook configuration, endpoint and delivery models, encrypted secrets, SSRF-guarded POSTs, MongoDB indexes, repositories, leasing, retries, and endpoint health tracking.
Domain events and dispatch
services/events/*, services/webhooks/payloads.py, services/webhooks/registry.py, services/webhooks/matcher.py, services/webhooks/dispatcher.py, services/url_service.py, services/bulk_url_service.py, services/click/handlers.py
Adds event contracts, payload projections, registry validation, subscription matching, durable delivery intent, and link lifecycle/click event production.
Rendering, execution, and API operations
services/webhooks/executor.py, services/webhooks/renderers/*, services/webhooks/service.py, services/webhooks/signing.py, routes/api_v1/webhooks.py, schemas/dto/.../webhook.py
Renders raw, Discord, and Slack payloads; signs and sends deliveries; records attempts; manages retries and endpoint disablement; and exposes endpoint, test, secret, and delivery-log APIs.
Runtime wiring and validation
dependencies/..., workers/click_worker.py, app.py, tests/..., axiom/dashboards/spoo-webhooks.json
Wires inline and stream execution, starts and stops executors, registers worker consumers, adds authorization and feature flags, and covers API, event, delivery, renderer, indexing, configuration, safe-fetch, and operational behavior.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • spoo-me/spoo#142: Both changes extend API-key scope definitions and authorization handling.
  • spoo-me/spoo#213: Webhook fanout extends the click-event pipeline and worker wiring.
  • spoo-me/spoo#230: Webhook click payloads consume GeoIP data from the click pipeline.

Suggested labels: enhancement

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.28% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and correctly identifies the PR’s main feature area.
Linked Issues check ✅ Passed The PR implements the requested webhook system, event types, signing, retries, logs, catalog, test sends, link scoping, and payload flavors.
Out of Scope Changes check ✅ Passed No clearly unrelated changes stand out; the added files and updates support the webhook feature end to end.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/webhooks-system

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.

@Zingzy Zingzy self-assigned this Jul 22, 2026
@Zingzy Zingzy added backend Changes related to Backand/API Feature 🌟 labels Jul 22, 2026
@Zingzy Zingzy moved this to 📋 Planning Stage in spoo.me Development Roadmap Jul 22, 2026
@Zingzy Zingzy added this to the Core Feature Enhancements milestone Jul 22, 2026

@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: 13

🧹 Nitpick comments (7)
app.py (1)

198-214: 🩺 Stability & Availability | 🔵 Trivial

Consider bounding the shutdown wait on the embedded executor task.

await webhook_executor_task after cancel() has no timeout. Today DeliveryExecutor.run() only exits via CancelledError and its await points (sleep, network I/O) are cancellation-friendly, so this should return promptly — but if that loop ever grows a non-cancellable blocking call, shutdown could stall past the deploy's termination grace period.

🛡️ Optional hardening: bound the wait
         if webhook_executor_task is not None:
             webhook_executor_task.cancel()
-            with suppress(asyncio.CancelledError):
-                await webhook_executor_task
+            with suppress(asyncio.CancelledError):
+                await asyncio.wait_for(webhook_executor_task, timeout=10)
🤖 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 `@app.py` around lines 198 - 214, Bound the shutdown wait for
webhook_executor_task in the application lifespan cleanup: after cancelling the
task, await it with the existing asyncio timeout mechanism and preserve
suppression of normal cancellation, while ensuring an overlong shutdown does not
block deployment termination. Keep the existing embedded executor startup and
cancellation flow unchanged.
tests/integration/api_v1/test_webhooks.py (1)

55-140: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add a cross-tenant ownership isolation test.

This suite thoroughly covers CRUD, signing, retries, and delivery logs for a single user, but no test asserts that a second user cannot GET/PATCH/DELETE/test-send/list-deliveries on another user's endpoint (the fakes already gate on user_id, e.g. find_owned, delete_endpoint). Given secrets and delivery logs are exposed here, a short test exercising a second _make_user() against another user's created['id'] and asserting 404s would close this gap cheaply.

Also applies to: 304-483

🤖 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 `@tests/integration/api_v1/test_webhooks.py` around lines 55 - 140, Add an
integration test using two users created via _make_user(), with the first user
creating an endpoint and the second attempting GET, PATCH, DELETE, test-send,
and list-deliveries operations against the first user’s endpoint ID. Assert each
cross-tenant request returns 404, preserving the existing endpoint setup and
authentication helpers.
axiom/dashboards/spoo-webhooks.json (1)

27-106: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

is_test filter applied inconsistently across charts.

Only stat-delivered excludes test pings (is_test != true); stat-delivery-success-rate, stat-p95-delivery, ts-dispatch-by-type, ts-delivery-outcomes, ts-attempt-failures-by-status, table-slowest-endpoints, and table-failing-endpoints do not. Test webhook sends (dashboard "send test" pings) will skew success-rate, latency percentile, and failing-endpoint metrics used for on-call triage.

Example fix for one chart
     {
       "id": "stat-delivery-success-rate",
       "name": "Delivery success rate %",
       "type": "Statistic",
       "query": {
-        "apl": "['spoo-prod']\n| where event in (\"webhook_delivered\", \"webhook_delivery_attempt_failed\")\n| summarize total = count(), ok = countif(event == \"webhook_delivered\")\n| project rate = round(100.0 * ok / total, 2)"
+        "apl": "['spoo-prod']\n| where event in (\"webhook_delivered\", \"webhook_delivery_attempt_failed\") and is_test != true\n| summarize total = count(), ok = countif(event == \"webhook_delivered\")\n| project rate = round(100.0 * ok / total, 2)"
       }
     },
🤖 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 `@axiom/dashboards/spoo-webhooks.json` around lines 27 - 106, Apply the same
is_test exclusion used by stat-delivered to the queries for
stat-delivery-success-rate, stat-p95-delivery, ts-dispatch-by-type,
ts-delivery-outcomes, ts-attempt-failures-by-status, table-slowest-endpoints,
and table-failing-endpoints. Add the filter so test pings are excluded while
preserving each chart’s existing event conditions and aggregations.
workers/click_worker.py (1)

162-208: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Redundant GeoIPService instantiation when both webhooks and stats groups run.

webhook_geoip is constructed at line 187 for WebhookClickConsumer, and a separate geoip instance is constructed at line 210 for the stats group — both open the same .mmdb files independently. app.py's lifespan comment notes GeoIP is meant to be "a singleton so the .mmdb readers are opened once and reused". Consider building one GeoIPService up front in _build_runtime and sharing it between the webhooks and stats consumers.

Proposed consolidation
     runtime = _WorkerRuntime(
         mongo_client=mongo_client,
         cache_redis=cache_redis,
         counter_redis=None,
     )
 
+    shared_geoip: GeoIPService | None = None
+
     worker_domain_sink = None
     if run_webhooks:
         ...
-        webhook_geoip = GeoIPService(settings.geoip_country_db, settings.geoip_city_db)
+        shared_geoip = shared_geoip or GeoIPService(
+            settings.geoip_country_db, settings.geoip_city_db
+        )
         runtime.consumers["webhooks"] = WebhookClickConsumer(
-            dispatcher, webhook_geoip, settings.system_default_domain
+            dispatcher, shared_geoip, settings.system_default_domain
         )
         ...
 
     if "stats" in groups:
-        geoip = GeoIPService(settings.geoip_country_db, settings.geoip_city_db)
+        geoip = shared_geoip or GeoIPService(
+            settings.geoip_country_db, settings.geoip_city_db
+        )

Also applies to: 209-222

🤖 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 `@workers/click_worker.py` around lines 162 - 208, Consolidate GeoIP
initialization in _build_runtime by constructing a single GeoIPService before
the webhooks and stats consumer setup. Reuse that shared instance for
WebhookClickConsumer and the stats consumer, removing the separate webhook_geoip
and geoip constructions while preserving their existing configuration arguments.
tests/unit/repositories/test_indexes.py (1)

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

Webhook-endpoint indexes (ix_matcher, user_id+status) aren't asserted.

webhook_endpoints_col is mocked but no assertion checks its create_index calls, unlike the events/deliveries collections. ix_matcher backs the hot matcher-lookup path (SubscriptionMatcher), so it's worth pinning here too.

Suggested additional assertions
         webhook_deliveries_col.create_index.assert_any_await(
             [("created_at", 1)], expireAfterSeconds=2_592_000, name="ttl_created_at"
         )
+        webhook_endpoints_col.create_index.assert_any_await(
+            [("user_id", 1), ("events", 1), ("status", 1)], name="ix_matcher"
+        )

Also applies to: 46-48, 67-80

🤖 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 `@tests/unit/repositories/test_indexes.py` around lines 30 - 32, Extend the
index-creation assertions for webhook_endpoints_col to verify its create_index
calls, matching the existing assertions for webhook_events_col and
webhook_deliveries_col. Pin both the ix_matcher index and the compound
user_id-plus-status index, including their configured fields and relevant
options.
services/webhooks/signing.py (1)

1-10: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Correct the secret-storage documentation.

Lines 6-7 state that secrets are stored hash-only, but WebhookEndpointDoc.signing_secret_enc stores ciphertext and the executor decrypts it to sign deliveries. Keep this contract description 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 `@services/webhooks/signing.py` around lines 1 - 10, Update the module
docstring’s secret-storage description to state that the signing secret is
stored encrypted/ciphertext and decrypted by the delivery executor when signing
webhooks. Keep the existing format, one-time display, and retry timestamp
behavior unchanged.
services/bulk_url_service.py (1)

621-655: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Sequential per-item event emission on bulk paths adds request latency for large batches.

_emit_updated/_emit_deleted await each self._events.emit(...) one at a time. Since sinks never raise (errors are swallowed internally), these can safely run concurrently to bound per-request latency on large bulk operations.

♻️ Suggested refactor
     async def _emit_updated(self, docs: list[UrlV2Doc], set_ops: dict) -> None:
         """One link.updated per changed item — post-state snapshot plus the
         same changes map the single-item producer builds."""
-        for doc in docs:
-            owner = link_owner_id(doc)
-            if owner is None:
-                continue
-            merged = doc.model_dump(by_alias=True)
-            merged.update(set_ops)
-            merged["_id"] = doc.id
-            post_doc = UrlV2Doc.from_mongo(merged)
-            await self._events.emit(
-                DomainEvent(
-                    type="link.updated",
-                    owner_id=owner,
-                    data={
-                        "link": link_snapshot(post_doc),
-                        "changes": event_changes(doc, set_ops),
-                    },
-                )
-            )
+        events = []
+        for doc in docs:
+            owner = link_owner_id(doc)
+            if owner is None:
+                continue
+            merged = doc.model_dump(by_alias=True)
+            merged.update(set_ops)
+            merged["_id"] = doc.id
+            post_doc = UrlV2Doc.from_mongo(merged)
+            events.append(
+                DomainEvent(
+                    type="link.updated",
+                    owner_id=owner,
+                    data={
+                        "link": link_snapshot(post_doc),
+                        "changes": event_changes(doc, set_ops),
+                    },
+                )
+            )
+        await asyncio.gather(*(self._events.emit(e) for e in events))
🤖 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 `@services/bulk_url_service.py` around lines 621 - 655, The _emit_updated and
_emit_deleted methods currently await each event emission sequentially,
increasing latency for large batches. Build the per-item DomainEvent emissions
in each method and await them concurrently, using the existing self._events.emit
calls while preserving owner filtering, event payloads, and empty-batch
behavior.
🤖 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 `@config.py`:
- Around line 646-647: Update the webhook initialization path around
AppSettings.secret_key and WebhookSettings so startup fails when webhooks are
enabled and the resolved master secret is empty. Validate SECRET_KEY or its
configured fallback before creating WebhookSettings, while preserving normal
initialization when a non-empty secret is available.

In `@dependencies/wiring.py`:
- Around line 240-251: Update the Webhooks startup wiring around
DeliveryExecutor to detect an empty settings.secret_key when Webhooks are
enabled and emit a warning consistent with the existing session/JWT startup
checks. Reuse the established warning/logging mechanism and ensure the check
occurs during application startup before WebhookService or DeliveryExecutor uses
the key.
- Around line 218-281: In the webhook startup wiring around
queue_redis_for_webhooks and wh_settings.enabled, add a log.warning when queue
Redis is configured, webhooks are enabled, and settings.click_events.sink is
"inline", clearly indicating that click webhooks may be silent. Keep the
existing sink selection unchanged unless wiring the click-stream webhook path is
already supported by the surrounding code.

In `@infrastructure/safe_fetch.py`:
- Around line 258-264: Update the URL parsing and DNS resolution try block in
post_public() to also catch FetchTransientError from _resolve_public_ip() and
return it as a PostResult delivery failure, preserving the exception message and
existing result shape so transient failures are recorded and rescheduled.

In `@repositories/webhook_delivery_repository.py`:
- Around line 39-42: Replace the separate count_pending() admission flow with an
atomic per-endpoint pending reservation that increments only when the configured
capacity remains available, and make dispatch proceed only when that conditional
update succeeds. Release the reservation whenever a delivery transitions out of
pending, including successful, failed, and canceled paths, while keeping the
reservation consistent with existing delivery state.
- Around line 125-143: Update mark_failed to increment attempt_count in the same
atomic update that appends the DeliveryAttempt, keeping the stored count
consistent with the newly added terminal failure attempt.

In `@routes/api_v1/webhooks.py`:
- Line 122: Update the webhook route handlers in routes/api_v1/webhooks.py so
every non-catalog operation—not just creation—calls
flag_svc.require(WEBHOOKS_FLAG, user) before proceeding, including list,
modification, test, and manual retry endpoints. Preserve catalog operations’
existing behavior and ensure gated operations cannot initiate outbound
deliveries without entitlement.
- Around line 57-60: Update the ReadUser dependency to use
require_scopes_verified with WEBHOOKS_READ_SCOPES, matching ManageUser’s
verified-email enforcement while preserving the existing read scope requirements
for webhook list, detail, and delivery-log endpoints.

In `@schemas/dto/requests/webhook.py`:
- Around line 54-57: Update the PATCH request model’s description field to
enforce the same 256-character maximum as the create model, using the existing
Field validation pattern in the surrounding webhook request fields. Keep the
field optional and preserve its current default behavior.

In `@services/events/sinks.py`:
- Around line 71-87: Remove the unsupported ref_policy="ACKED" argument from the
Redis xadd call in emit, preserving the existing stream write and fallback
behavior. Do not attempt to retain ACKED trimming until the redis-py dependency
supports that keyword.

In `@services/webhooks/consumers.py`:
- Around line 42-51: The stream consumer methods WebhookClickConsumer.consume
and WebhookDomainConsumer.consume allow dispatcher failures to escape uncaught.
Wrap each await self._dispatcher.dispatch(event) call in try/except matching
WebhookFanoutClickSink.emit’s containment and logging behavior, so dispatch
errors are logged and handled deliberately without propagating from the
subscriber.

In `@services/webhooks/executor.py`:
- Around line 105-109: Update WebhookExecutor.attempt so deliveries for paused
or otherwise temporarily non-active endpoints remain pending and are retried
after the endpoint resumes, rather than being marked failed as
endpoint_inactive; preserve failure handling for endpoints that no longer exist
if required by the existing flow. Adjust the corresponding unit test in
test_executor.py to assert the delivery remains pending under a paused endpoint.

---

Nitpick comments:
In `@app.py`:
- Around line 198-214: Bound the shutdown wait for webhook_executor_task in the
application lifespan cleanup: after cancelling the task, await it with the
existing asyncio timeout mechanism and preserve suppression of normal
cancellation, while ensuring an overlong shutdown does not block deployment
termination. Keep the existing embedded executor startup and cancellation flow
unchanged.

In `@axiom/dashboards/spoo-webhooks.json`:
- Around line 27-106: Apply the same is_test exclusion used by stat-delivered to
the queries for stat-delivery-success-rate, stat-p95-delivery,
ts-dispatch-by-type, ts-delivery-outcomes, ts-attempt-failures-by-status,
table-slowest-endpoints, and table-failing-endpoints. Add the filter so test
pings are excluded while preserving each chart’s existing event conditions and
aggregations.

In `@services/bulk_url_service.py`:
- Around line 621-655: The _emit_updated and _emit_deleted methods currently
await each event emission sequentially, increasing latency for large batches.
Build the per-item DomainEvent emissions in each method and await them
concurrently, using the existing self._events.emit calls while preserving owner
filtering, event payloads, and empty-batch behavior.

In `@services/webhooks/signing.py`:
- Around line 1-10: Update the module docstring’s secret-storage description to
state that the signing secret is stored encrypted/ciphertext and decrypted by
the delivery executor when signing webhooks. Keep the existing format, one-time
display, and retry timestamp behavior unchanged.

In `@tests/integration/api_v1/test_webhooks.py`:
- Around line 55-140: Add an integration test using two users created via
_make_user(), with the first user creating an endpoint and the second attempting
GET, PATCH, DELETE, test-send, and list-deliveries operations against the first
user’s endpoint ID. Assert each cross-tenant request returns 404, preserving the
existing endpoint setup and authentication helpers.

In `@tests/unit/repositories/test_indexes.py`:
- Around line 30-32: Extend the index-creation assertions for
webhook_endpoints_col to verify its create_index calls, matching the existing
assertions for webhook_events_col and webhook_deliveries_col. Pin both the
ix_matcher index and the compound user_id-plus-status index, including their
configured fields and relevant options.

In `@workers/click_worker.py`:
- Around line 162-208: Consolidate GeoIP initialization in _build_runtime by
constructing a single GeoIPService before the webhooks and stats consumer setup.
Reuse that shared instance for WebhookClickConsumer and the stats consumer,
removing the separate webhook_geoip and geoip constructions while preserving
their existing configuration arguments.
🪄 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: 241ebd59-f517-429f-8489-7f62fa5b3571

📥 Commits

Reviewing files that changed from the base of the PR and between ec280b5 and dcb21b3.

📒 Files selected for processing (55)
  • .env.example
  • app.py
  • axiom/dashboards/spoo-webhooks.json
  • config.py
  • dependencies/__init__.py
  • dependencies/auth.py
  • dependencies/services.py
  • dependencies/wiring.py
  • infrastructure/crypto.py
  • infrastructure/safe_fetch.py
  • middleware/rate_limiter.py
  • repositories/indexes.py
  • repositories/webhook_delivery_repository.py
  • repositories/webhook_endpoint_repository.py
  • repositories/webhook_event_repository.py
  • routes/api_v1/__init__.py
  • routes/api_v1/webhooks.py
  • schemas/dto/requests/api_key.py
  • schemas/dto/requests/webhook.py
  • schemas/dto/responses/webhook.py
  • schemas/enums/webhook.py
  • schemas/models/webhook.py
  • services/bulk_url_service.py
  • services/click/handlers.py
  • services/events/__init__.py
  • services/events/contract.py
  • services/events/protocol.py
  • services/events/sinks.py
  • services/feature_flag_service.py
  • services/url_service.py
  • services/webhooks/__init__.py
  • services/webhooks/consumers.py
  • services/webhooks/dispatcher.py
  • services/webhooks/executor.py
  • services/webhooks/matcher.py
  • services/webhooks/payloads.py
  • services/webhooks/registry.py
  • services/webhooks/renderers/__init__.py
  • services/webhooks/renderers/protocol.py
  • services/webhooks/renderers/raw.py
  • services/webhooks/service.py
  • services/webhooks/signing.py
  • shared/scopes.py
  • tests/integration/api_v1/test_webhooks.py
  • tests/smoke/test_click_sink_wiring.py
  • tests/smoke/test_custom_domain_cf_wiring.py
  • tests/unit/repositories/test_indexes.py
  • tests/unit/services/test_bulk_url_service.py
  • tests/unit/services/webhooks/__init__.py
  • tests/unit/services/webhooks/test_executor.py
  • tests/unit/services/webhooks/test_matcher_dispatcher.py
  • tests/unit/services/webhooks/test_payloads.py
  • tests/unit/services/webhooks/test_registry.py
  • tests/unit/services/webhooks/test_signing.py
  • workers/click_worker.py

Comment thread config.py
Comment thread dependencies/wiring.py
Comment thread dependencies/wiring.py
Comment thread infrastructure/safe_fetch.py
Comment thread repositories/indexes.py
Comment thread routes/api_v1/webhooks.py
Comment thread schemas/dto/requests/webhook.py
Comment thread services/events/sinks.py
Comment thread services/webhooks/consumers.py
Comment thread services/webhooks/executor.py

@Zingzy Zingzy left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Verdict: mergeable, and merging is safe — but nothing here should be enabled for a real user until the tier-1 list below is closed. This is a genuinely strong piece of architecture: WEBHOOKS_ENABLED defaults false → NullDomainEventSink, producers stay unconditional, and I verified the production redirect path (stream rung) is byte-for-byte unchanged — webhook work lives in the worker, never in the GET response. So the risk gate is flag-enable time, not merge. I ran the full webhooks suite (65 pass) plus five adversarial deep-dives (SSRF, crypto, executor concurrency, hot-path cost, authz); the findings are what to finish before you seed the first webhooks flag, framed that way throughout.

Architecture read — the load-bearing decisions are right

  • The services/events/ backbone as a separate package with webhooks as one consumer is the correct seam: alerts/audit slot in as sibling consumer groups without touching producers. Not speculative — it's already carrying two feeds (clicks + domain) in this PR.
  • Feature-off is genuinely free, and I checked rather than trusted: NullSink emit is a no-op, no worker groups mount, the redirect path's click sink is only wrapped on the inline (no-queue-Redis) rung. In prod with queue Redis, GET /{code} is untouched.
  • Mongo-only executor, embedded OR worker, atomic lease-claim (find_one_and_update on ix_claim) — elegant, needs no new infra, self-heals on restart, and N concurrent executors are safe. The claim/backoff/render-once/crash-window semantics are all correct (verified line by line).
  • Signing is spec-correct — the published Standard Webhooks reference vector is pinned and passes; secret storage is authenticated AES-GCM with a random per-encryption nonce; the secret is returned exactly once and never logged. Confidentiality and integrity hold.
  • Registry-as-code driving validation + catalog + samples + test sends + wildcard expansion (can't drift), with disciplined event governance (causer-not-field; status_changed/bot.detected cut with reasons). Payload sanitization holds across every event type — no creation_ip, no owner_id, no password hash; the earlier updated_ip leak fix survives.
  • IDOR: clean. Every reachable by-id path enforces ownership in the Mongo query. Test send / delivery log / manual retry is Stripe-grade DX.

Tier 1 — close before enabling the flag (correctness/security that will bite the first real subscriber or the operator)

1. SECRET_KEY rotation turns every endpoint into an immortal poison delivery (live). _headers calls decrypt_secret unguarded (executor.py:223). Rotating SECRET_KEY re-keys _derive_aes_key, so GCM auth fails with InvalidTag on every stored secret. That throws before any attempt is recorded → the loop's generic except logs webhook_executor_tick_failed and sleeps → the 60s lease expires → the same row is re-claimed → throws again, forever. The endpoint never disables and never exhausts. SECRET_KEY rotation is a routine security op (it's how JWTs get invalidated in this very ecosystem), and it silently converts webhooks into an infinite retry storm with no operator signal beyond a log line. Wrap decrypt: on failure, mark the delivery terminally failed and disable the endpoint with a distinct reason, so rotation degrades loudly and recoverably.

2. dropped_since_last is dead code — the flagship event loses data silently. make_delivery_row hardcodes dropped_since_last: 0 (dispatcher.py:41); record_success drains the counter and returns it, but the caller discards the return (executor.py:138); nothing ever reads endpoint.dropped_count when building a row. So the pending-cap drop signal increments a counter that's zeroed on the next success and never reaches a consumer. For link.clicked feeding an analytics pipeline — the whole "turn spoo into infrastructure" use case — a subscriber who falls behind the 1000-cap loses clicks with zero indication, and the mechanism designed to tell them (D13) is unwired. Wire it (carry the endpoint's current dropped_count onto the next row and reset on that row's success) or cut it and log the drop loudly. The dropped_since_last=42 test builds a state the pipeline can never produce, so it green-lights the dead path.

3. Test-send and manual-retry mutate real endpoint health and enroll in the background ladder. is_test is used only for logging (executor.py:147); nothing guards the health counters or the reschedule. Consequences: a transient-failed test send calls record_attempt_and_reschedule, setting next_attempt_at → the row becomes claimable and silently becomes a 10-hour background delivery (contradicting the "only executor this row meets" contract); exhausted test sends call record_exhausted, so a user clicking "test" on a broken endpoint ~10 times auto-disables their own endpoint; a passing test calls record_success, resetting a real failure streak and masking a failing endpoint. Test/retry should be single-shot and must not touch consecutive_failures/total_* or the retry schedule.

Tier 2 — hardening before GA (abuse/reputation/trust, given spoo's phishing-flag history)

4. Endpoints auto-disable silently. The on_disabled hook is None in both wiring sites, so an endpoint dying after 10 failures notifies the user only via a log event. For infrastructure positioning, a user's integration going dark with no email is a trust-breaker — wire the notification before GA.

5. Abuse/reputation cluster — decide the kill-switch. POST /{id}/test runs the real pipeline synchronously and returns status_code + 256-byte response_body + latency for any user-supplied public HTTPS URL. That's an authenticated "fetch the first 256 bytes + status + timing of any HTTPS host, from spoo's egress IP" oracle/amplifier (~600 probes/hr/credential). Internal SSRF is blocked (the guard is tight — see below), so this is external-only, but it's still spoo's IP hitting third parties on demand. Compounding it: per-click fanout POSTs to user URLs make spoo a potential reflector, there's no per-user delivery-rate cap (only a 5-endpoint count cap), and flipping the webhooks flag off does not stop /test or existing deliveries — only CREATE is gated. So the flag is an onboarding gate, not an abuse lever. Decide the actual abuse response (per-endpoint disable is the only current one) and consider a global delivery-rate cap.

6. The delivery-time SSRF guard has zero direct tests. The guard itself is solid — I tried hard: https-only + resolve-then-pin-to-IP + no-redirects blocks loopback/RFC1918/link-local/CGNAT/metadata-IP/userinfo/DNS-rebind/redirect-to-Redis. But post_public has no direct test asserting it rejects http, rejects a private IP, or refuses redirects — test_executor and test_webhooks patch it out entirely. A future refactor silently regresses SSRF. Add the tests. One real gap in the guard: 64:ff9b::/96 (NAT64) returns is_global=True, so https://[64:ff9b::7f00:1]/ passes _is_public (safe_fetch.py:69) and on a NAT64/DNS64 host routes to an internal HTTPS service — reject the prefix explicitly.

7. Bulk ops add per-item awaited work even when the feature is off. Bulk delete/update emit one event per item in an awaited loop, each doing a UrlV2Doc.from_mongo re-parse (bulk_url_service.py:634) with no enabled guard, plus a sequential XADD per item in stream mode. A 1000-link bulk op adds ~1000 re-parses (feature off) or ~1000 serial XADDs (on) to that request. Batch the writes and guard the re-parse behind the sink being non-null.

Nits

  • Signing docstring lies about the security model. signing.py:6 says the secret is "stored hash-only (SHA-256)" — it's AES-GCM encrypted (it has to be; the server signs with it). hash_signing_secret is dead in production (test-only). Fix the docstring and drop the function — a maintainer trusting it would assume secrets are irreversible at rest.
  • Rotation scaffolding is inert. previous_secret_enc/previous_secret_expires_at and the dual-signing branch exist but no route ever writes them, so rotation grace can't happen. Fine as a Phase-2 seam — but note that when you do wire rotation, the grace-window comparison at executor.py:229 compares a naive Mongo datetime against an aware now() and will TypeError (the loop-poisoning kind); use as_aware_utc() (the repo already has it). Cheap to fix now while it's dead.
  • Pending-cap and quota are both racy (count-then-write): concurrent dispatch can breach the 1000 cap, concurrent creates can exceed 5 endpoints. The quota race mirrors the existing api-key pattern, so it's a consistent stance, not a regression — noting for completeness.
  • update_fields/list_by_endpoint enforce ownership via a preceding fetch, not in their own filter — safe today, but a latent regression trap; fold user_id into the query like delete_endpoint does.

Product sense

The catalog scope is right: five events (created/updated/deleted/clicked/expired), fat snapshots so consumers don't need a follow-up call, additive-only contract, raw-flavor first. link.clicked is correctly the flagship — it's what makes real-time analytics/Zapier pipelines possible — which is exactly why Tier-1 #2 (silent drop, dead drop-signal) matters most: the highest-value event is the one that loses data quietly under load. Deferring Discord/Slack flavors to Phase 2 is defensible, though "point a webhook at your Discord" is the single most-requested easy win for this indie audience and the scaffolding's already there. The debuggability triad (test send + full delivery log with rendered body + manual retry) is the right DX bet and genuinely best-in-class — it just needs Tier-1 #3 fixed so the test button can't disable the thing it's testing. Net: the product thesis is sound and the surface is well-chosen; the gap is reliability-under-load and silent-failure trust, which is precisely what an "infrastructure" positioning is judged on.

Merge-safety summary

Merge is safe: dark by default, hot path verified untouched, CI green across the matrix, feature-off cost negligible. Nothing above blocks the merge. Tier 1 blocks enabling the flag for a real user (data loss + operator-facing crash loop + self-disabling test button); Tier 2 blocks GA (silent disable, abuse levers, SSRF test coverage). Sequence the tail as: land the events backbone + management API now (dark) → close Tier 1 → seed your own flag and dogfood link.clicked at volume → wire disable-email + abuse cap → open the flag.

Comment thread services/webhooks/executor.py
Comment thread services/webhooks/dispatcher.py Outdated
Comment thread services/webhooks/executor.py
Comment thread infrastructure/safe_fetch.py
Comment thread services/webhooks/executor.py Outdated
Secret-rotation failures now terminate the row and disable the endpoint
with a distinct reason instead of livelocking the claim loop. Test sends
and manual retries are single-shot: no ladder, no health mutation.
dropped_since_last is wired end to end (dispatch carries the endpoint
counter, success drains it). Paused endpoints defer deliveries instead
of failing them. NAT64 literals rejected, transient DNS failures become
recorded outcomes, and post_public gains direct SSRF tests. Startup
refuses WEBHOOKS_ENABLED without SECRET_KEY, click fanout wraps whenever
clicks track inline, reads require verified email, and test/retry carry
the feature-flag gate.
@Zingzy

Zingzy commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

Review batch addressed in ed562e6. Disposition per finding:

Fixed

  • Startup now raises when WEBHOOKS_ENABLED=true with an empty SECRET_KEY (hard fail rather than the suggested warning: a predictable encryption key is not a degraded mode worth booting into). Test added.
  • post_public converts transient DNS failures (and invalid URLs) into delivery outcomes, so they land in the retry ladder as recorded attempts instead of escaping as exceptions.
  • TTL index recreation guards the concurrent drop_index race (code 27 tolerated), matching the existing alias_1 guard.
  • mark_failed increments attempt_count alongside the appended attempt.
  • Read endpoints now require a verified email, matching the manage side.
  • Test sends and manual retries carry the feature-flag gate, since both initiate outbound calls. List/get/patch/delete deliberately stay ungated: a user who loses the flag keeps managing and pausing what they already created, and creation was always gated. The module docstring now states this split.
  • UpdateWebhookEndpointRequest.description enforces the 256-char bound.
  • The queue-Redis-present + CLICK_EVENTS_SINK=inline combination no longer leaves click webhooks silent: the fanout wrapper now keys on the click sink being inline rather than on the domain sink, so that topology works instead of warning.
  • Bounded the embedded executor shutdown wait (10s).
  • Paused endpoints now defer pending deliveries (recheck in 5 minutes, no attempt recorded) instead of terminally failing them; disabled and deleted endpoints stay terminal. Tests updated.

Not changed, with reasons

  • ref_policy on xadd: the lockfile pins redis-py 7.4.1, which supports the kwarg (verified against the installed signature), and the same call has been shipping in the click pipeline. The 6.2.0 premise does not match this repository.
  • Pending-cap atomicity: count-then-insert can overshoot by one concurrent batch, and that is acceptable for a protective backstop. An exact cap would add a reservation counter write to every dispatch on the hot consumer path; the cap bounds pathology, it is not an SLA. Now documented at the call site.
  • Exception containment in the stream consumers: deliberate. An escaping exception leaves the message pending, which is the designed at-least-once path: the claimer retries it and the DLQ guard bounds poison. Swallowing a transient Mongo error there would silently lose delivery intent. The fanout sink swallows because the redirect path must never fail; the consumers must not, because redelivery is exactly what we want.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
services/webhooks/signing.py (1)

54-64: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Enforce timestamp freshness in verify().

verify() only validates the signature, so it accepts any valid signature for an arbitrarily old webhook-timestamp, opening replay and old-delivery failures. Move to Standard Webhooks’ timestamp-tolerance check here or require every documented consumer example/docs caller to reject timestamps outside a small window before processing the event.

🤖 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 `@services/webhooks/signing.py` around lines 54 - 64, Update verify() to
enforce Standard Webhooks timestamp freshness before accepting any signature,
rejecting timestamps outside the configured small tolerance window while
preserving constant-time matching and support for multiple rotation signatures.
Ensure documented consumer examples and callers use this verification behavior
rather than bypassing the freshness check.
🤖 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.

Outside diff comments:
In `@services/webhooks/signing.py`:
- Around line 54-64: Update verify() to enforce Standard Webhooks timestamp
freshness before accepting any signature, rejecting timestamps outside the
configured small tolerance window while preserving constant-time matching and
support for multiple rotation signatures. Ensure documented consumer examples
and callers use this verification behavior rather than bypassing the freshness
check.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cda45660-d79e-481e-83da-355eb867c8f8

📥 Commits

Reviewing files that changed from the base of the PR and between dcb21b3 and ed562e6.

📒 Files selected for processing (18)
  • app.py
  • config.py
  • dependencies/wiring.py
  • infrastructure/safe_fetch.py
  • repositories/indexes.py
  • repositories/webhook_delivery_repository.py
  • routes/api_v1/webhooks.py
  • schemas/dto/requests/webhook.py
  • schemas/enums/webhook.py
  • services/webhooks/dispatcher.py
  • services/webhooks/executor.py
  • services/webhooks/signing.py
  • tests/integration/api_v1/test_webhooks.py
  • tests/unit/infrastructure/test_safe_fetch_post.py
  • tests/unit/services/webhooks/test_executor.py
  • tests/unit/services/webhooks/test_matcher_dispatcher.py
  • tests/unit/services/webhooks/test_signing.py
  • tests/unit/test_config.py
💤 Files with no reviewable changes (1)
  • tests/unit/services/webhooks/test_signing.py
🚧 Files skipped from review as they are similar to previous changes (13)
  • services/webhooks/dispatcher.py
  • schemas/enums/webhook.py
  • config.py
  • app.py
  • schemas/dto/requests/webhook.py
  • infrastructure/safe_fetch.py
  • tests/unit/services/webhooks/test_matcher_dispatcher.py
  • repositories/webhook_delivery_repository.py
  • dependencies/wiring.py
  • routes/api_v1/webhooks.py
  • repositories/indexes.py
  • services/webhooks/executor.py
  • tests/integration/api_v1/test_webhooks.py

@Zingzy Zingzy left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Re-reviewed ed562e6 against the head I first reviewed (dcb21b3) — I read the delta and re-verified each finding at the choke point rather than trusting the changelog, and ran the affected suite (123 pass, incl. the new direct post_public SSRF tests and config-guard tests). All three Tier-1 items are genuinely closed, and several Tier-2/nit items came with them. My original findings stand as having been real; nothing I flagged turned out to be a false alarm, and every fix lands where the defect was.

Tier 1 — verified fixed:

  • Decrypt crash-loop: _headers is now wrapped (executor.py); a decrypt failure marks the delivery secret_unreadable and disables the endpoint with the new SECRET_UNREADABLE reason (single-shot rows skip the disable). The infinite reclaim livelock is dead — a SECRET_KEY rotation now degrades loudly and recoverably. Confirmed the exception can no longer escape before an attempt is recorded.
  • dropped_since_last: make_delivery_row now snapshots endpoint.dropped_count onto the row, the renderer surfaces it, record_success drains it. The drop signal reaches the subscriber. The at-least-once repeat-until-landed behavior you noted in the comment is the right tradeoff (consumers dedup on webhook-id, the number is informational).
  • Test/retry single-shot: single_shot = is_test or status != PENDING cleanly excludes them from record_success/record_exhausted/the retry ladder and 410-disable. A failing test can no longer auto-disable a real endpoint and a passing one can no longer mask a failure streak. Bonus: PAUSED now defers (5-min recheck) instead of terminally failing the queued delivery — that also closes the pause-destroys-deliveries semantic.

Also closed: NAT64 64:ff9b::/96 rejected outright in _is_public (rejecting the whole translation prefix is cleaner than unwrap-and-recheck — agreed); post_public now catches FetchTransientError/InvalidURL → returns data not an exception, so a slow DNS name can't leave a row leased; config hard-fails WEBHOOKS_ENABLED without SECRET_KEY; the grace-window comparison goes through as_aware_utc so Phase-2 rotation can't poison; signing docstring corrected to AES-GCM and dead hash_signing_secret removed; mark_failed now $incs attempt_count; reads require verified email. And flag-gating test+retry is the right call — it means flipping the flag off now stops the on-demand outbound amplifier, which was the sharp half of the abuse concern; keeping passive management ungated is a coherent doctrine.

The two you skipped with reasons — I agree with both: the pending-cap count-then-insert is a protective backstop, not an SLA, and a one-batch overshoot doesn't justify a reservation-counter write on every dispatch. And letting a dispatch exception escape the stream consumer is correct — it leaves the message unacked for the claimer/DLQ at-least-once path; the asymmetry with the inline sink (which swallows, because there's no message to leave pending) is right, not an oversight.

Merge: clear. Nothing outstanding blocks it, and it's still dark by default.

Before GA (the flag-enable tail, all explicitly deferred and fine to carry): wire the on-disabled email (→ the notifications framework) so endpoints don't die silently; add a per-user delivery-rate cap (the endpoint-count cap doesn't bound the reflector surface once link.clicked subscribers exist); and decide whether /test should keep echoing the 256-byte response body (it's a content-peek of any public HTTPS URL from spoo's egress, now gated behind the flag + 10/min, so bounded — but worth a conscious call at GA). None of these gate the merge or the first dogfood.

Good, fast turnaround — the fixes are precise and the reasoning on the two declines is sound.

Zingzy added 3 commits July 23, 2026 15:06
build_link_clicked called the async get_country_code without awaiting
it, leaking a coroutine into the payload for any non-geo click whose
country had to be resolved from the mmdb. Made the builder async and
awaited the call; both callers (the stream consumer and the inline
fanout sink) already run in async context. A load test through the real
worker surfaced it — unit tests dispatched pre-built events and missed
the geoip branch.
Endpoints can now point straight at a Discord or Slack incoming
webhook. A shared copy layer picks wording and density per event
(compact for clicks, field grids for lifecycle, a colored diff block
for link.updated on Discord); unknown event types degrade to a JSON
code block so future events never terminal-fail flavored endpoints.
Rate limiting is receiver flow control, not endpoint failure: honor
Retry-After (capped at 15m, 60s fallback) via the existing defer path,
leaving the retry ladder and the auto-disable streak untouched. Test
sends still report a terminal outcome synchronously.
Zingzy added 2 commits July 24, 2026 16:44
Clicks carry an inline field grid with guaranteed substance: Device
always, From reading direct when there is no referrer, plus Location,
UTM, Bot and Clicks when present. Lifecycle cards state never and
unlimited as real answers, and deleted links report lifetime clicks
and age. Long values like destinations stay full width.
Secrets are stored encrypted, never hashed, since the executor reads
them back to sign. Owners can now fetch the full secret from the
endpoint instead of copying it exactly once at creation. Unreadable
secrets (rotated master key) report cleanly instead of raising.
Zingzy added 5 commits July 24, 2026 17:17
An endpoint with two deliveries mid-retry showed delivered 0 of 0,
contradicting its own delivery log. total_deliveries now increments
when the row is enqueued, so the ratio reads 0 of 2 while pending and
2 of 2 once landed. Test sends stay out of the counters.
Every click dimension gets its own field in a two-column grid closed
by spacer fields, country codes carry flag emoji, and the running
click count always renders. Created cards state the full link
configuration including password, bot blocking, meta tags and geo
rules. Messages post under the spoo.me name and avatar, the wire
timestamp now carries its UTC offset so Discord localizes it, and
the middle-dot separator is gone from embeds.
The discord flavor now renders a components-only message: an accent
container with a heading, divider, grouped substance lines and a
small-text footer whose timestamp Discord localizes per viewer.
Renderers can declare query params the executor appends at delivery,
which carries the with_components switch. Mentions are hard-disabled
since text displays can ping and payload values are user-controlled.
…veal to sessions

Payload values reaching components-v2 text displays are now
backslash-escaped, so a crafted Referer header cannot plant a masked
link or any live markup in the subscriber's channel; masked-link
targets also encode parentheses. The secret reveal endpoint requires
an interactive session, matching how key creation refuses delegated
credentials.
feat(webhooks): discord and slack delivery flavors
@Zingzy
Zingzy merged commit c838398 into main Jul 24, 2026
12 checks passed
@Zingzy
Zingzy deleted the feat/webhooks-system branch July 24, 2026 16:36
@github-project-automation github-project-automation Bot moved this from 📋 Planning Stage to ✔️ Done in spoo.me Development Roadmap Jul 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Changes related to Backand/API Feature 🌟

Projects

Status: ✔️ Done

Development

Successfully merging this pull request may close these issues.

[FEAT] Webhooks: real-time event deliveries for links

2 participants