Observers
Observers — Configure the full observer system that powers webhook delivery
FraiseQL supports two webhook directions: outgoing webhooks triggered by database mutations and delivered via the observer system, and incoming webhook verification for securely receiving events from external services like Stripe or GitHub.
Outgoing webhooks are configured in the observer runtime config (fraiseql-observer.toml). When a mutation matches an observer rule, the Rust runtime delivers a POST request to the configured endpoint. No Python code is required.
PostgreSQL mutation → fraiseql runtime emits event → fraiseql-observer picks up event → matches [[observers]] rule → executes webhook action → HTTP POST to endpointConfigure an observer with a webhook action in fraiseql-observer.toml:
[[observers]]entity = "Order"event_type = "INSERT"
[[observers.actions]]type = "webhook"url_env = "ORDER_WEBHOOK_URL"The url_env key names an environment variable that holds the webhook URL. This keeps secrets out of config files.
[[observers]]entity = "Order"event_type = "INSERT"
[[observers.actions]]type = "webhook"url_env = "ANALYTICS_WEBHOOK_URL"headers = {"Authorization" = "Bearer ${API_TOKEN}", "X-Source" = "fraiseql"}body_template = '''{ "event_id": "{{ event.id }}", "event_type": "{{ event.event_type }}", "entity": "{{ event.entity_type }}", "data": {{ event.data }}}'''Use condition on the observer to filter which events trigger the webhook:
[[observers]]entity = "Order"event_type = "UPDATE"condition = "data.status == 'shipped'"
[[observers.actions]]type = "webhook"url_env = "SHIPPING_WEBHOOK_URL"Configure retries per observer:
[[observers]]entity = "Order"event_type = "INSERT"
[[observers.actions]]type = "webhook"url_env = "ORDER_WEBHOOK_URL"
[observers.retry]backoff_strategy = "exponential"initial_delay_ms = 100max_attempts = 3max_delay_ms = 30000Outgoing webhook payloads are signed with HMAC-SHA256. The receiver uses the signature to confirm the request originated from FraiseQL and was not tampered with.
FraiseQL adds two headers to every outgoing webhook:
X-FraiseQL-Signature: sha256=<hex>X-FraiseQL-Timestamp: <unix_epoch>The signature is computed as:
HMAC-SHA256(secret, timestamp + "." + body)The following examples are standalone implementations for webhook receiver services. They do not import any FraiseQL library — they use each language’s standard HMAC library.
import hmacimport hashlibimport time
def verify_webhook( payload: bytes, signature: str, timestamp: str, secret: str, max_age_seconds: int = 300) -> bool: # Check timestamp freshness (replay attack protection) ts = int(timestamp) if abs(time.time() - ts) > max_age_seconds: return False
# Compute expected signature message = f"{timestamp}.".encode() + payload expected = "sha256=" + hmac.new( secret.encode(), message, hashlib.sha256 ).hexdigest()
# Constant-time comparison return hmac.compare_digest(signature, expected)
# Usage in Flask@app.route("/webhook", methods=["POST"])def handle_webhook(): if not verify_webhook( request.data, request.headers.get("X-FraiseQL-Signature"), request.headers.get("X-FraiseQL-Timestamp"), WEBHOOK_SECRET ): return "Invalid signature", 401
data = request.json # Process webhook... return "OK", 200const crypto = require('crypto');
function verifyWebhook(payload, signature, timestamp, secret, maxAge = 300) { // Check timestamp freshness const ts = parseInt(timestamp); if (Math.abs(Date.now() / 1000 - ts) > maxAge) { return false; }
// Compute expected signature const message = `${timestamp}.${payload}`; const expected = 'sha256=' + crypto .createHmac('sha256', secret) .update(message) .digest('hex');
// Constant-time comparison return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expected) );}
// Express middlewareapp.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => { const valid = verifyWebhook( req.body.toString(), req.headers['x-fraiseql-signature'], req.headers['x-fraiseql-timestamp'], process.env.WEBHOOK_SECRET );
if (!valid) { return res.status(401).send('Invalid signature'); }
const data = JSON.parse(req.body); // Process webhook... res.send('OK');});package main
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "math" "strconv" "time")
func verifyWebhook(payload []byte, signature, timestamp, secret string, maxAge int64) bool { // Check timestamp freshness ts, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return false } if math.Abs(float64(time.Now().Unix()-ts)) > float64(maxAge) { return false }
// Compute expected signature message := fmt.Sprintf("%s.%s", timestamp, string(payload)) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(message)) expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
// Constant-time comparison return hmac.Equal([]byte(signature), []byte(expected))}The fraiseql-webhooks crate handles incoming webhook signature verification from external services. It supports 15+ providers including Stripe, GitHub, Shopify, GitLab, Slack, Twilio, and more.
When an external service sends a webhook to your FraiseQL application, the runtime:
secret_env environment variable to retrieve the signing secretIncoming webhooks are configured in fraiseql-observer.toml under [webhooks.<name>]. The secret_env key names an environment variable — it is never the secret value itself.
[webhooks.github]provider = "github"secret_env = "GITHUB_WEBHOOK_SECRET"signature_header = "X-Hub-Signature-256"timestamp_tolerance = 300
[webhooks.github.events]"push" = { function = "fn_handle_github_push" }"pull_request" = { function = "fn_handle_github_pr" }GitHub uses HMAC-SHA256 with the format sha256=<hex> in the X-Hub-Signature-256 header.
[webhooks.stripe]provider = "stripe"secret_env = "STRIPE_WEBHOOK_SECRET"timestamp_tolerance = 300idempotent = true
[webhooks.stripe.events]"payment_intent.succeeded" = { function = "fn_handle_payment_success" }"customer.subscription.deleted" = { function = "fn_handle_subscription_cancel" }Stripe uses its own signature format: t=<timestamp>,v1=<signature> in the Stripe-Signature header.
[webhooks.my_service]provider = "hmac-sha256"secret_env = "MY_SERVICE_WEBHOOK_SECRET"signature_header = "X-Signature"timestamp_header = "X-Timestamp"timestamp_tolerance = 300
[webhooks.my_service.events]"order.created" = { function = "fn_handle_external_order" }For custom providers, specify signature_header and optionally timestamp_header. The hmac-sha256 provider verifies HMAC-SHA256(secret, payload).
| Field | Type | Required | Description |
|---|---|---|---|
provider | string | No | Provider name (github, stripe, shopify, hmac-sha256, etc.) |
path | string | No | Override endpoint path (default: /webhooks/<name>) |
secret_env | string | Yes | Environment variable name containing the signing secret |
signature_header | string | No | Custom signature header name (for custom providers) |
timestamp_header | string | No | Custom timestamp header name (for custom providers) |
timestamp_tolerance | integer | No | Maximum age of webhook in seconds (default: 300) |
idempotent | boolean | No | Enable idempotency checking (default: true) |
events | map | No | Event type to database function mappings |
Webhook actions executed through the observer system are tracked under the standard observer metrics:
| Metric | Description |
|---|---|
fraiseql_observer_action_executed_total{action_type="webhook"} | Total webhook deliveries attempted |
fraiseql_observer_action_duration_seconds{action_type="webhook"} | Webhook delivery latency histogram |
fraiseql_observer_action_errors_total{action_type="webhook"} | Total webhook delivery errors |
fraiseql_observer_job_retry_attempts_total{action_type="webhook"} | Total retry attempts for webhook jobs |
fraiseql_observer_dlq_items | Items in the dead letter queue (all action types) |
Never put webhook URLs or secrets directly in config files. Always reference environment variables:
[[observers.actions]]type = "webhook"url_env = "ORDER_WEBHOOK_URL" # not url = "https://..."[webhooks.stripe]secret_env = "STRIPE_WEBHOOK_SECRET" # not secret = "whsec_..."Webhook delivery guarantees at-least-once delivery. A message may be redelivered after a crash or timeout. Check for duplicates before processing:
@app.post("/webhook")def handle_webhook(data: dict): key = request.headers.get("Idempotency-Key") if already_processed(key): return {"status": "already_processed"}
process_webhook(data) mark_processed(key) return {"status": "ok"}Never skip signature verification on incoming webhooks:
if not verify_webhook(payload, signature, timestamp, secret): raise HTTPException(401, "Invalid signature")The default tolerance of 300 seconds (5 minutes) rejects replayed requests. Do not increase this value unless your infrastructure has significant clock skew.
url_env is set and the environment variable is exportedfraiseql-observer logs for delivery errors[observers.retry]secret_env is set correctly[webhooks.<name>.events] mapping includes the event typefunction exists and is callableObservers
Observers — Configure the full observer system that powers webhook delivery
Observer-Webhook Patterns
Observer-Webhook Patterns — Event-driven architecture patterns
NATS Integration
NATS — Distributed event streaming for high-volume webhook workloads
Security
Security — Webhook authentication and request validation
Subscriptions
Subscriptions — Real-time push to clients via WebSocket or gRPC server streaming