Skip to content

Webhooks

FraiseQL provides both outgoing webhooks (via Observers) and incoming webhook verification for secure integrations.

Send webhooks when data changes using the Observer system.

from fraiseql import observer, webhook
@observer(
entity="Order",
event="INSERT",
actions=[
webhook("https://api.example.com/orders/new")
]
)
def on_new_order():
pass
webhook(
"https://api.example.com/orders",
headers={
"Authorization": "Bearer ${WEBHOOK_API_KEY}",
"X-Source": "fraiseql"
}
)
webhook(
"https://api.example.com/orders",
body_template='''
{
"event": "order.created",
"order_id": "{{id}}",
"total": {{total}},
"customer": "{{customer_email}}",
"timestamp": "{{_timestamp}}"
}
'''
)
webhook(url_env="ORDER_WEBHOOK_URL")

FraiseQL signs all outgoing webhooks with HMAC-SHA256.

[webhooks]
signing_enabled = true
signing_secret = "${WEBHOOK_SIGNING_SECRET}"
signing_algorithm = "sha256" # or "sha512"
# Headers for signature
signature_header = "X-FraiseQL-Signature"
timestamp_header = "X-FraiseQL-Timestamp"
X-FraiseQL-Signature: sha256=abc123def456...
X-FraiseQL-Timestamp: 1704067200

Signature computed as:

HMAC-SHA256(secret, timestamp + "." + body)
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
ts = int(timestamp)
if abs(time.time() - ts) > max_age_seconds:
return False # Replay attack protection
# 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

Receive webhooks from external services with verification.

[webhooks.incoming.github]
enabled = true
path = "/webhooks/github"
secret = "${GITHUB_WEBHOOK_SECRET}"
signature_header = "X-Hub-Signature-256"
events = ["push", "pull_request", "issues"]
[webhooks.retry]
enabled = true
max_attempts = 5
initial_delay_ms = 1000
max_delay_ms = 60000
backoff_multiplier = 2.0
# Retry on these status codes
retry_status_codes = [408, 429, 500, 502, 503, 504]
[webhooks.dlq]
enabled = true
storage = "postgres" # or "redis"
retention_days = 7
[webhooks.dlq.postgres]
table_name = "tb_webhook_dlq"

Inspect failed webhooks:

Terminal window
# List failed webhooks
fraiseql-cli webhook dlq list
# Retry a specific webhook
fraiseql-cli webhook dlq retry --id "webhook-123"
# Retry all failed
fraiseql-cli webhook dlq retry-all --older-than 1h
[webhooks]
# Connection timeout
connect_timeout_ms = 5000
# Request timeout
request_timeout_ms = 30000
# Max payload size
max_payload_bytes = 1048576 # 1MB
# Max concurrent requests
max_concurrent = 100

Available in webhook body templates:

VariableDescription
{{field_name}}Entity field value
{{_id}}Entity ID
{{_timestamp}}Event timestamp (ISO 8601)
{{_event}}Event type (INSERT, UPDATE, DELETE)
{{_entity}}Entity type name
{{_json}}Entire entity as JSON
{{_old}}Previous values (UPDATE only)
{{_changed}}Changed fields (UPDATE only)
webhook(
"https://api.example.com/events",
body_template='''
{
"event_type": "{{_entity}}.{{_event}}",
"entity_id": "{{_id}}",
"timestamp": "{{_timestamp}}",
"data": {{_json}},
"changes": {{_changed}}
}
'''
)
MetricDescription
fraiseql_webhook_requests_totalTotal webhook requests
fraiseql_webhook_success_totalSuccessful deliveries
fraiseql_webhook_failure_totalFailed deliveries
fraiseql_webhook_retry_totalRetry attempts
fraiseql_webhook_latency_msDelivery latency
fraiseql_webhook_dlq_sizeDead letter queue size

Include idempotency key for safe retries:

webhook(
"https://api.example.com/orders",
headers={
"Idempotency-Key": "{{_entity}}-{{_id}}-{{_timestamp}}"
}
)

Always verify incoming webhook signatures:

if not verify_webhook(payload, signature, timestamp, secret):
raise HTTPException(401, "Invalid signature")

Expect duplicate deliveries:

@app.post("/webhook")
def handle_webhook(data: dict):
# Use idempotency key to prevent duplicate processing
key = request.headers.get("Idempotency-Key")
if already_processed(key):
return {"status": "already_processed"}
process_webhook(data)
mark_processed(key)
return {"status": "ok"}
# Webhook success rate
sum(rate(fraiseql_webhook_success_total[5m])) /
sum(rate(fraiseql_webhook_requests_total[5m]))
# Alert on DLQ growth
fraiseql_webhook_dlq_size > 100
  1. Check endpoint is reachable
  2. Verify URL is correct
  3. Check firewall rules
  4. Review retry attempts in logs
  1. Verify secret matches on both sides
  2. Check timestamp tolerance
  3. Ensure payload isn’t modified (proxies, encoding)
  4. Verify signature algorithm matches
  1. Increase timeout settings
  2. Check endpoint performance
  3. Consider async processing on receiver

Observers

Observers — Event-driven webhooks

Security

Security — Webhook authentication