openclawapprovalswebhooksslackdiscordmcp

Approval Webhooks for OpenClaw Agents — Get Pinged When Something Needs Your Attention

Your OpenClaw agent is blocked waiting for approval and nobody refreshed the dashboard. The new mcp.approval_required and mcp.approval_decided webhook events fix that — Slack, Discord, PagerDuty, anything HTTP.

secr team·

The MCP gateway's approval queue has been working since the OpenClaw integration shipped. You add a require_approval rule for github.delete_repo. The agent invokes it. The call queues, and… sits there. Until somebody happens to open the secr dashboard and notice.

That's the gap this post closes. Two new webhook events fire automatically the moment a tool call needs attention, and again when an admin decides:

  • mcp.approval_required — emitted when a call enters the pending queue
  • mcp.approval_decided — emitted when an admin clicks Approve or Deny

Both events ride the existing secr webhook infrastructure: HMAC-signed deliveries, retry-on-failure, scoped per project/environment if you want. If you can subscribe to secret.created, you can subscribe to these.

Subscribing

In the dashboard, WebhooksAdd webhook. Pick the events:

☑ mcp.approval_required
☑ mcp.approval_decided

Set the URL to wherever you want pings delivered. The signing secret is shown once on creation; verify HMAC-SHA256 on incoming requests against the X-Secr-Signature header.

Payload shape

mcp.approval_required:

{
  "event": "mcp.approval_required",
  "timestamp": "2026-05-09T11:42:18.293Z",
  "org": { "id": "..." },
  "callId": "8e0b3...",
  "agent": { "id": "agent-uuid", "name": "support-bot" },
  "tool": {
    "name": "github.delete_repo",
    "parameters": { "repo": "acme/old-staging" }
  },
  "environmentSlug": "production",
  "projectSlug": "support",
  "source": {
    "ipAddress": "52.x.y.z",
    "userAgent": "openclaw/1.2.3"
  },
  "dashboardUrl": "https://secr.dev/dashboard/mcp-gateway?tab=pending&id=8e0b3..."
}

The parameters block is redacted server-side — sensitive keys (token, password, value, etc.) are replaced with [REDACTED] before the webhook fires.

mcp.approval_decided:

{
  "event": "mcp.approval_decided",
  "timestamp": "2026-05-09T11:43:55.012Z",
  "org": { "id": "..." },
  "callId": "8e0b3...",
  "agent": { "id": "agent-uuid" },
  "tool": { "name": "github.delete_repo" },
  "decision": "approve",
  "decidedBy": { "id": "user-uuid", "email": "alice@acme.com" },
  "decidedAt": "2026-05-09T11:43:55.012Z",
  "note": null
}

Slack incoming webhook

The most common destination. Create a Slack app with the incoming-webhook scope, install it to your workspace, copy the webhook URL. Then add a tiny relay at <your-domain>/relay/secr-to-slack that maps the secr payload to Slack's format.

Or — even simpler — go direct from secr to a serverless function (Cloudflare Workers, Vercel Functions, Lambda) that does the format translation:

// Cloudflare Worker — secr webhook → Slack channel
export default {
  async fetch(req, env) {
    const sig = req.headers.get("x-secr-signature");
    const body = await req.text();
    if (!verifyHmac(body, sig, env.SECR_WEBHOOK_SECRET)) {
      return new Response("invalid signature", { status: 401 });
    }
    const payload = JSON.parse(body);
    if (payload.event !== "mcp.approval_required") {
      return new Response("ok"); // ignore other events
    }

    const text = `🛑 *Approval needed: \`${payload.tool.name}\`*\n` +
      `Agent: ${payload.agent.name}\n` +
      `Environment: ${payload.environmentSlug ?? "—"}\n` +
      `<${payload.dashboardUrl}|Open in dashboard>`;

    await fetch(env.SLACK_WEBHOOK_URL, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ text }),
    });
    return new Response("ok");
  }
};

function verifyHmac(body, sigHeader, secret) {
  const [, sig] = sigHeader.split("=");
  const computed = crypto
    .createHmac("sha256", secret)
    .update(body)
    .digest("hex");
  return sig === computed;
}

Five minutes of setup. Works.

Discord webhook

Discord webhooks accept content (plain) or embeds (rich). Embeds look better for approval messages because you get colour-coding (red for required, green for approved, etc):

const embed = {
  title: "🛑 Approval needed",
  description: `Tool: \`${payload.tool.name}\``,
  color: 0xff6b6b, // red
  fields: [
    { name: "Agent", value: payload.agent.name, inline: true },
    { name: "Environment", value: payload.environmentSlug ?? "—", inline: true },
    { name: "Source IP", value: payload.source.ipAddress ?? "—", inline: true },
  ],
  url: payload.dashboardUrl,
  timestamp: payload.timestamp,
};

await fetch(DISCORD_WEBHOOK_URL, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ embeds: [embed] }),
});

You don't get an Approve button — Discord webhooks are output-only. For interactive buttons, use a bot. Or use Telegram, which we wrote about separately.

PagerDuty for production-only critical tools

If you've marked production as approval-required, you might want to escalate via PagerDuty rather than just notify Slack:

await fetch("https://events.pagerduty.com/v2/enqueue", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    routing_key: PAGERDUTY_INTEGRATION_KEY,
    event_action: "trigger",
    dedup_key: payload.callId,
    payload: {
      summary: `OpenClaw approval needed: ${payload.tool.name}`,
      source: payload.agent.name,
      severity: "warning",
      custom_details: {
        environment: payload.environmentSlug,
        source_ip: payload.source.ipAddress,
        dashboard_url: payload.dashboardUrl,
      },
    },
  }),
});

The dedup_key: payload.callId ensures you don't get a paged storm if the agent retries. When the call is decided, listen for mcp.approval_decided and resolve the incident:

if (payload.event === "mcp.approval_decided") {
  await fetch("https://events.pagerduty.com/v2/enqueue", {
    method: "POST",
    body: JSON.stringify({
      routing_key: PAGERDUTY_INTEGRATION_KEY,
      event_action: "resolve",
      dedup_key: payload.callId,
    }),
  });
}

Custom HTTP endpoint — if you have your own ops tooling

Treat the secr payload as authoritative. Verify signature, switch on event, route. The shape is stable; we'll add fields for new features (we add never remove), so feel free to ignore unknown keys.

Idempotency

Both events fire once per state transition. Webhook delivery has retries (HTTP 5xx triggers up to 3 retries with exponential backoff), so design your handler to be idempotent. Use payload.callId + payload.event as the dedup key. If your downstream system is non-idempotent (creating tickets, paging, etc.), maintain a small "seen" cache.

Security

  • All deliveries are HMAC-SHA256 signed against the secret you got at webhook creation. Verify it. Don't accept unsigned posts.
  • X-Secr-Timestamp is included — reject deliveries older than ~5 minutes to prevent replay.
  • X-Secr-Delivery-Id is unique per attempt — useful for logging and dedup.

What's next

Once you've got webhook delivery working end-to-end, the natural next step is interactive approvals — clicking Approve/Deny from the notification itself. Slack and Discord need a full bot for that; Telegram supports it natively via inline keyboards. We covered the Telegram path in Approve OpenClaw tool calls from Telegram.

Either way, the goal is the same: when the agent stops, a human knows, decides, and the agent moves on. Without anyone refreshing a dashboard.

Ready to get started?

Stop sharing secrets over Slack. Get set up in under two minutes.

Create your account