PINTI Docs

Approvals & Callbacks

When a spend intent exceeds the approval threshold, PINTI pauses execution and waits for human sign-off. This page explains the full approval lifecycle and how agents get notified.

When Does REQUIRE_APPROVAL Trigger?

If your policy has a requireApprovalOver threshold, any spend intent exceeding that amount will return REQUIRE_APPROVAL instead of ALLOW.

Example: Policy with $50 approval threshold
{
  "name": "Production Policy",
  "maxSingleAmount": 50000,
  "dailyLimit": 100000,
  "requireApprovalOver": 5000,
  "unit": "USD"
}

With this policy, amounts up to $50.00 (5000 minor units) are auto-allowed. Amounts above $50.00 require human approval.

Approval Lifecycle

Complete flow
1. Agent calls POST /spend/evaluate
   → Decision: REQUIRE_APPROVAL
   → No SAT issued

2. PINTI creates an Approval record (status: PENDING)
   → Webhook fired to your notification URL (if configured)
   → Email sent to workspace owner (if configured)

3. Human reviews in Dashboard → /approvals
   → Clicks "Approve" or "Reject"

4. On APPROVE:
   → SpendRequest.decision updated to ALLOW
   → SAT issued and stored
   → Agent callback URL called with SAT (if provided)
   → Webhook fired with approval.resolved event

5. On REJECT:
   → SpendRequest.decision updated to DENY
   → Agent callback URL called with DENY (if provided)
   → Webhook fired with approval.resolved event

Agent Integration: callbackUrl

The simplest way for an agent to receive approval results is to provide a callbackUrl in the evaluate request. When the approval is resolved, PINTI will POST the result to that URL.

1. Agent sends evaluate with callbackUrl
POST /api/v1/spend/evaluate
x-api-key: pinti_xxxxxxxx_...

{
  "agentId": "my-agent",
  "amountMinor": 15000,
  "unit": "USD",
  "merchant": "aws.amazon.com",
  "category": "cloud",
  "reason": "Scale up GPU instances",
  "callbackUrl": "https://my-agent.example.com/pinti/callback"
}
2. PINTI responds with REQUIRE_APPROVAL
{
  "decision": "REQUIRE_APPROVAL",
  "decisionReason": "REQUIRES_APPROVAL",
  "spendRequestId": "cm4abc123..."
}
3. After human approves, PINTI calls your callbackUrl
POST https://my-agent.example.com/pinti/callback

{
  "event": "approval.resolved",
  "spendRequestId": "cm4abc123...",
  "decision": "ALLOW",
  "sat": "eyJ2ZXJzaW9uIjox...",
  "satExpiresAt": "2026-02-25T12:05:00.000Z",
  "decidedAt": "2026-02-25T12:00:00.000Z"
}

Warning

Your callback endpoint must respond with a 2xx status within 5 seconds. PINTI fires the callback once — there is no automatic retry. If you miss it, use polling as a fallback.

Fallback: Polling

If you don't provide a callbackUrl, your agent can poll the spend request status:

Poll for approval status
GET /api/v1/spend-requests/:spendRequestId
x-api-key: pinti_xxxxxxxx_...

// Response when still pending:
{
  "decision": "REQUIRE_APPROVAL",
  "approval": { "status": "PENDING" }
}

// Response after approval (SAT included):
{
  "decision": "ALLOW",
  "approval": { "status": "APPROVED", "decidedAt": "..." },
  "sat": "eyJ2ZXJzaW9uIjox...",
  "satExpiresAt": "2026-02-25T12:05:00.000Z"
}

// Response after rejection:
{
  "decision": "DENY",
  "approval": { "status": "REJECTED", "note": "Too expensive" }
}

Tip

Use exponential backoff when polling. Start with 2s intervals, max 30s. A typical approval takes 1-5 minutes depending on the human reviewer.

Agent Callback Listener

Your agent needs a simple HTTP endpoint to receive approval callbacks. Here's a minimal example:

