Webhook verification
This is for a different job than connecting an agent: a service in your own backend that receives events from Clausgap, rather than an agent that calls out to it. When an escrow or policy-governance event is created or resolved, the control plane POSTs a signed payload to your subscription URL. Always verify the signature before processing.
Signature header
X-Clausgap-Signature: t=<unix-timestamp>,v1=<hex-hmac>
Signed input: "<unix-timestamp>.<raw-json-body>"Verification — Python
import hmac, hashlib
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = "whsec_..." # configured in /v1/subscriptions
@app.post("/clausgap-webhook")
def webhook():
sig_header = request.headers.get("X-Clausgap-Signature", "")
parts = dict(part.split("=", 1) for part in sig_header.split(","))
timestamp = parts.get("t", "")
received = parts.get("v1", "")
signed = f"{timestamp}.{request.data.decode()}"
expected = hmac.new(
WEBHOOK_SECRET.encode(),
signed.encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, received):
abort(400, "Invalid signature")
event = request.json
print(f"Received: {event['event']}", event.get("escrow_id"))
return "", 204Verification — TypeScript (Express)
import { createHmac, timingSafeEqual } from "crypto"
import express from "express"
const app = express()
app.use(express.raw({ type: "application/json" }))
const WEBHOOK_SECRET = process.env.CLAUSGAP_WEBHOOK_SECRET!
app.post("/clausgap-webhook", (req, res) => {
const sigHeader = req.headers["x-clausgap-signature"] as string ?? ""
const parts = Object.fromEntries(sigHeader.split(",").map(p => p.split("=")))
const { t: timestamp, v1: received } = parts
const signed = `${timestamp}.${req.body.toString()}`
const expected = createHmac("sha256", WEBHOOK_SECRET).update(signed).digest("hex")
if (!timingSafeEqual(Buffer.from(expected), Buffer.from(received ?? ""))) {
return res.status(400).send("Invalid signature")
}
const event = JSON.parse(req.body.toString())
console.log("Clausgap event:", event.event, event.escrow_id)
res.status(204).send()
})Escrow event payload
{
"event": "escrow.created",
"escrow_id": "esw_abc123",
"tenant_id": "acme-corp",
"agent_role": "treasury_bot",
"agent_id": "bot-1",
"action_type": "api_call",
"action_summary": "POST /submit_payment",
"estimated_value": 48200.00,
"value_currency": "USD",
"risk_score": 0.85,
"hold_type": "multi_party",
"required_approvals": 2,
"ttl": 3600,
"expires_at": "2025-07-24T15:30:00Z",
"created_at": "2025-07-24T14:30:00Z",
"notify_targets": ["https://hooks.slack.com/services/T.../B.../finance"]
}Testing and troubleshooting
Check a destination before relying on it: Send test on the dashboard's Subscriptions page (or POST /v1/subscriptions/{id}/test) delivers a clausgap.test event right away, signed exactly like a real one, and tells you what happened. For a webhook it also returns the payload it sent, so you can compare your signature check against it.
| Result | What to check |
|---|---|
| DELIVERED | Nothing — your endpoint answered 2xx. |
| REJECTED_BY_DESTINATION | Your endpoint answered 401/403. If it verifies X-Clausgap-Signature, make sure it uses this subscription's signing secret. |
| NOT_FOUND | Your endpoint answered 404 — check the URL path. |
| DESTINATION_ERROR | Your endpoint answered another non-2xx status; see its logs. |
| TIMEOUT | No response within 10 seconds. |
| DNS_ERROR | The host name doesn't resolve. |
| TLS_ERROR | The certificate isn't trusted for the host name. |
| CONNECTION_FAILED | Nothing accepted the connection (wrong port, firewall). |
| DESTINATION_BLOCKED | The URL resolves to a private, loopback or cloud-metadata address. Clausgap only delivers to public addresses — use a different URL. |
Open a subscription in the dashboard to see its delivery log: each delivery's status, attempts, the reason for the last failure in the same plain terms, and when a retrying delivery is next attempted. Failed deliveries are retried with exponential backoff for up to 24 hours. A destination Clausgap refuses fails at once rather than retrying, and deleting or pausing a subscription stops the deliveries already queued for it.
Slack & Microsoft Teams
Everything above is for a subscription with destination_type: "webhook" — raw signed JSON to an endpoint you write. Set destination_type to slack or teams instead and skip that endpoint entirely: the control plane posts a pre-formatted Slack Block Kit message or Teams Adaptive Card straight to an incoming webhook URL you paste in from your workspace, no server or signature verification required. Escrow cards include the agent role, hold type, approvals required, expiry, the action being held, and Approve / Deny buttons that open the hold in the dashboard.
// POST /v1/subscriptions
{
"destination_type": "slack",
"url": "https://hooks.slack.com/services/T000/B000/XXXXXXXX",
"event_types": ["escrow.created", "escrow.released", "escrow.rejected"],
"destination_config": { "app_url": "https://app.clausgap.dev" }
}destination_config.app_url is optional — it only controls where the Approve/Deny links point. For Teams, use destination_type: "teams" with a connector webhook URL in place of Slack's incoming webhook URL; everything else is identical. Configure both from the dashboard's Subscriptions page, or via the API above.