Skip to main content
WebhookApprovalProvider sends one signed POST to a URL you own for every approval request, and takes the answer back through a signed POST to the gateway’s /ctrlrun/approvals/ endpoint. Your side is a small service that turns the request into a Slack message with two buttons and turns the click into the answer. The grant is written by the same call ctrlrun approve makes; there is no second approval path. Prerequisites: pip install ctrlrun (the outbound half is core); pip install "ctrlrun[gateway]" for the inbound endpoint, which the gateway serves; a shared secret; a URL that can receive the POST.
1

Configure the provider

In a process using the decorator:
With the gateway, the same thing is two flags:
The secret comes from $CTRLRUN_WEBHOOK_SECRET or a file, never from a flag value: a secret on a command line is in every process listing on the host. An http:// URL is refused unless --allow-insecure-webhook, and then only on loopback.
2

Receive the request

One POST per APPROVAL_REQUESTED, with a CTRLRun-Signature header. Verify it before you read the body. The header is two comma-separated fields:
t is unix seconds and v1 is the hex HMAC-SHA256, keyed on your secret, over the exact bytes f"{t}.{body}" — the body as sent, not re-serialized. Compare it with a constant-time comparison, and refuse a t outside your replay window (five minutes).
Show the human exactly what is in action: the name, every argument, who is asking. The approval will be bound to that hash, so what they see is what will run.
3

Send the answer back

POST to respond_to, signed the same way, with a timestamp within the replay window (five minutes by default):
All four are required. request_id must equal the one in the path, and action_hash must equal the one on the stored request — echo back what the request gave you. A body carrying only decision and approver is refused with the path and the body name different requests, which is the shape of this guide’s own earlier example.decision is grant or deny; approver is a non-empty string recorded on the receipt. A replayed grant inside the window is idempotent, because the record is already granted; outside it, the timestamp check refuses. The endpoint answers 200 on success and a 4xx with the reason on a bad signature, an unknown request id, a hash that does not match, an expired request, or a request already answered the other way.
4

Watch it land

If the human never answers, the request expires at expires_at (the approval TTL, fifteen minutes by default) and a waiting call raises ApprovalTimeout. Nothing runs.

What the provider does not do

It does not build the Slack message or the buttons; that is your service, which knows your workspace. It does not authenticate the approver: approver is recorded as given, which is the threat model’s stated limit. It does not retry into the future: delivery is two retries with a short backoff, and an undeliverable request is logged and left pending for ctrlrun approve.

If it didn’t work

  • signature does not verify: the secret differs, or your side re-serialized the body before signing. Sign the exact bytes.
  • timestamp outside the replay window: clocks differ by more than five minutes.
  • ApprovalTimeout: nobody answered within the TTL; the request is expired, not lost.
  • The webhook never fires: the provider is not on the Control the protected function uses. With the decorator, pass control=control.

Next