Core API

Webhook callbacks for signed operator decisions

Validate signed Contro1 callbacks and safely resume AI workflows after approvals, rejections, expirations, or escalations.

Webhooks are the handoff point between human review and agent execution, so signature verification and idempotency are mandatory.

Key takeaways

  • Every callback is signed with HMAC-SHA256 over timestamp plus body.
  • Reject callbacks older than 5 minutes to prevent replay.
  • Return 200 even on idempotent duplicates so Contro1 stops retrying.
  • Map approved, denied, timed_out, and cancelled outcomes to explicit branches.
  • Callbacks include policy_context when the original request included policy or risk evidence.
  • Enforce the approval in the executing system: bind it to the exact action, run each request_id once, pull-verify on doubt.

Why webhook verification matters

If your callback path accepts forged decisions, your approval layer is not real.

Every workflow that resumes after human review should verify timestamp and signature before applying the response.

What your handler should do

  • Verify X-CentCom-Signature and X-CentCom-Timestamp
  • Use X-CentCom-Request-Id for correlation in logs
  • Reject stale callbacks
  • Deduplicate by delivery or request ID
  • Map approved, denied, timed_out, and cancelled outcomes explicitly

Enforce approvals at execution: the anti-bypass guardrail

A signed webhook is more than a callback - it is cryptographic proof of a human decision. Put the check inside the system that performs the action (the payment service, the deploy runner, the CRM writer), not inside the agent. When the executing system refuses to act without a verified approval, no agent can perform that action by skipping Contro1 - including shadow agents nobody registered.

Four rules turn the webhook into a real gate:

  • Signature + freshness: reject an invalid X-CentCom-Signature and any timestamp older than 5 minutes, so an old approval cannot be replayed. The timestamp marks callback delivery, not request creation - a decision that takes hours or days still arrives freshly signed, and every retry is re-signed, so long SLAs are unaffected.
  • Bind the approval to the exact action: match metadata / correlation_id and the action parameters (amount, target, record ids) before executing. "An approval arrived" is never permission for a different action.
  • One-time use: execute each request_id exactly once (keep an idempotency record), so one approval cannot authorize a second run.
  • Pull-verify when in doubt: confirm state directly with GET /v1/requests/:id using a read-only API key instead of trusting what an agent hands you.
executionGate.ts
import { verifyWebhook } from '@contro1/sdk';

// The execution gate lives in the service that performs the action - not in the agent.
app.post('/api/contro1/webhook', async (req, res) => {
  // 1. Signature + freshness: rejects forgeries and replayed approvals.
  //    The timestamp is the callback's SEND time, not the request's creation
  //    time - a decision that took days still verifies (retries are re-signed).
  const ok = verifyWebhook(req.rawBody, req.headers['x-centcom-signature'],
    req.headers['x-centcom-timestamp'], process.env.CENTCOM_WEBHOOK_SECRET!);
  if (!ok) return res.status(401).json({ error: 'invalid signature' });
  res.status(200).end(); // ack fast, keep processing async

  const { request_id, status, metadata } = JSON.parse(req.rawBody);
  if (status !== 'approved') return;

  // 2. Bind the approval to the exact pending action and its parameters
  const action = await pendingActions.get(metadata.case_id);
  if (!action || action.amount !== metadata.amount) {
    return alertSecurity('approval does not match a pending action', request_id);
  }

  // 3. One-time use: a request_id executes exactly once
  if (!(await executedRequests.addIfAbsent(request_id))) return;

  // 4. Only now perform the action
  await performAction(action);
});
execution_gate.py
from centcom import verify_webhook

# The execution gate lives in the service that performs the action - not in the agent.
@app.post("/webhooks/contro1")
async def contro1_webhook(request: Request):
    raw = await request.body()
    # 1. Signature + freshness: rejects forgeries and replayed approvals.
    #    The timestamp is the callback's SEND time, not the request's creation
    #    time - a decision that took days still verifies (retries are re-signed).
    if not verify_webhook(raw, request.headers["X-CentCom-Signature"],
                          request.headers["X-CentCom-Timestamp"], WEBHOOK_SECRET):
        raise HTTPException(401, "invalid signature")

    payload = json.loads(raw)
    if payload.get("status") != "approved":
        return {"ok": True}

    # 2. Bind the approval to the exact pending action and its parameters
    action = pending_actions.get(payload["metadata"]["case_id"])
    if not action or action.amount != payload["metadata"]["amount"]:
        alert_security("approval does not match a pending action", payload["request_id"])
        return {"ok": True}

    # 3. One-time use: a request_id executes exactly once
    if not executed.add_if_absent(payload["request_id"]):
        return {"ok": True}

    # 4. Only now perform the action
    perform_action(action)
    return {"ok": True}