Node.js callback listener
import express from "express";

const app = express();
app.use(express.json());

// Store pending approvals in memory (use Redis/DB in production)
const pendingApprovals = new Map();

app.post("/pinti/callback", (req, res) => {
  const { event, spendRequestId, decision, sat } = req.body;

  if (event === "approval.resolved") {
    if (decision === "ALLOW" && sat) {
      // Approval granted — continue with payment
      console.log("Approved! SAT:", sat);
      // Resume the agent's workflow...
    } else {
      // Rejected — abort the operation
      console.log("Rejected for:", spendRequestId);
    }

    pendingApprovals.delete(spendRequestId);
  }

  res.status(200).json({ received: true });
});

app.listen(3001);

Workspace Webhooks

In addition to agent-level callbacks, you can configure a workspace-wide webhook URL in Settings. This webhook receives all approval events for your workspace.

Webhook Events

EventWhenUse Case
approval.pendingNew approval createdSend Slack/Telegram/email alert to reviewer
approval.resolvedApproval approved or rejectedLog resolution, trigger downstream workflows

Webhook Payload: approval.pending

POST to your webhookUrl
{
  "event": "approval.pending",
  "approval": {
    "id": "cm...",
    "spendRequestId": "cm...",
    "agentId": "my-agent",
    "amountMinor": 15000,
    "unit": "USD",
    "merchant": "aws.amazon.com",
    "category": "cloud",
    "reason": "Scale up GPU instances"
  },
  "dashboardUrl": "https://pinti.ai/approvals",
  "timestamp": "2026-02-25T11:55:00.000Z"
}

Webhook Payload: approval.resolved

POST to your webhookUrl
{
  "event": "approval.resolved",
  "approval": {
    "id": "cm...",
    "spendRequestId": "cm...",
    "status": "APPROVED",
    "decidedAt": "2026-02-25T12:00:00.000Z",
    "note": "Approved for this month"
  },
  "timestamp": "2026-02-25T12:00:01.000Z"
}

Tip

Connect your webhook URL to Telegram, Slack, or Discord using services like Zapier, Make, or n8n — or build a simple bot that receives the JSON payload and posts a message.

Recommended Pattern

For production agents, use both callback + polling:

Robust approval handling
async function evaluateWithApproval(intent) {
  const result = await pinti.evaluate({
    ...intent,
    callbackUrl: "https://my-agent.example.com/pinti/callback"
  });

  if (result.decision === "ALLOW") {
    return result.sat; // proceed immediately
  }

  if (result.decision === "DENY") {
    throw new Error("Spend denied: " + result.decisionReason);
  }

  // REQUIRE_APPROVAL — wait for callback or poll
  console.log("Waiting for human approval...");

  // Option A: Wait for callback (handled by your HTTP server)
  // Option B: Poll as fallback
  const sat = await pollForApproval(result.spendRequestId, {
    interval: 5000,   // 5 seconds
    timeout: 300000,  // 5 minutes
  });

  return sat;
}

async function pollForApproval(spendRequestId, { interval, timeout }) {
  const start = Date.now();

  while (Date.now() - start < timeout) {
    const status = await fetch(
      `https://pinti.ai/api/v1/spend-requests/${spendRequestId}`,
      { headers: { "x-api-key": API_KEY } }
    ).then(r => r.json());

    if (status.decision === "ALLOW") return status.sat;
    if (status.decision === "DENY") throw new Error("Rejected");

    await new Promise(r => setTimeout(r, interval));
  }

  throw new Error("Approval timed out");
}

Security Notes

  • Callback URLs must use HTTPS in production. HTTP callbacks are allowed only for localhost during development.
  • Webhook payloads are signed with your workspace's signing key (HMAC-SHA256). Verify the signature before processing.
  • SAT tokens returned in callbacks expire in 2 minutes. If the agent doesn't use the SAT in time, it can request a new one via POST /spend-requests/:id/issue-sat.