Canopy

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.

FieldTypeDescription
status"allowed"Discriminator
txHashstring | nullOn-chain transaction hash on Base
transactionIdstring | nullCanopy's internal record ID
costUsdnumber | nullActual 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").

FieldTypeDescription
status"pending_approval"Discriminator
approvalIdstringPass to waitForApproval(), getApprovalStatus(), approve(), or deny()
transactionIdstringCanopy's internal record ID
reasonstringHuman-readable explanation
recipientNamestring | nullResolved entity name (e.g., "Alchemy") for the LLM to use when asking the user
recipientAddressstring | nullThe on-chain address the payment would go to
amountUsdnumber | nullThe amount the LLM should reference inline
agentNamestring | nullWhich agent is asking (useful in multi-agent UX)
expiresAtstring | nullISO timestamp the approval auto-cancels at
chatApprovalEnabledbooleanIf 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):

  1. Chat-native — the LLM asks the user using recipientName / amountUsd and calls canopy.approve(approvalId) or canopy.deny(approvalId) when the user replies. This is the default path through canopy.openai.dispatch() / canopy.anthropic.dispatch(): the rich fields land in the tool message, the LLM phrases the question, and the next-turn canopy_approve call resolves the approval.
  2. waitForApproval(approvalId) — block until a decision is made (default 5-minute timeout). Useful for scripted agents.
  3. 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.

FieldTypeDescription
status"denied"Discriminator
reasonstringHuman-readable reason
transactionIdstringCanopy'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() in try/catch to handle infrastructure errors separately.