Development Preview · PR #2551 · 58d61b2 · built
Skip to content

Webhook Management

SynthOrg accepts inbound webhooks from external providers (GitHub, Stripe, Linear, etc.) at /webhooks/{connection}. Each receiver registers a connection record (transport, signing secret, replay window) and is handled by an integration-specific dispatcher that validates the typed envelope and routes the payload.

Envelope contract

Wire shape: any JSON object. Inbound bodies route through parse_typed("webhook.payload", body, WebhookEventPayload) which enforces:

  • Object root (arrays, scalars, non-JSON bodies are rejected with HTTP 400).
  • Arbitrary keys via ConfigDict(extra="allow") so provider-specific schemas flow through unchanged.

Details: docs/reference/typed-boundaries.md (webhook payload envelope section).

Configuration surface

Key Type Default Purpose
integrations.webhooks.enabled bool false Master switch.
integrations.webhooks.replay_window_seconds int 300 Reject nonces older than this.
integrations.webhooks.max_payload_bytes int 1048576 Bound inbound body size.
integrations.webhooks.idempotency_ttl_seconds int 86400 Idempotency-key cache lifetime.

Connection records are stored per integration via WebhookConnectionRepository. Each connection carries signing_secret, nonce_header, signature_header, and a per-integration retry policy.

Worked example: configure and POST

Inbound webhooks are verified against a signing_secret credential stored on an integration connection (there is no standalone webhook-registration call). Create the connection through POST /api/v1/connections with the secret as a credential; the inbound endpoint then resolves to /webhooks/{connection_type}/{connection_name}:

curl -s -b cookies.txt -X POST http://localhost:8000/api/v1/connections \
  -H "Content-Type: application/json" \
  --data '{
    "name": "primary",
    "connection_type": "github",
    "credentials": {"signing_secret": "whsec_PROVIDED_BY_GITHUB"}
  }'

WebhookActivityService.list_activity(...) then surfaces the receipt log for that connection.

POST a sample payload from the command line:

NONCE=$(uuidgen)
TS=$(date +%s)
BODY='{"action":"opened","number":7,"pull_request":{"id":42}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac whsec_PROVIDED_BY_GITHUB | sed 's/^.* //')

curl -i http://localhost:8000/webhooks/github/primary \
  -H "Content-Type: application/json" \
  -H "X-GitHub-Delivery: $NONCE" \
  -H "X-Hub-Signature-256: sha256=$SIG" \
  --data "$BODY"

Expected:

  • 204 No Content on the first delivery (handler accepted).
  • 204 again on a retry within the replay window (idempotency-key short-circuit).
  • 400 on a malformed JSON body (envelope rejection at parse_typed time).
  • 401 on a signature mismatch.
  • 409 on a replay older than the replay window.

Retry semantics

Providers retry on non-2xx responses; SynthOrg accepts duplicate deliveries up to idempotency_ttl_seconds. The composed idempotency key is connection_name:event_type:nonce, length-clamped to 255 chars (DB schema cap), then folded to a SHA-256 digest if oversized so the cache lookup never fails on length.

Per-delivery state transitions land on the WebhookReceipt:

  • received -> dispatched (handler returned) -> acknowledged (downstream completed).
  • received -> duplicate when the idempotency key has been observed.
  • received -> rejected on signature/nonce failure.

The WEBHOOK_RECEIPT_STATUS_TRANSITIONED event fires AFTER each persistence write so dashboards can chart delivery health.

Adding a new provider

  1. Add a row to WebhookConnectionRepository schema (already covers the common columns).
  2. Implement a handler in src/synthorg/integrations/webhooks/handlers/<provider>.py that consumes a typed WebhookEventPayload and dispatches to the right service.
  3. Register the handler in the dispatcher's strategy registry.
  4. Add a per-provider test under tests/unit/integrations/webhooks/ covering accept, replay, signature mismatch, and oversized payload.

See docs/design/integrations.md for the broader integrations architecture.