Skip to content
MeridFlow AiFlow v8.x • self-hosted

Approvals

Reviews and decides on PendingApproval rows: Orchestrator tool calls deferred behind requires_approval instead of running immediately. See that page for how a tool ends up here in the first place; this page covers listing pending requests and deciding them.

Gated behind a deployment setting, Admin session token required

Same two gates as Orchestrator tools: every endpoint here only exists when your deployment has Orchestrators turned on, and every endpoint here requires an admin session token with Admin or Owner, not just any logged-in admin. Deciding a pending request is exactly as consequential as turning requires_approval on in the first place, so it's held to the same tier.

Listing pending approvals

GET /api/v1/pending-approvals
Parameter Type Required Default Description
status_filter "pending" | "approved" | "rejected" No none (unfiltered) Restrict results to one status.
orchestrator_id integer No none (unfiltered) Restrict results to one Orchestrator's own requests.

Results are always ordered newest first (by id descending). Both filters can be combined (as an AND).

curl "https://api.your-domain.com/api/v1/pending-approvals?status_filter=pending" \
  -H "Authorization: Bearer $ADMIN_TOKEN"
import httpx

response = httpx.get(
    "https://api.your-domain.com/api/v1/pending-approvals",
    headers={"Authorization": f"Bearer {admin_token}"},
    params={"status_filter": "pending"},
)
response.raise_for_status()
approvals = response.json()
const response = await fetch(
  "https://api.your-domain.com/api/v1/pending-approvals?status_filter=pending",
  { headers: { Authorization: `Bearer ${adminToken}` } },
);
const approvals = await response.json();
[
  {
    "id": 12,
    "orchestrator_id": 3,
    "tool_name": "send_email",
    "arguments": {
      "to": "customer@example.com",
      "subject": "Your refund",
      "body_html": "<p>Your refund has been approved.</p>"
    },
    "status": "pending",
    "result": null,
    "note": null,
    "decided_at": null,
    "decided_by_admin_id": null,
    "created_at": "2026-01-15T10:12:00Z"
  }
]

Fields

