Files
webhooks/docs/architecture.md
Maxence MaireauxandGitHub 6504cd9692 fix: prevent webhook message loss, remove panic on unknown filter, stop logging secrets (#142)
* fix: resolve P0/P1 reliability and security issues

- P0: Process messages synchronously to prevent data loss on crash
  The Watermill handler now blocks until webhook delivery completes.
  Messages are only acknowledged after processing, not before.
  Errors on invalid payloads are returned (nack) instead of swallowed.
  Per-config failures use continue instead of return, so one bad
  endpoint no longer blocks delivery to other matching configs.

- P1: Replace panic with error on unknown filter key
  FindManyConfigs now returns an error instead of panicking when
  called with an unrecognized filter key. A single bad API request
  no longer crashes the process.

- P1: Remove environment and secret logging
  Removed os.Environ() dumps from server and worker startup.
  Replaced config %+v logging with safe ID/endpoint-only logging.
  Removed event-payload from OpenTelemetry span attributes.

- Docs: Add architecture, message-processing, and security docs.
  Fix CTE typo in retry-mechanism.md.

No breaking changes to public API, Store interface, or CLI flags.

* chore: add CODEOWNERS assigning @formancehq/backend as default reviewers

* fix: remove dead code flagged by ineffassign linter

* fix: address CodeRabbit review feedback

- Return error (nack) when InsertOneAttempt fails for non-success attempts,
  preventing silent loss of retry records
- Rewrite TestProcessMessages_SynchronousProcessing to be deterministic:
  HTTP handler blocks on a channel, test proves handler is blocked until
  the HTTP response completes
- Add TestProcessMessages_NackOnInsertFailureForRetry for the new nack path
- Expand secret logging test patterns to cover %v and more variable names
- Rename TestConfigStringerDoesNotLeakSecret to TestDocument_PercentPlusVLeaksSecrets
- Fix retry-mechanism.md claim query priority documentation: DISTINCT ON
  does per-webhook ordering, not global priority

* fix: propagate errors to span, avoid err shadowing in defer

- Rename loop-scoped err to attemptErr/insertErr so the defer
  closure captures the correct error for span.RecordError()
- Add explicit span.RecordError() on MakeAttempt and InsertOneAttempt
  failures that previously went unrecorded in traces
- Assign err before return on nack path so the defer sees it
- Update Go version in architecture doc to 1.25+ (matches go.mod)

Addresses review comments from paul-nicolas on PR #142.

* fix: handle transport errors in MakeAttempt as retryable attempts

- MakeAttempt now returns a retryable Attempt (instead of a bare error)
  when httpClient.Do fails due to transport/timeout, preventing event
  loss since the attempt record is persisted for the retry loop
- Replace base64-looking test secret with plain dummy to avoid
  triggering secret scanners in CI

Addresses CodeRabbit review comments on PR #142.
2026-04-08 15:16:21 +02:00

5.7 KiB

Architecture

Overview

The webhooks service delivers event notifications to user-configured HTTP endpoints. It consists of two main components that can run independently or together:

  • Server — REST API for managing webhook configurations (CRUD, activation, secret management)
  • Worker — Background service that consumes events from a message broker (Kafka/NATS) and delivers webhooks, with automatic retry on failure

Components

┌─────────────────┐      ┌──────────────────┐      ┌──────────────────┐
│  Kafka / NATS   │─────▶│     Worker       │─────▶│  User Endpoints  │
│  (event source) │      │  (consumer +     │      │  (webhook targets)│
└─────────────────┘      │   retrier)       │      └──────────────────┘
                         └────────┬─────────┘
                                  │
                         ┌────────▼─────────┐
┌─────────────────┐      │                  │
│   API Clients   │─────▶│    PostgreSQL    │
│                 │◀─────│    (configs +    │
└─────────────────┘      │     attempts)   │
         ▲               └──────────────────┘
         │
┌────────┴─────────┐
│     Server       │
│  (REST API)      │
└──────────────────┘

Server

The server exposes a REST API (see openapi.yaml) with these endpoints:

Method Path Description
GET /configs List webhook configs (filterable by id, endpoint)
POST /configs Create a new webhook config
PUT /configs/{id} Update a webhook config
DELETE /configs/{id} Delete a webhook config
PUT /configs/{id}/activate Activate a config
PUT /configs/{id}/deactivate Deactivate a config
PUT /configs/{id}/secret/change Rotate the signing secret
GET /configs/{id}/test Send a test webhook
GET /_healthcheck Health check
GET /_info Service version info

Authentication is handled via OAuth2 client credentials (configurable via --auth-* flags).

Worker

The worker has two responsibilities:

  1. Event consumption — Subscribes to configured Kafka/NATS topics via Watermill. For each event, it finds matching active configs by event type and delivers the webhook synchronously. Messages are only acknowledged after processing completes — no data loss on crash.

  2. Retry loop — A background Retrier polls the database for failed attempts due for retry. Uses an atomic claim pattern for safe multi-worker scaling. See retry-mechanism.md for full details.

Data Model

Config — A webhook subscription:

  • id (UUID) — unique identifier
  • endpoint (URL) — where webhooks are sent
  • event_types (string array) — which event types trigger this webhook
  • secret (base64) — HMAC-SHA256 signing key (24 random bytes, base64-encoded)
  • active (boolean) — whether the config receives webhooks
  • name (string, optional) — human-readable label

Attempt — A single webhook delivery attempt:

  • id (UUID) — unique identifier
  • webhook_id (UUID) — groups all attempts for one event/config pair
  • config (JSONB) — snapshot of the config at delivery time
  • payload (string) — the event payload sent
  • status_code (int) — HTTP response status
  • status — one of: success, to retry, retrying, failed
  • retry_attempt (int) — attempt number (0 = first delivery)
  • next_retry_after (timestamp) — when this attempt can be retried

Webhook Delivery

Request Format

Each webhook is an HTTP POST with these headers:

Header Description
content-type application/json
user-agent formance-webhooks/v0
formance-webhook-id Unique webhook ID
formance-webhook-timestamp Unix timestamp of the delivery
formance-webhook-signature HMAC-SHA256 signature (v1,<base64>)
formance-webhook-test true if this is a test webhook
formance-webhook-idempotency-key Idempotency key (if present in the event)

Signature Verification

Signatures use HMAC-SHA256. The signed payload is:

{webhook_id}.{timestamp}.{body}

The signature header format is v1,<base64-encoded-hmac>. Recipients should:

  1. Extract the timestamp and signature from the headers
  2. Reconstruct the signed payload: {formance-webhook-id}.{formance-webhook-timestamp}.{raw-body}
  3. Compute HMAC-SHA256 with the shared secret
  4. Compare signatures using constant-time comparison

Response Handling

  • 2xx → success, no retry
  • Non-2xx → to retry, scheduled with exponential backoff
  • Max duration exceeded → failed, no more retries

Technology Stack

Component Technology
Language Go 1.25+
HTTP framework chi
Database PostgreSQL via bun ORM
Message broker Kafka or NATS via Watermill
Dependency injection uber/fx
CLI cobra
Observability OpenTelemetry (traces)
SDK Auto-generated Go client via Speakeasy