Payload shape

The primary fields for new webhook consumers are request_id, status, response, responded_by, responded_at, metadata, risk_level, policy_trigger, policy_context, and approval_comment_required.

structured_response duplicates the operator response in protocol terms, and protocol_response contains the full canonical Contro1Response for SDK adapters. If you are writing a simple handler, prefer response plus status.

webhook_payload.json
{
  "request_id": "req_abc123",
  "state": "answered",
  "status": "approved",
  "response": {
    "approved": true,
    "comment": "Approved for this customer."
  },
  "responded_by": "Ariel Navon",
  "responded_at": "2026-04-26T19:01:08.984Z",
  "metadata": {
    "case_id": "refund-8842"
  },
  "risk_level": "high",
  "policy_trigger": "Vendor transfers above $10,000 require finance approval.",
  "policy_context": {
    "source": "custom_rules",
    "policy_name": "finance-transfer-controls",
    "rule_id": "vendor-transfer-over-10000",
    "rule_reason": "Vendor transfers above $10,000 require finance approval.",
    "policy_version": "git:8f42c1a",
    "enforcement": "require_approval"
  },
  "approval_comment_required": true,
  "message": "Approved for this customer.",
  "structured_response": {
    "approved": true,
    "comment": "Approved for this customer."
  },
  "resolved_at": "2026-04-26T19:01:08.984Z",
  "protocol_response": {
    "request_id": "req_abc123",
    "status": "approved",
    "message": "Approved for this customer.",
    "structured_response": {
      "approved": true,
      "comment": "Approved for this customer."
    },
    "resolved_at": "2026-04-26T19:01:08.984Z"
  }
}

Verification example

verify.ts
import { verifyWebhook } from '@contro1/sdk';

const isValid = verifyWebhook(
  rawBody,
  req.headers['x-centcom-signature'],
  req.headers['x-centcom-timestamp'],
  process.env.CENTCOM_WEBHOOK_SECRET || ''
);

if (!isValid) {
  res.status(401).json({ error: 'Invalid signature' });
  return;
}
verify.py
import hmac, hashlib, time, os

def verify_webhook(body: bytes, signature: str, timestamp: str) -> bool:
    secret = os.environ["CENTCOM_WEBHOOK_SECRET"].encode()
    # timestamp = when THIS callback was sent, not when the request was
    # created. A decision that took days still arrives freshly signed
    # (every delivery and retry is re-signed), so long SLAs always pass.
    if abs(time.time() - int(timestamp)) > 300:
        return False  # stale = possible replay
    signed = f"{timestamp}.".encode() + body
    expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Delivery states

  • answered - the operator submitted a response
  • callback_pending - Contro1 is attempting to deliver the signed callback
  • callback_delivered - the callback URL returned a successful 2xx response
  • callback_failed - retries were exhausted or delivery failed permanently
  • closed - the request lifecycle is complete after successful callback delivery

Frequently asked questions

What should happen if callback delivery fails?

Your workflow should be able to recover by reading request state from the API and replaying the final decision safely.

Can I resume workflows synchronously instead of with a webhook?

You can poll in simple setups, but signed callbacks are the better pattern for long-running or multi-team production flows.

How many retries will Contro1 attempt?

Up to 5 retries with exponential backoff. Your handler should be idempotent - the same delivery ID may arrive more than once.

Do I need to respond quickly?

Return 200 within 10 seconds. If your downstream workflow is slow, acknowledge immediately and process asynchronously.

Does my server timezone affect signature verification?

No. The timestamp is Unix epoch seconds (UTC-based), which is identical everywhere in the world at the same instant - timezones are only a display layer. The reviewer timezone is irrelevant too: only the send time of the callback enters the signature. The one thing that can break verification is an actually-wrong clock: keep your server NTP-synced (cloud providers do this by default) so it stays within the 5-minute freshness window.

Does the 5-minute freshness window conflict with a long SLA?

No. The timestamp marks when the callback is sent, not when the request was created. A decision that takes hours or days still arrives freshly signed, and every retry is re-signed. The window only blocks replayed callbacks.