Webhooks¶
Configure an HTTPS URL in Settings → Integrations → Webhooks (/settings/integration?tab=webhooks). When a tool column finishes for a row, Kuration may send your endpoint a tool_output_ready event via POST.
Slack Incoming Webhooks (notifications) use the same Integrations screen but are vendor-specific — see Slack notifications.
Event: tool_output_ready¶
Emitted when tool output has been produced for a cell (delivery is asynchronous and best-effort; failures do not roll back row processing).
Request your server receives¶
Method: POST
Content-Type: application/json
Headers
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
Kuration-Webhook/1.0 |
X-Kuration-Signature |
sha256=<hex> — HMAC-SHA256 over the canonical JSON body (UTF-8), using your webhook secret. |
The canonical signed string is json.dumps(payload_dict, separators=(',', ':')).encode('utf-8') (no spaces after :), while the HTTP body may use different JSON whitespace. GET the raw bytes with request.get_data(), parse JSON, then re-serialize with the same separators and UTF-8-encode before HMAC:
canonical = json.dumps(data, separators=(",", ":")).encode("utf-8")
expected = hmac.new(secret.encode("utf-8"), canonical, hashlib.sha256).hexdigest()
This matches generate_webhook_signature in the platform codebase.
Implement verification against the prefix form
The header looks like sha256=abcd.... Strip the sha256= prefix (or compare the full header to sha256=" + hex_digest) before calling hmac.compare_digest. Comparing plain hex to the full header string will fail.
Payload schema¶
JSON object:
{
"event": "tool_output_ready",
"project_id": "98e79cf8-87c9-49b0-9899-58edb6f312345",
"row_id": "Jxl8KwcI6bAHH8wdQ123",
"col_id": "466527f1-ef79-4cd6-bf03-c878681d1234",
"value": "Technology",
"status": "success",
"timestamp": "2024-01-20T14:45:00.000000"
}
| Field | Type | Description |
|---|---|---|
event |
string | Always tool_output_ready for this hook. |
project_id |
string | Project UUID. |
row_id |
string | Row identifier. |
col_id |
string | Tool column UUID. |
value |
string | Serialized tool output value (meaning depends on tool). |
status |
string | Outcome marker (e.g. success, error, skipped, etc.—matches internal tool run type). |
timestamp |
string | ISO-style timestamp string. |
Configure and rotate the secret from the integrations page; treat it like a password (environment variables, secret manager).
Flask receiver example¶
from flask import Flask, request, jsonify
import hmac
import hashlib
import json
app = Flask(__name__)
WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET" # from Kuration settings
def normalize_signature(header_value: str) -> str:
"""Return hex digest portion from X-Kuration-Signature (sha256=<hex>)."""
if not header_value:
return ""
value = header_value.strip()
if value.startswith("sha256="):
return value[len("sha256=") :]
return value
@app.route("/webhook", methods=["POST"])
def handle_webhook():
signature_header = request.headers.get("X-Kuration-Signature", "")
signature = normalize_signature(signature_header)
if not signature:
return jsonify({"error": "Missing signature"}), 401
raw = request.get_data()
try:
data = json.loads(raw.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
return jsonify({"error": "Invalid JSON"}), 400
canonical = json.dumps(data, separators=(",", ":")).encode("utf-8")
expected = hmac.new(
WEBHOOK_SECRET.encode("utf-8"),
canonical,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(signature, expected):
return jsonify({"error": "Invalid signature"}), 401
print(
f"Received {data.get('event')} project={data.get('project_id')} "
f"row={data.get('row_id')} col={data.get('col_id')}"
)
# Process data here; respond quickly — see below.
return jsonify({"success": True}), 200
if __name__ == "__main__":
app.run(port=5000)
Verification helper only¶
Useful if you integrate with frameworks other than Flask but still verify the MAC over raw bytes:
import hmac
import hashlib
import json
def verify_webhook_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
"""True if signature_header matches sha256 over canonical JSON (compact separators)."""
if not signature_header:
return False
trimmed = signature_header.strip()
if trimmed.startswith("sha256="):
provided = trimmed[len("sha256=") :]
else:
provided = trimmed
try:
data = json.loads(raw_body.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
return False
canonical = json.dumps(data, separators=(",", ":")).encode("utf-8")
expected = hmac.new(secret.encode("utf-8"), canonical, hashlib.sha256).hexdigest()
return hmac.compare_digest(provided, expected)
# sig = request.headers.get("X-Kuration-Signature", "")
# verify_webhook_signature(request.get_data(), sig, WEBHOOK_SECRET)
Security and delivery notes¶
- Verify every request with
hmac.compare_digest(constant-time). - Store the secret in environment variables or a secret manager — never commit it.
- Respond quickly: outbound delivery uses a ~5 second timeout; offload heavy work to a queue/async task and return
2xxpromptly. - Use HTTPS for your public URL.
Slack notifications¶
To send alerts to Slack, create an Incoming Webhook in Slack’s API dashboard, then connect it under Integrations → Webhooks (/settings/integration?tab=webhooks) using Manage / Connect on the Slack card. That path is distinct from tool_output_ready deliveries: follow Slack’s documentation for webhook URL rotation and message formatting.
Source of truth¶
Signing and outbound POSTs are implemented in webhook_service.py (generate_webhook_signature, send_webhook). Payload fields follow WebhookPayload in models/webhook.py. If behavior changes, update this page and the in-app webhook documentation together.