Implements Webhooks for Tuist Events as a first-class account resource. Owners register HTTPS endpoints from the dashboard, subscribe each one to the Tuist events it cares about, and Tuist takes care of signing, retries, and the per-delivery audit log.
What’s in this PR
Domain
Tuist.Webhooks.WebhookEndpoint schema with account_id, name, url, signing_secret, signing_secret_last_four, and event_types. Both url and signing_secret use Tuist.Vault.Binary so they’re Cloak-encrypted at rest on bytea columns; signing_secret_last_four mirrors the cleartext tail so the dashboard never decrypts the full key just to render the masked preview.
Tuist.Webhooks context: list_endpoints/1, list_endpoints_subscribed_to/2, get_account_endpoint/2, create_endpoint/2, update_endpoint/2, rotate_signing_secret/1, delete_endpoint/1, plus the delivery-side helpers list_deliveries/2, delivery_stats/2, deliveries_timeseries/2, get_delivery_attempt/2. All dashboard reads go through a narrow @dashboard_fields projection that omits signing_secret; only the delivery worker’s get_endpoint/1 keeps the full load.
- Event catalog:
test_case.created, test_case.updated, preview.created, preview.deleted. event_groups/0 returns them grouped by resource so the create modal can render a “Select all Test cases events” toggle next to the individual checkboxes.
Tuist.Webhooks.Dispatcher: call site for the rest of the codebase. dispatch_test_case_created/2, dispatch_test_case_event/3, dispatch_preview_created/1, dispatch_preview_deleted/1 — each one resolves the account, looks up subscribed endpoints, builds the envelope, and enqueues a delivery job per match. Single-endpoint enqueue failures are swallowed so one bad subscriber can’t block delivery to the others.
Signing + delivery
Tuist.Webhooks.Signature: HMAC-SHA256 helpers (sign/3, verify/4, generate_secret/0). Signed string is \"<unix_ts>.<raw_json>\", header is Tuist-Signature: t=<ts>,v1=<hex_sig>, with a 5-minute replay tolerance and constant-time comparison. New secrets are minted as tuist_webhook_<base64url(32 random bytes)> — matches the tuist_<scope>_<random> convention used by project tokens, SCIM tokens, and account tokens.
Tuist.Webhooks.Workers.DeliveryWorker: dedicated Oban worker on its own :webhooks queue (concurrency 20). max_attempts: 7 covers the initial send plus six retries on the 1m → 5m → 30m → 2h → 8h → 24h schedule. 10-second request timeout, redirects disabled (otherwise a 3xx to a private/metadata address would bypass the SSRF guard). Endpoint is re-read on every attempt so URL / secret edits and rotations take effect on the next retry; a deleted endpoint is :discard instead of retrying forever.
Tuist.OAuth2.SSRFGuard.pin/1 + connect_options/1 are applied before each request. Loopback, RFC1918, link-local, ULA, and cloud-metadata destinations are rejected before bytes leave the process, and the rejection is still recorded as a failed attempt so it shows up in the dashboard.
Delivery audit log (ClickHouse)
webhook_delivery_attempts lives in ClickHouse — append-only with bursty fan-out is exactly the workload CH’s MergeTree handles best, and the time-bucketed chart queries get dramatically faster than the equivalent PG aggregates would.
MergeTree engine, PARTITION BY toYYYYMM(inserted_at), ORDER BY (webhook_endpoint_id, inserted_at). Skip indexes on event_type and status accelerate the dashboard’s filter paths. Retention is unbounded, matching other customer-facing audit data (build_runs, test_case_runs, runner_jobs).
- Writes go through
Tuist.Ingestion.Bufferable — DeliveryAttempt.Buffer.insert/1 accumulates RowBinary in memory and flushes on size or interval, keeping the worker hot path async and avoiding “too many parts” at fan-out time.
- Each row captures
id, event_id, event_type, attempt, status (delivered/failed), the request body, request headers (JSON-encoded), response status / headers / body, error string, and duration_ms. Response bodies truncated at 64 KiB so a chatty upstream can’t bloat the row. response_status: 0 is the sentinel for “no HTTP response received” (network error, SSRF reject).
Dashboard
- Webhooks tab on the account settings: lists endpoints with name, URL, masked signing secret (
tuist_webhook_•••••XYZ suffix only, read from the cleartext signing_secret_last_four column), the count of subscribed events, and a kebab with Rotate secret / Delete endpoint. Whole row navigates to the detail page; a custom StopRowNavigate JS hook stops the kebab click from being captured by LiveView’s window-level bindNav (and replays the menu items’ phx-click so confirm dialogs and event dispatch still work from inside the row link).
- Create endpoint modal: name + HTTPS URL + grouped event checkboxes with per-group select-all. On submit the new signing secret is shown once via a
Clipboard hook; closing the modal removes it from the LiveView assigns. The form body lives in a shared TuistWeb.Components.WebhookEndpointForm component used by both the create and edit modals.
- Endpoint detail page:
- Summary card (URL, masked signing secret, subscribed events, created date) with
Edit endpoint and a kebab for Rotate secret / Delete endpoint.
- Event deliveries chart (ECharts line with Total + Failed series, interactive legend toggles, configurable date range).
- Events table with cursor pagination (Flop, page size 20), filterable by status, event type, and event-id search; rows navigate to per-event detail.
- Event detail page: Summary card (status, response code, attempt, duration, sent timestamp, destination), Request card with headers + pretty-printed JSON body, Response card with status / headers / body.
API docs
- Six new
OpenApiSpex schemas under TuistWeb.API.Schemas.Webhook (WebhookTestCase, WebhookPreview, plus a WebhookXxxEvent envelope per supported event type) registered into components.schemas via OpenApiSpex.add_schemas/2.
- A top-level
Webhook events OpenAPI tag carries a markdown description with a table of supported events, anchored to each event’s schema — Redoc renders it as a sidebar section on tuist.dev/api/docs. Description lives on the tag (not info.description) so Swift OpenAPI Generator doesn’t splat it onto the CLI’s Client.swift docblock.
dispatcher_test.exs casts every emitted payload against the matching schema so drift between the snapshot builders and the documented contract fails CI.
Integrations into existing flows
Tuist.Tests ingestion path calls Dispatcher.dispatch_test_case_created/2 for newly-observed test cases and dispatch_test_case_event/3 on flagged transitions (marked_flaky, muted, unskipped, …). Both fan-outs share the same "first run" filter as the first_run TestCaseEvent audit row, so the webhook and the audit log agree by construction.
Tuist.AppBuilds calls dispatch_preview_created/1 after a preview’s app build finishes uploading, and dispatch_preview_deleted/1 after a preview row is removed.
Ops / migrations
priv/repo/migrations/20260513120000_add_webhook_endpoints_table.exs — single consolidated PG migration that creates the table with the final shape (bytea-encrypted url + signing_secret, signing_secret_last_four, event_types, indexed on account_id).
priv/ingest_repo/migrations/20260515160000_create_webhook_delivery_attempts_table.exs — CH MergeTree with the partition / ordering described above.
priv/repo/seeds.exs adds two seeded endpoints (Notion automation, Slack relay) with 1500 attempts each on a 97/3 delivered/failed mix so the chart and table render with realistic data locally.
Docs + compliance
- New customer-facing guide at
/en/guides/integrations/webhooks (linked from the Integrations sidebar): event catalog, payload envelope, header reference, signature verification with a Node example (raw-body + constant-time compare), retry schedule, security notes. References the per-event OpenAPI schemas on tuist.dev/api/docs for the payload contract.
- Changelog entry
2026.05.15-webhooks.md with the endpoint detail screenshot as the hero image.
server/data-export.md documents both new tables. Endpoint URL and signing secret are listed as non-exportable bearer credentials (encrypted at rest, often contain path / query tokens). Delivery attempts are exportable; retained indefinitely on the CH side.
dashboard_account.pot extracted for the new strings; no .po changes (handled by tuistit).
Tests
Tuist.WebhooksTest — context-level CRUD, masking, time-series bucketing, rotation, secret encryption round-trip.
Tuist.Webhooks.SignatureTest — sign / verify, tolerance, tampered-payload rejection, malformed-header rejection.
Tuist.Webhooks.DispatcherTest — fan-out, account-scope isolation, no-op when there are zero subscribers, envelope shape per event type, payload-against-schema conformance for every event (catches drift between dispatcher and OpenAPI schema at CI time).
Tuist.Webhooks.Workers.DeliveryWorkerTest — 2xx → :delivered, 5xx → :failed + retry, transport error → :failed, SSRF rejection records an attempt without making the request, deleted endpoint discards.
TuistWeb.WebhooksLiveTest — index render, create flow with secret disclosure, rotate, delete.
TuistWeb.WebhookLiveTest — detail render, filters, chart data, event navigation, pagination.
How to test locally — end-to-end with a real receiver
The full path including SSRF guard, real HMAC, real Oban queue, real retry backoff — five minutes of setup.
1. Start a public tunnel in one terminal:
cloudflared tunnel --url http://localhost:4000
Copy the https://…trycloudflare.com URL it prints.
2. Register the endpoint. Sign in to the dev server (tuistrocks@tuist.dev / tuistrocks), open /<account>/webhooks, click Add endpoint, paste the cloudflared URL, tick preview.created (easiest to trigger), save. Copy the displayed tuist_webhook_… secret — you only see it once.
3. Start the receiver in a second terminal — a zero-deps Node script lives at /tmp/tuist-webhook-receiver.mjs (see the script body in commit history if you want it in-repo). It captures the raw body, verifies the Tuist-Signature header in constant time, and prints the decoded payload:
TUIST_WEBHOOK_SECRET='tuist_webhook_…' node /tmp/tuist-webhook-receiver.mjs
4. Fire an event from a project pointing at the dev server:
tuist share <scheme> # uploads a build → fires preview.created
5. Verify in the receiver terminal:
──────────────────────────────────────────
2026-05-18T09:04:09Z preview.created id=b0a1d0e6-…
✓ signature verified
{ \"id\": \"b0a1d0e6-…\", \"type\": \"preview.created\", \"created\": 1779…, \"object\": { … } }
The dashboard chart’s Total line ticks up and the events table gains a new row; clicking it shows the exact request body + headers + response we just round-tripped.
Failure paths worth testing:
- Kill the receiver mid-trigger → dashboard records a failed delivery, retry schedule (1m → 5m → …) kicks in; bring it back and watch the retry succeed.
- Tamper with the secret env var → receiver returns 401, dashboard records
failed with status 401.
- Register a
http://127.0.0.1:4000 URL instead of the tunnel → SSRFGuard rejects before sending, attempt recorded with blocked: private_ip_resolved.
Run the test suite
mix test test/tuist/webhooks_test.exs test/tuist/webhooks/ test/tuist_web/live/webhooks_live_test.exs test/tuist_web/live/webhook_live_test.exs
🤖 Generated with Claude Code