Webhooks
Real-time event notifications with HMAC-SHA256 signature verification.
Overview
Webhooks let GaaS tell your systems what happened instead of you polling for it. When GaaS makes a governance decision, when a human review of an escalated action changes state, or when your organization reaches its usage limit, GaaS sends an HTTPS POST to the URL you registered.
Key features:
- HMAC-SHA256 signed payloads, with a secret GaaS generates for each webhook
- Automatic retries: 3 attempts, with waits of 5 and 25 seconds
- Organization-wide or per-escalation subscriptions
- Failed deliveries kept so you can list and retry them
Available Events
Subscribe to any of these event types:
| Event Type | Description | When Triggered |
|---|---|---|
decision.approved |
Action approved | GaaS approves an intent, with or without modifications |
decision.blocked |
Action blocked | GaaS blocks an intent |
decision.escalated |
Action held for human review | GaaS escalates an intent |
decision.overridden |
Block approved by a person | A person approves a blocked action; the agent's next retry of that same action within 24 hours is approved once |
escalation.decided |
Review completed | A reviewer submits a decision on an escalation |
escalation.timed_out |
Review timed out | No review arrived within the escalation's time limit |
escalation.cancelled |
Escalation cancelled | An operator or admin cancels the escalation |
escalation.reassigned |
Escalation reassigned | The escalation is handed to different reviewers |
quota.exceeded |
Usage limit reached | Your organization reaches its hard usage limit and GaaS starts refusing new intents |
Decision events are sent in every pipeline mode; data.pipeline_mode says which (live, shadow or test).
rate_limit.exceeded, observation.recorded, policy.calibrated and pattern.detected, but the hosted service does not currently send them.
Registering a Webhook
Create a webhook with a POST request, using an API key with the admin role:
POST https://api.gaas.is/v1/escalations/webhooks
X-API-Key: your_admin_api_key
Content-Type: application/json
{
"url": "https://yourapp.com/webhooks/gaas",
"event_types": ["decision.blocked", "decision.escalated", "escalation.decided"],
"description": "Ops alerts"
}
Parameters:
url(required) — Your receiver. It must be HTTPS and publicly reachable, with nouser:password@; private, loopback and cloud-metadata addresses are refused.event_types(optional) — Event types to receive. Leave it out to receive every event type. An unknown event type is rejected with400.escalation_id(optional) — Watch one escalation only. Leave it out for an organization-wide webhook; only organization-wide webhooks receivedecision.*andquota.exceededevents.description(optional) — A note for yourself.
GaaS answers 201 Created with the webhook, including the signing secret it generated:
{
"webhook": {
"id": "wh_81af440d0481",
"created_at": "2026-09-28T17:52:54.924800Z",
"url": "https://yourapp.com/webhooks/gaas",
"organization_id": "org_acme",
"escalation_id": null,
"event_types": ["decision.blocked", "decision.escalated", "escalation.decided"],
"secret": "whsec_5b0e6c1d9a8f4e2b8c7d6e5f4a3b2c1d",
"active": true,
"description": "Ops alerts"
}
}
"***".
Event Payload Structure
When an event occurs, GaaS sends a POST request to your webhook URL. A decision.blocked body looks like this (indented here; on the wire it is compact JSON with keys in alphabetical order):
{
"data": {
"decision_id": "dec_0f0de7b67fcd4e1487fe6bc5030eacf6",
"human_override_id": null,
"intent_id": "int_85e46e12d8d949ff9c207bffac3afca5",
"pipeline_mode": "live",
"risk_score": 0.562,
"timestamp": "2026-09-28T17:52:54.955690+00:00",
"verdict": "block"
},
"decision_id": "dec_0f0de7b67fcd4e1487fe6bc5030eacf6",
"escalation_id": null,
"escalation_status": null,
"event_type": "decision.blocked",
"intent_id": "int_85e46e12d8d949ff9c207bffac3afca5",
"observation_id": null,
"organization_id": "org_acme",
"pattern_id": null,
"policy_id": null,
"timestamp": "2026-09-28T17:52:54.959796Z",
"webhook_id": "wh_81af440d0481"
}
Escalation events set escalation_id and escalation_status, and carry the full escalation record in data. In decision events, data.human_override_id is set when a person's approval turned a block into this approval, and null otherwise. decision.overridden carries override_id, agent_id, approved_by, approved_at, expires_at and policies_overridden in data. quota.exceeded carries plan_id, included_actions, used_actions, hard_limit, batch_size and timestamp in data. Verdicts are lowercase: approve, approve_modified, escalate, block.
Headers sent with webhook:
Content-Type: application/jsonX-GaaS-Signature: sha256=<hmac_signature>— HMAC-SHA256 signature for verificationX-GaaS-Event: <event_type>— Event type (e.g.,decision.blocked)X-GaaS-Delivery: dlv_…— Delivery ID, reused by the automatic retries of one deliveryX-GaaS-Retry: true— Present only on a manual retry (see Retry Policy)
Signature Verification (HMAC-SHA256)
Always verify webhook signatures to ensure requests are from GaaS and haven't been tampered with. The X-GaaS-Signature header contains sha256= followed by the lowercase hex HMAC-SHA256 of the raw request body, keyed with your webhook's whole whsec_… secret.
Python (Flask)
import hashlib
import hmac
import os
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"] # the whsec_… value from registration
@app.route("/webhooks/gaas", methods=["POST"])
def handle_webhook():
# Get signature from header
signature_header = request.headers.get("X-GaaS-Signature", "")
# Compute HMAC-SHA256 of the raw request body
raw_body = request.get_data()
computed = "sha256=" + hmac.new(
WEBHOOK_SECRET.encode("utf-8"),
raw_body,
hashlib.sha256
).hexdigest()
# Constant-time comparison to prevent timing attacks
if not hmac.compare_digest(signature_header, computed):
abort(401, "Invalid signature")
# Signature valid, process event
payload = request.get_json()
event_type = payload["event_type"]
if event_type == "decision.blocked":
handle_blocked(payload)
elif event_type == "escalation.decided":
handle_review_decided(payload)
return {"status": "received"}, 200
TypeScript (Express)
import express from 'express';
import crypto from 'crypto';
const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!; // the whsec_… value from registration
// Important: Use raw body for signature verification
app.use('/webhooks/gaas', express.raw({ type: 'application/json' }));
app.post('/webhooks/gaas', (req, res) => {
const signatureHeader = String(req.headers['x-gaas-signature'] ?? '');
// Compute HMAC-SHA256 of raw body
const computed = 'sha256=' + crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
// Constant-time comparison (lengths must match first)
const a = Buffer.from(signatureHeader);
const b = Buffer.from(computed);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('Invalid signature');
}
// Signature valid, parse and process event
const payload = JSON.parse(req.body.toString());
const eventType = payload.event_type;
if (eventType === 'decision.blocked') {
handleBlocked(payload);
} else if (eventType === 'escalation.decided') {
handleReviewDecided(payload);
}
res.json({ status: 'received' });
});
Java (Spring Boot)
import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@RestController
public class WebhookController {
private static final String WEBHOOK_SECRET = System.getenv("WEBHOOK_SECRET"); // the whsec_… value
@PostMapping("/webhooks/gaas")
public Map<String, String> handleWebhook(
@RequestHeader("X-GaaS-Signature") String signatureHeader,
@RequestBody byte[] rawBody
) throws Exception {
// Compute HMAC-SHA256 of the raw body
Mac hmac = Mac.getInstance("HmacSHA256");
hmac.init(new SecretKeySpec(
WEBHOOK_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String computed = "sha256=" + bytesToHex(hmac.doFinal(rawBody));
// Constant-time comparison
if (!MessageDigest.isEqual(
signatureHeader.getBytes(StandardCharsets.UTF_8),
computed.getBytes(StandardCharsets.UTF_8))) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid signature");
}
// Process event
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> payload = mapper.readValue(rawBody, Map.class);
String eventType = (String) payload.get("event_type");
return Map.of("status", "received");
}
private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}
hmac.compare_digest in Python, crypto.timingSafeEqual in Node.js, MessageDigest.isEqual in Java) to prevent timing attacks when comparing signatures.
Retry Policy
If your endpoint returns anything other than a 2xx status, times out, or refuses the connection, GaaS tries again:
- Attempt 1: Immediate delivery
- Attempt 2: After 5 seconds
- Attempt 3: After a further 25 seconds
Each attempt waits up to 10 seconds for your answer. After 3 failed attempts the delivery is marked failed. List failed deliveries with GET /v1/escalations/webhooks/deliveries/failed (operator or admin key; add ?webhook_id=wh_… to narrow it), and retry one with POST /v1/escalations/webhooks/deliveries/{delivery_id}/retry.
X-GaaS-Retry: true, the same event type, and data.original_delivery_id. To recover the missed event, use the intent_id on the failed-delivery record with GET /v1/intents/{intent_id}/audit.
200 OK). For long-running tasks, acknowledge the webhook immediately and process asynchronously (queue-based processing recommended).
Testing Webhooks
For local development, use ngrok or webhook.site to give your receiver a public HTTPS address:
Using ngrok
# Start ngrok tunnel
ngrok http 3000
# Register webhook with ngrok URL
curl -X POST https://api.gaas.is/v1/escalations/webhooks \
-H "X-API-Key: your_admin_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://abc123.ngrok.io/webhooks/gaas",
"description": "Local testing"
}'
Using webhook.site
- Visit webhook.site
- Copy your unique URL (e.g.,
https://webhook.site/abc-123-def) - Register that URL as a webhook in GaaS
- View payloads and headers in real-time on the webhook.site dashboard
Managing Webhooks
List Webhooks
GET https://api.gaas.is/v1/escalations/webhooks
X-API-Key: your_api_key
Secrets are masked as "***"; URLs are shown in full.
Delete Webhook
DELETE https://api.gaas.is/v1/escalations/webhooks/{webhook_id}
X-API-Key: your_admin_api_key
View Failed Deliveries
GET https://api.gaas.is/v1/escalations/webhooks/deliveries/failed
X-API-Key: your_operator_api_key
Best Practices
- Always verify signatures to prevent spoofing.
- Remember the
X-GaaS-DeliveryIDs you have processed and skip repeats. The signature has no timestamp, so this is also your defence against replayed requests. - Respond quickly (within 10 seconds) to avoid timeouts. Use async processing for long-running tasks.
- Store webhook secrets securely (environment variables or secrets manager, never in code).
- Watch for failed deliveries and alert on repeated failures (they indicate endpoint downtime).
- Subscribe to what you use. Leave out
event_typesto receive everything, or list only the events you handle.
Troubleshooting
Webhooks Not Delivered
Cause: Endpoint unreachable, firewall blocking, or the webhook is scoped to one escalation.
Solution: Check the failed-deliveries list for the response status. Verify your endpoint is publicly reachable over HTTPS (test with curl). Decision and quota events only go to organization-wide webhooks, so register without escalation_id.
Signature Verification Failing
Cause: Wrong secret, modified body before verification, or encoding mismatch.
Solution: Ensure you're using the raw request body (not parsed JSON). Use the whole whsec_… secret from the registration response, as UTF-8.
Duplicate Events
Cause: Webhook retries after timeout or transient failure.
Solution: Make your webhook handler idempotent. Use X-GaaS-Delivery as the deduplication key.
Related Pages
- Send GaaS alerts to your SIEM — Splunk, Microsoft Sentinel, Datadog and a small adapter
- Getting Started — Onboard and submit your first intent
- Authentication — API key setup and security
- API Reference — Complete endpoint documentation