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.
{
"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
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 eventAgent 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.
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"
}{
"decision": "REQUIRE_APPROVAL",
"decisionReason": "REQUIRES_APPROVAL",
"spendRequestId": "cm4abc123..."
}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
Fallback: Polling
If you don't provide a callbackUrl, your agent can poll the spend request 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
Agent Callback Listener
Your agent needs a simple HTTP endpoint to receive approval callbacks. Here's a minimal example:
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
| Event | When | Use Case |
|---|---|---|
approval.pending | New approval created | Send Slack/Telegram/email alert to reviewer |
approval.resolved | Approval approved or rejected | Log resolution, trigger downstream workflows |
Webhook Payload: approval.pending
{
"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
{
"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
Recommended Pattern
For production agents, use both callback + polling:
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.