Payment outcomes
When you call pay(), Canopy evaluates the agent's policy and returns one of three outcomes: allowed, pending_approval, or denied. These are return values, not exceptions — your code handles them with a switch (TypeScript) or if/elif (Python).
HTTP and network failures still throw normally. This design lets the LLM read the outcome and decide what to do next, rather than crashing on a policy refusal.
allowed — payment went through
The payment was within policy and the on-chain transaction was submitted.
| Field | Type | Description |
|---|---|---|
status | "allowed" | Discriminator |
txHash | string | null | On-chain transaction hash on Base |
transactionId | string | null | Canopy's internal record ID |
costUsd | number | null | Actual USD debited |
Use txHash to link to a block explorer or confirm settlement.
pending_approval — waiting for human review
The payment exceeded the agent's approval threshold. The transaction is held until a human approves or denies it — in the dashboard, or in chat (the LLM calls canopy_approve / canopy_deny when the user replies "yes" / "no").
| Field | Type | Description |
|---|---|---|
status | "pending_approval" | Discriminator |
approvalId | string | Pass to waitForApproval(), getApprovalStatus(), approve(), or deny() |
transactionId | string | Canopy's internal record ID |
reason | string | Human-readable explanation |
recipientName | string | null | Resolved entity name (e.g., "Alchemy") for the LLM to use when asking the user |
recipientAddress | string | null | The on-chain address the payment would go to |
amountUsd | number | null | The amount the LLM should reference inline |
agentName | string | null | Which agent is asking (useful in multi-agent UX) |
expiresAt | string | null | ISO timestamp the approval auto-cancels at |
chatApprovalEnabled | boolean | If false, approve()/deny() will throw — direct the user to the dashboard |
You have three places to resolve a pending approval (all hit the same backend):
- Chat-native — the LLM asks the user using
recipientName/amountUsdand callscanopy.approve(approvalId)orcanopy.deny(approvalId)when the user replies. This is the default path throughcanopy.openai.dispatch()/canopy.anthropic.dispatch(): the rich fields land in the tool message, the LLM phrases the question, and the next-turncanopy_approvecall resolves the approval. waitForApproval(approvalId)— block until a decision is made (default 5-minute timeout). Useful for scripted agents.- Dashboard — an org admin reviews the pending-approvals list and clicks approve / deny. The agent doesn't have to do anything; whatever it does next will reflect the decision.
denied — policy blocked the payment
The policy rejected the payment before any transaction was attempted. Common reasons: spend cap exceeded, recipient not in allowlist, agent's policy is misconfigured.
| Field | Type | Description |
|---|---|---|
status | "denied" | Discriminator |
reason | string | Human-readable reason |
transactionId | string | Canopy's record ID (useful for support) |
A denied outcome costs nothing — no funds move and no on-chain transaction is created.
Handling outcomes in code
const result = await canopy.pay({ to: "0x4838B106FCe9647Bdf1E7877BF73cE8B0BAD5f97", amountUsd: 7.50 });
switch (result.status) {
case "allowed":
console.log("paid:", result.txHash);
break;
case "pending_approval":
const decision = await canopy.waitForApproval(result.approvalId);
console.log("decision:", decision.status); // "approved" | "denied" | "expired"
break;
case "denied":
console.log("blocked:", result.reason);
break;
}result = canopy.pay(to="0x4838B106FCe9647Bdf1E7877BF73cE8B0BAD5f97", amount_usd=7.50)
if result["status"] == "allowed":
print("paid:", result["tx_hash"])
elif result["status"] == "pending_approval":
decision = canopy.wait_for_approval(result["approval_id"])
print("decision:", decision["status"])
elif result["status"] == "denied":
print("blocked:", result["reason"])Polling instead of blocking
If you don't want to block on waitForApproval, poll getApprovalStatus:
let status = await canopy.getApprovalStatus(approvalId);
while (status.status === "pending") {
await new Promise((r) => setTimeout(r, 5_000));
status = await canopy.getApprovalStatus(approvalId);
}HTTP and network errors always throw — for example, a DNS failure or a 500. Only the three policy outcomes are return values. Wrap
pay()intry/catchto handle infrastructure errors separately.