Approval and alert notifications
Send approval requests, decisions, revocations and budget alerts to a signed webhook, Slack, Microsoft Teams or Matrix, and let approvers approve or deny from chat.
When a rule holds a tool call for approval, someone has to notice. The MAQPNA gateway can tell them: it publishes governance events to notifiers, which deliver them to a signed webhook, Slack, Microsoft Teams or Matrix. In Slack and Teams, approvers can approve or deny from the message itself. Delivery is at least once, from an on-disk outbox that survives restarts.
Email is not a built-in channel. To send email, point a webhook notifier at a small relay that turns the signed JSON event into a message.
sequenceDiagram
participant A as Agent
participant G as Gateway
participant O as Outbox
participant N as Slack / Teams / webhook
participant P as Approver
A->>G: tools/call delete_resource
G->>G: rule says require_approval
G->>O: approval.created
O->>N: deliver (retry with backoff)
N->>P: message with Approve / Deny
P->>N: clicks Approve
N->>G: signed callback /v1/approvals/{id}/callback/{notifier}
G->>G: map chat user to approver, check roles
G-->>A: call runs, result returned
G->>O: approval.decided
When to use it#
- Approvers should not have to watch the console.
- You want a chat trail of decisions next to the audit ledger.
- Revocations and budget alerts should reach the on-call channel.
Events#
| Event | When |
|---|---|
approval.created |
A call is held for approval |
approval.partial |
One of several required approvals was recorded (four-eyes) |
approval.decided |
The approval was approved or denied |
approval.expired |
Nobody decided in time |
revocation.created |
A kill-switch revocation was created |
budget.threshold |
Spend crossed an alertAtPercent threshold below 100 % |
budget.exceeded |
A budget limit was reached |
audit.forward_refused |
A collector refused an edge gateway's ledger segment |
A notifier gets every event unless you list some in events.
Channels#
type |
Destination | Secret settings | Approve from chat |
|---|---|---|---|
webhook |
Any HTTPS endpoint; JSON event body | url, hmacSecretFile (signs the body) |
No (call the admin API) |
slack |
Slack incoming webhook; Block Kit message with Approve, Deny and Open buttons | webhookURLFile, signingSecretFile (Slack app signing secret, for callbacks) |
Yes |
teams |
Teams incoming webhook or workflow; Adaptive Card with Action.Execute approve and deny |
webhookURLFile, plus callbackOIDC (Bot Framework token) or hmacSecretFile (signed relay) for callbacks |
Yes, through a bot or relay |
matrix |
A Matrix room (PUT /_matrix/client/v3/rooms/{room}/send/...) |
homeserver, roomId, accessTokenFile |
No |
Incoming-webhook URLs embed a credential, so Slack and Teams read them from a file. The gateway never logs a notifier URL.
Prerequisites#
- MAQPNA installed with the Helm chart, and at least one
require_approvalrule (Human approvals). - For approve-from-chat: OIDC admin auth, so that chat users map to real approvers (Keycloak and OIDC).
- With
sovereignty.allowedEgressHostsset: the notifier hosts in that list. The gateway checks every notifier endpoint at start-up.
Step 1: Create the notifier Secret#
All notifier secrets go in one Secret, mounted at /etc/maqpna/notify:
kubectl create secret generic maqpna-notify -n maqpna-system \
--from-literal=hmac='<random-32-byte-hex>' \
--from-literal=slack-url='https://hooks.slack.com/services/<your-webhook-path>' \
--from-literal=slack-signing='<slack-signing-secret>'
Step 2: Configure the notifiers#
approvals:
consoleURL: https://console.example.eu # linked from every message
notifierSecret: maqpna-notify
outboxPath: /var/lib/maqpna/audit/notify-outbox.jsonl # the default
notifiers:
- name: ops
type: webhook
url: https://hooks.example.eu/maqpna
hmacSecretFile: /etc/maqpna/notify/hmac
- name: approvers
type: slack
webhookURLFile: /etc/maqpna/notify/slack-url
signingSecretFile: /etc/maqpna/notify/slack-signing
events: [approval.created, approval.decided, approval.expired]
userMap: # Slack user ID -> approver
U012ABCDEF: [email protected]
U034GHIJKL: {user: [email protected], groups: [maqpna-approvers], namespaces: [team-a]}
preview:
mode: redacted # none | hash | redacted | full
maxBytes: 4096
approvals.preview.mode controls how much of the tool-call arguments approvers see in the message. redacted masks obvious secrets; raw arguments are only stored in full mode.
Step 3: Enable approve-from-chat (Slack)#
In your Slack app, turn on Interactivity and set the request URL to:
https://gateway.example.eu/v1/notify/callback/approvers
The gateway checks Slack's X-Slack-Signature and X-Slack-Request-Timestamp with the signing secret, rejects requests outside a 5-minute replay window, maps the Slack user through userMap, and applies the same rules as the admin API: the approver must hold the approver role for the namespace, cannot approve their own calls, and cannot vote twice. The decision is recorded with issuer urn:maqpna:notifier:<name>.
For Teams, configure callbackOIDC (issuer, audience, optional jwksURL or discoveryURL) so the gateway verifies the Bot Framework token on Action.Execute invokes.
Step 4: Receive signed webhooks#
Each webhook delivery is a POST with the JSON event and these headers:
| Header | Value |
|---|---|
X-Maqpna-Event |
The event type, for example approval.created |
X-Maqpna-Delivery |
<event id>.<notifier>; stable across retries, use it to de-duplicate |
X-Maqpna-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>." + body)> |
Verify the signature and reject old timestamps:
import hashlib, hmac, time
def verify(secret: bytes, header: str, body: bytes, window: int = 300) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
t, sig = parts.get("t"), parts.get("v1")
if not t or not sig or abs(time.time() - int(t)) > window:
return False
want = hmac.new(secret, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, sig)
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret: Buffer, header: string, body: Buffer, window = 300): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=", 2)));
const t = parts["t"], sig = parts["v1"];
if (!t || !sig || Math.abs(Date.now() / 1000 - Number(t)) > window) return false;
const want = createHmac("sha256", secret).update(`${t}.`).update(body).digest("hex");
return want.length === sig.length && timingSafeEqual(Buffer.from(want), Buffer.from(sig));
}
The payload has id, type, time, consoleUrl, and for approval events an approval object (id, status, namespace, agent, session, user, server, tool, argsPreview, policy, rule, reason, minApprovers, approvers, decidedBy, createdAt, expiresAt). Other events carry data.
Delivery and retries#
Deliveries are queued in the outbox (approvals.outboxPath, mode 0600) and retried with exponential backoff from 1 second up to 5 minutes, at most 12 attempts (about one hour). Deliveries older than 24 hours are dropped. With the Postgres state backend, replicas share the outbox state, so an expiry is announced once.
Verify#
- Make a call that a
require_approvalrule holds (see Your first governed agent). - Check that the message arrives. The gateway logs
approval notifiers configuredat start-up with each notifier's name and type. - Approve from Slack and check the decision:
maqpna approvals get <id>.
Troubleshooting#
| Symptom | Cause | Fix |
|---|---|---|
Gateway does not start: notifiers[<name>]: webhookURLFile: ... |
The Secret key is missing or empty | Check approvals.notifierSecret and the file name under /etc/maqpna/notify |
| Start-up refuses a notifier endpoint | Host not allowed by the sovereignty policy | Add the host to sovereignty.allowedEgressHosts |
| Chat reply "You are not registered as a MAQPNA approver." | Chat user not in userMap |
Add the user ID |
| Chat reply "You are not an approver for namespace ..." | The mapped user lacks the approver role there | Fix adminAuth.roles or namespaceRoles, or userMap groups |
401 invalid callback signature |
Wrong Slack signing secret, or clock skew over 5 minutes | Check signingSecretFile; sync clocks |
409 replayed callback |
The same callback was sent twice | Nothing to do; the first one counted |
404 no interactive callback configured |
No signing secret (Slack) or callbackOIDC / HMAC (Teams) |
Configure callback verification |