> ## Documentation Index
> Fetch the complete documentation index at: https://ctrlrun.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Approvals in Slack via webhook

> The approval request goes out as one signed POST, a human answers in Slack, the answer comes back signed to the gateway's endpoint.

A refund above the desk limit should be answered in the channel where the support lead
already lives. `WebhookApprovalProvider` sends the request to your Slack service as one signed
POST; your service turns the button click into a signed POST back. This recipe runs the
inbound half in process, with the real signing and the real handler, so what a Slack service
must send is shown exactly.

## The policy

```yaml runnable theme={null}
schema: ctrlrun.policy/v2

actions:
  stripe.refund:
    effect: "refund:{payment_id}"
    rules:
      - when: { amount_gte: 0, amount_lte: 50000 }
        decision: allow
      - decision: approve
```

## The code

```python runnable file=main.py theme={null}
import json
from pathlib import Path

import ctrlrun
from ctrlrun import Control, Policy, SQLiteStateStore
from ctrlrun.webhook import handle_inbound, sign

HERE = Path(__file__).resolve().parent
STATE = HERE / ".ctrlrun"
STATE.mkdir(exist_ok=True)
for name in ("state.db", "state.db-wal", "state.db-shm"):
    (STATE / name).unlink(missing_ok=True)

SECRET = "shared-with-the-slack-service"  # from $CTRLRUN_WEBHOOK_SECRET in production
store = SQLiteStateStore(STATE / "state.db")
control = Control(Policy.from_file(HERE / "ctrlrun.yaml"), store)
refunds: list[int] = []


@ctrlrun.protect("stripe.refund", effect="refund:{payment_id}", control=control)
def refund(payment_id: str, amount: int) -> str:
    refunds.append(amount)
    return "refunded"


with ctrlrun.context(agent="support-agent"):
    try:
        refund(payment_id="txn_5", amount=250000)
    except ctrlrun.ApprovalRequired as pending:
        request_id = pending.request_id
        print("€2,500 refund: request", request_id[:4] + "…", "goes to Slack")
    else:
        raise SystemExit("a €2,500 refund ran without a human")

    # What the Slack service POSTs to /ctrlrun/approvals/<request_id> when the lead clicks. It
    # echoes the action hash it showed the human: an answer for a different hash is refused.
    shown = store.get_approval(request_id).request.action_hash
    answer = json.dumps(
        {
            "request_id": request_id,
            "action_hash": shown,
            "decision": "grant",
            "approver": "slack:dana",
        }
    ).encode()
    status, message = handle_inbound(store, request_id, answer, sign(answer, SECRET), secret=SECRET)
    print("the lead approves in Slack:", status, message)

    # A forged answer, signed with the wrong secret, changes nothing.
    forged = json.dumps(
        {
            "request_id": request_id,
            "action_hash": shown,
            "decision": "grant",
            "approver": "slack:nobody",
        }
    ).encode()
    status, message = handle_inbound(
        store, request_id, forged, sign(forged, "guess"), secret=SECRET
    )
    print("a forged answer:", status, message)
    if status == 200:
        raise SystemExit("a forged answer was accepted")

    with ctrlrun.with_approval(request_id):
        print("€2,500 refund, approved in Slack:", refund(payment_id="txn_5", amount=250000))

print("refunds:", refunds)
```

## What the agent sees

```text theme={null}
€2,500 refund: request apr_… goes to Slack
the lead approves in Slack: 200 ok
a forged answer: 400 the signature did not verify inside the replay window
€2,500 refund, approved in Slack: refunded
refunds: [250000]
```

The answer names the request, the hash the human saw, the decision and the approver; the approver is what the receipt records. The signature is an HMAC-SHA256 over
`timestamp.body` on the exact bytes, and a timestamp outside the five-minute window is refused
even with the right secret.

## The receipt

```bash runnable theme={null}
ctrlrun receipts --last 1
```

`approve/committed`, approver `slack:dana`. The same shape as an approval given with
`ctrlrun approve`, because it went through the same call.

## When an AMBIGUOUS appears

Approvals never make an effect ambiguous; the remote does. If the approved refund's reply is
lost, the effect is `AMBIGUOUS` and the approval is already spent: resolve the effect with
`ctrlrun resolve refund:txn_5 --committed` or `--failed`. On `--failed` a retry needs a new
approval, because the old one authorized one execution.

## Next

* [Approve in Slack](/guides/approvals-in-slack): the outbound half, the payload and the flags.
* [Approval binding](/concepts/approval-binding) · [Get started](/get-started/quickstart) · [Why](/why).


## Related topics

- [Cookbook](/cookbook/index.md)
- [Approve in Slack](/guides/approvals-in-slack.md)
- [Approval binding](/concepts/approval-binding.md)
- [WebhookApprovalProvider](/reference/api/WebhookApprovalProvider.md)
- [Not only agents](/not-only-agents.md)
