Skip to content

Webhooks

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 endpoint

Configure 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 = 100
max_attempts = 3
max_delay_ms = 30000

Outgoing 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 hmac
import hashlib
import 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", 200

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:

  1. Reads the configured secret_env environment variable to retrieve the signing secret
  2. Verifies the request signature using the provider-specific algorithm
  3. Checks the timestamp is within the configured tolerance window
  4. Routes the verified event to the configured database function

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

FieldTypeRequiredDescription
providerstringNoProvider name (github, stripe, shopify, hmac-sha256, etc.)
pathstringNoOverride endpoint path (default: /webhooks/<name>)
secret_envstringYesEnvironment variable name containing the signing secret
signature_headerstringNoCustom signature header name (for custom providers)
timestamp_headerstringNoCustom timestamp header name (for custom providers)
timestamp_toleranceintegerNoMaximum age of webhook in seconds (default: 300)
idempotentbooleanNoEnable idempotency checking (default: true)
eventsmapNoEvent type to database function mappings

Webhook actions executed through the observer system are tracked under the standard observer metrics:

MetricDescription
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_itemsItems in the dead letter queue (all action types)

Use Environment Variables for URLs and Secrets

Section titled “Use Environment Variables for URLs and Secrets”

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")

Use Timestamp Tolerance to Prevent Replay Attacks

Section titled “Use Timestamp Tolerance to Prevent Replay Attacks”

The default tolerance of 300 seconds (5 minutes) rejects replayed requests. Do not increase this value unless your infrastructure has significant clock skew.

  1. Check the endpoint URL is reachable from the observer process
  2. Confirm url_env is set and the environment variable is exported
  3. Review fraiseql-observer logs for delivery errors
  4. Check retry configuration — failed deliveries are retried according to [observers.retry]
  1. Verify the environment variable named in secret_env is set correctly
  2. Check that the raw request body is used for verification (not parsed JSON)
  3. Confirm the timestamp tolerance has not expired
  4. Verify the provider name matches the expected signature format
  1. Confirm the [webhooks.<name>.events] mapping includes the event type
  2. Check the database function named in function exists and is callable
  3. Review application logs for function execution errors

Observers

Observers — Configure the full observer system that powers webhook delivery

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