Field Type Description
id integer The request's id, used in the approve and reject paths below.
orchestrator_id integer Which Orchestrator's run created this request.
tool_name string Which tool was called, e.g. send_email.
arguments object The exact keyword arguments the model supplied to the tool call, unmodified. Approving replays the real tool with exactly these arguments.
status string pending, approved, or rejected.
result string or null The real tool's own return string, populated only once approved and actually executed. Stays null for a rejected or still-pending request.
note string or null An optional note attached when rejecting. Never set by approving.
decided_at datetime or null When an Admin approved or rejected this request. null while still pending.
decided_by_admin_id integer or null Which admin decided it. null while still pending, and stays null forever if that admin account is later deleted (see Audit log's same admin_id nullability).
created_at datetime When the tool call was queued.

Approving a request

POST /api/v1/pending-approvals/{approval_id}/approve

Rebuilds the real tool the same way the agent-tree builder would have, and actually invokes it with the exact arguments above. This is not a status change alone, it's the deferred action genuinely happening, for real, for the first time.

curl -X POST https://api.your-domain.com/api/v1/pending-approvals/12/approve \
  -H "Authorization: Bearer $ADMIN_TOKEN"
import httpx

response = httpx.post(
    "https://api.your-domain.com/api/v1/pending-approvals/12/approve",
    headers={"Authorization": f"Bearer {admin_token}"},
)
response.raise_for_status()
approval = response.json()
const response = await fetch(
  "https://api.your-domain.com/api/v1/pending-approvals/12/approve",
  {
    method: "POST",
    headers: { Authorization: `Bearer ${adminToken}` },
  },
);
const approval = await response.json();

Returns the updated row, with status: "approved", result populated with whatever the real tool returned, and decided_at/ decided_by_admin_id set. A nonexistent approval_id gets a 404; a request that isn't pending anymore (already approved or rejected by someone else) gets a 409, never a silent double-execution.

Rejecting a request

POST /api/v1/pending-approvals/{approval_id}/reject
Field Type Required Default Description
note string or null No null An optional reason, shown on the row.

Never invokes the real tool. result stays null forever for a rejected request.

curl -X POST https://api.your-domain.com/api/v1/pending-approvals/12/reject \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -d '{"note": "Refund amount looks wrong, following up with the customer first."}'
import httpx

response = httpx.post(
    "https://api.your-domain.com/api/v1/pending-approvals/12/reject",
    headers={"Authorization": f"Bearer {admin_token}"},
    json={"note": "Refund amount looks wrong, following up with the customer first."},
)
response.raise_for_status()
approval = response.json()
const response = await fetch(
  "https://api.your-domain.com/api/v1/pending-approvals/12/reject",
  {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${adminToken}`,
    },
    body: JSON.stringify({
      note: "Refund amount looks wrong, following up with the customer first.",
    }),
  },
);
const approval = await response.json();

Same 404/409 behavior as approving, for the same reasons.

Being told a request is waiting

A request sits until somebody looks at it, so a deployment can be told the moment one is queued instead of discovering it later. The approval.requested webhook fires either way, but that only helps when something is listening for it. These channels reach a person.

GET /api/v1/approval-notifications returns the current settings, and PUT updates them. Both take an admin session token with Admin or Owner, the same tier as deciding a request. Every channel is off until turned on, and any combination can run at once.

Field Type Notes
email_enabled boolean Off by default.
email_address string Falls back to the deployment's REQUEST_APPROVAL_EMAIL.
telegram_enabled boolean Off by default.
telegram_chat_id string Falls back to the deployment's TELEGRAM_CHAT_ID.
telegram_bot_token string Write-only. Falls back to TELEGRAM_BOT_TOKEN. Send "" to clear it.
whatsapp_enabled boolean Off by default.
whatsapp_to_number string The number to message.
whatsapp_from_number string The E.164 WhatsApp-enabled number to send from.
whatsapp_content_sid string An approved Content Template. Required; see the warning below.
call_enabled boolean Off by default.
call_to_number string The number to call.
call_from_number string The E.164 Twilio number to call from.

A field left out of a PUT is unchanged, so one channel can be edited without resending the rest. telegram_bot_token is never returned; the response carries telegram_bot_token_set instead, so a client can show whether one is stored without being able to read it.

curl -X PUT https://api.your-domain.com/api/v1/approval-notifications       -H "Content-Type: application/json"       -H "Authorization: Bearer $ADMIN_TOKEN"       -d '{"email_enabled": true, "email_address": "ops@example.com"}'
import httpx

response = httpx.put(
    "https://api.your-domain.com/api/v1/approval-notifications",
    headers={"Authorization": f"Bearer {admin_token}"},
    json={"call_enabled": True, "call_to_number": "+15551234567",
          "call_from_number": "+15550001111"},
)
response.raise_for_status()
const response = await fetch("https://api.your-domain.com/api/v1/approval-notifications", {
  method: "PUT",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${adminToken}`,
  },
  body: JSON.stringify({ telegram_enabled: true, telegram_chat_id: "-1001234567890" }),
});

WhatsApp needs an approved template, not just a number

A notification is business-initiated, which WhatsApp only permits through a Content Template Meta has approved. A whatsapp_to_number without a whatsapp_content_sid sends nothing. The template is called with the request id as {{1}} and the tool name as {{2}}. The other three channels need no such approval.

The call channel speaks a short announcement and hangs up. It is not a conversation: no agent, no transcript, and no Call record, because there is nobody on the other end to talk to.

A channel that is on but incompletely configured logs and is skipped rather than failing the approval. Queuing a request must never depend on a notification succeeding.

From the command line, scripts/set_approval_notifications.py edits the same settings:

python -m scripts.set_approval_notifications --email ops@example.com
python -m scripts.set_approval_notifications --show

In the dashboard, the Approvals page's Notify me on request button opens the same settings.

Next

  • Orchestrator tools: how a tool gets set to requires_approval in the first place.
  • Audit log: every queue and every decision made here is also recorded there.
  • Webhook subscriptions: the approval.requested event, which fires whatever the notification channels above are set to.