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:

Sending alerts to a SIEM? Send GaaS alerts to your SIEM walks through Splunk, Microsoft Sentinel and Datadog, including when you need a small adapter.

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

Not delivered today: registration also accepts 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:

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"
  }
}
Save the secret now. This is the only response that shows it; listing your webhooks later shows "***".

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:


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();
    }
}
Security Note: Always use constant-time comparison functions (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:

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.

A manual retry does not resend the original event. It sends a short notice with 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.
Webhook endpoint requirements: Your endpoint must respond within 10 seconds and return a 2xx status code (typically 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

  1. Visit webhook.site
  2. Copy your unique URL (e.g., https://webhook.site/abc-123-def)
  3. Register that URL as a webhook in GaaS
  4. 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


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