Webhook Events
HatiData delivers webhook events for semantic triggers, billing events, and system notifications. Each webhook includes an HMAC-SHA256 signature for payload verification and follows a retry policy with exponential backoff.
Delivery
Endpoint Requirements
Your webhook endpoint must:
- Accept
POSTrequests withContent-Type: application/json - Return a
2xxstatus code within 10 seconds - Be reachable over HTTPS (HTTP is accepted for
localhostonly)
Headers
Every webhook request includes these headers:
| Header | Description |
|---|---|
Content-Type | application/json |
X-HatiData-Signature | HMAC-SHA256 signature: sha256=<hex_digest> |
X-HatiData-Event-Type | Event type (e.g., trigger.fired) |
X-HatiData-Event-Id | Unique event ID (UUID) for idempotency |
X-HatiData-Timestamp | ISO 8601 timestamp of event creation |
X-HatiData-Delivery-Attempt | Delivery attempt number (1-based) |
Signature Verification
Verify every webhook to ensure it was sent by HatiData and has not been tampered with.
Python
import hmac
import hashlib
def verify_webhook(payload: bytes, signature: str, secret: str) -> bool:
"""Verify the HMAC-SHA256 signature of a webhook payload."""
expected = hmac.new(
secret.encode("utf-8"),
payload,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature)
# In your webhook handler
from flask import Flask, request
app = Flask(__name__)
WEBHOOK_SECRET = "whsec_your_secret"
@app.route("/webhooks/hatidata", methods=["POST"])
def handle_webhook():
signature = request.headers.get("X-HatiData-Signature", "")
if not verify_webhook(request.data, signature, WEBHOOK_SECRET):
return {"error": "Invalid signature"}, 401
event = request.json
event_type = request.headers.get("X-HatiData-Event-Type")
# Process the event...
return {"received": True}, 200
TypeScript
import crypto from 'crypto';
import express from 'express';
function verifyWebhook(payload: Buffer, signature: string, secret: string): boolean {
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(`sha256=${expected}`),
Buffer.from(signature),
);
}
const app = express();
app.use(express.raw({ type: 'application/json' }));
app.post('/webhooks/hatidata', (req, res) => {
const signature = req.headers['x-hatidata-signature'] as string;
if (!verifyWebhook(req.body, signature, process.env.WEBHOOK_SECRET!)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body.toString());
// Process the event...
res.json({ received: true });
});
Event Types
trigger.fired
Sent when a semantic trigger fires.
{
"event_type": "trigger.fired",
"trigger_id": "trg_abc123",
"trigger_name": "churn-risk-detector",
"fired_at": "2025-12-15T10:30:00Z",
"similarity_score": 0.84,
"threshold": 0.78,
"content": "Customer expressed frustration and mentioned evaluating competitors...",
"content_id": "mem_xyz789",
"agent_id": "support-agent",
"org_id": "org_acme",
"metadata": {
"team": "customer-success",
"severity": "high"
}
}
trigger.error
Sent when a trigger evaluation fails.
{
"event_type": "trigger.error",
"trigger_id": "trg_abc123",
"trigger_name": "churn-risk-detector",
"error": "Embedding provider timeout after 10s",
"occurred_at": "2025-12-15T10:30:05Z",
"org_id": "org_acme"
}
billing.usage_threshold
Sent when an organization reaches a usage threshold.
{
"event_type": "billing.usage_threshold",
"org_id": "org_acme",
"threshold_percent": 80,
"current_usage": 8_000_000,
"monthly_limit": 10_000_000,
"period_start": "2025-12-01T00:00:00Z",
"period_end": "2025-12-31T23:59:59Z"
}
billing.subscription_updated
Sent when a subscription changes (upgrade, downgrade, cancellation).
{
"event_type": "billing.subscription_updated",
"org_id": "org_acme",
"previous_tier": "growth",
"new_tier": "enterprise",
"effective_at": "2025-12-15T00:00:00Z",
"subscription_id": "sub_abc123"
}
agent.key_rotated
Sent when an agent API key is rotated or revoked.
{
"event_type": "agent.key_rotated",
"org_id": "org_acme",
"key_id": "key_xyz789",
"key_name": "support-agent",
"action": "rotated",
"performed_by": "admin@acme.com",
"occurred_at": "2025-12-15T10:30:00Z"
}
agent.key_revoked
Sent when an agent API key is revoked.
{
"event_type": "agent.key_revoked",
"org_id": "org_acme",
"key_id": "key_xyz789",
"key_name": "support-agent",
"reason": "Key exposed in public repository",
"performed_by": "admin@acme.com",
"occurred_at": "2025-12-15T10:30:00Z"
}
branch.merged
Sent when a branch is merged to main.
{
"event_type": "branch.merged",
"org_id": "org_acme",
"branch_id": "br_abc123",
"branch_name": "pricing-experiment",
"agent_id": "pricing-agent",
"strategy": "branch_wins",
"tables_merged": ["products"],
"rows_affected": 42,
"merged_at": "2025-12-15T10:30:00Z"
}
system.maintenance
Sent before scheduled maintenance windows.
{
"event_type": "system.maintenance",
"scheduled_start": "2025-12-20T02:00:00Z",
"scheduled_end": "2025-12-20T04:00:00Z",
"affected_services": ["control-plane", "dashboard"],
"description": "Database migration for new billing features",
"impact": "Control plane API may be unavailable. Data plane queries are unaffected."
}
Retry Policy
Failed deliveries (non-2xx response or timeout) are retried with exponential backoff:
| Attempt | Delay | Cumulative Time |
|---|---|---|
| 1 | Immediate | 0s |
| 2 | 30s | 30s |
| 3 | 2 min | 2.5 min |
| 4 | 10 min | 12.5 min |
| 5 | 30 min | 42.5 min |
| 6 | 1 hour | 1h 42m |
After 6 failed attempts, the event is marked as failed in the delivery log. Failed events can be replayed manually via the API.
Replay Failed Events
# List failed deliveries
curl https://api.hatidata.com/v1/webhooks/deliveries?status=failed \
-H "Authorization: Bearer <jwt>"
# Replay a specific event
curl -X POST https://api.hatidata.com/v1/webhooks/deliveries/evt_abc123/replay \
-H "Authorization: Bearer <jwt>"
Idempotency
Every webhook event includes a unique X-HatiData-Event-Id header. Use this ID to deduplicate events in your handler:
PROCESSED_EVENTS = set()
@app.route("/webhooks/hatidata", methods=["POST"])
def handle_webhook():
event_id = request.headers.get("X-HatiData-Event-Id")
if event_id in PROCESSED_EVENTS:
return {"status": "already_processed"}, 200
# Process event...
PROCESSED_EVENTS.add(event_id)
return {"received": True}, 200
In production, store processed event IDs in a database or Redis with a 48-hour TTL.
Webhook Management
Register a Webhook Endpoint
curl -X POST https://api.hatidata.com/v1/webhooks \
-H "Authorization: Bearer <jwt>" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/hatidata",
"secret": "whsec_your_secret",
"events": ["trigger.fired", "billing.usage_threshold"],
"description": "Production webhook for alerts"
}'
List Registered Webhooks
curl https://api.hatidata.com/v1/webhooks \
-H "Authorization: Bearer <jwt>"
Delete a Webhook
curl -X DELETE https://api.hatidata.com/v1/webhooks/wh_abc123 \
-H "Authorization: Bearer <jwt>"
Related Concepts
- Semantic Trigger Pipeline -- Building trigger pipelines
- Trigger Recipes -- Advanced trigger patterns
- Semantic Triggers -- Trigger architecture
- Control Plane API -- Full API reference
- Security Model -- Authentication and authorization