CL
Clausgap
/Docs

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

text
X-Clausgap-Signature: t=<unix-timestamp>,v1=<hex-hmac>

Signed input: "<unix-timestamp>.<raw-json-body>"

Verification — Python

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 "", 204

Verification — TypeScript (Express)

typescript
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

json
{
  "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.

ResultWhat to check
DELIVEREDNothing — your endpoint answered 2xx.
REJECTED_BY_DESTINATIONYour endpoint answered 401/403. If it verifies X-Clausgap-Signature, make sure it uses this subscription's signing secret.
NOT_FOUNDYour endpoint answered 404 — check the URL path.
DESTINATION_ERRORYour endpoint answered another non-2xx status; see its logs.
TIMEOUTNo response within 10 seconds.
DNS_ERRORThe host name doesn't resolve.
TLS_ERRORThe certificate isn't trusted for the host name.
CONNECTION_FAILEDNothing accepted the connection (wrong port, firewall).
DESTINATION_BLOCKEDThe 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.

json
// 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.