Skip to main content
A payment is a USDC transfer signed by an agent’s authorization key, validated against the permission you approved for that agent, and broadcast to Base. Every payment passes through two independent enforcement layers before it reaches the chain — neither of which Tally alone can override.

Mental model

A tally.payments.create() call is an instruction: “transfer this much USDC from this wallet to this address, attributed to this agent.” Everything from there is enforcement and accounting. Two-layer enforcement is the load-bearing part:
  1. Tally pre-checks the request against the policy — per-tx max, recipient and contract allowlists, expiry, and the rolling daily cap. If anything fails, the payment is rejected before any Privy call.
  2. Privy’s secure enclave independently re-checks the per-tx max and the allowlists when it signs. A compromised Tally server can’t sneak a payment past the enclave, because the enclave doesn’t trust Tally — it trusts the policy the user signed.
The payment is also written to Tally’s transaction log atomically with the broadcast, so per-agent attribution and audit trail are immediate.

Sending a payment

const payment = await tally.payments.create({
  agent_id: "research-bot",
  wallet: "0x7a3fA1B9c4D2e6F8a0B1c2D3e4F5a6B7c8D9e0b21c",
  to: "0xC0fee04...",
  amount_usdc: "4.50",
  memo: "arxiv API access",          // optional, indexed for search
  idempotency_key: "invoice-2026-04-117", // optional, see below
});

console.log(payment.status, payment.tx_hash);
// → "pending" 0xabc...
Inputs:
FieldRequiredDescription
agent_idYesThe agent’s stable id. Must have an active grant on wallet.
walletYesSender wallet address. Must be in the API key’s account + mode.
toYesRecipient EVM address.
amount_usdcYesDecimal string, e.g. "10" or "2.50". Up to 6 decimal places.
memoNoUp to 200 chars. Indexed; useful for invoice/order references.
idempotency_keyNoUp to 64 chars. Scoped to (account, mode).
create returns immediately once Privy accepts the signed RPC. It doesn’t wait for block confirmation — see waiting for confirmation below.

Statuses

StatusMeaning
pendingSigned by Privy and broadcast to the network. Awaiting block confirmation.
confirmedMined and indexed by Tally.
failedThe transaction reverted on-chain, or Privy rejected the signing request.
A payment rejected by Tally’s pre-check (allowance exceeded, recipient not allowed, etc.) does not become a failed payment — it raises a ValidationError from create() and no record is written. The transaction log only contains things that made it past pre-check.

Idempotency

Pass idempotency_key to make retries safe across network failures:
await tally.payments.create({
  agent_id: "research-bot",
  wallet,
  to,
  amount_usdc: "4.50",
  idempotency_key: "invoice-2026-04-117",
});
Two calls with the same idempotency_key within the same (account, mode) return the original Payment without resubmitting on-chain. Use deterministic keys derived from your own data — invoice numbers, request IDs, anything that won’t change on retry. Avoid timestamps or UUIDs you regenerate, since those let a retry accidentally double-spend.

Waiting for confirmation

create() returns a pending payment with the broadcast tx_hash. To wait for the chain to confirm, you have two options.

Poll with payments.get()

let p = await tally.payments.create({ ... });

while (p.status === "pending") {
  await new Promise((r) => setTimeout(r, 2_000));
  p = await tally.payments.get(p.id);
}

if (p.status === "failed") {
  // handle on-chain failure
}
payments.get() lazily refreshes from the chain if Tally hasn’t yet caught up, so polling is the canonical way to wait. Base Sepolia confirmations are typically a few seconds; Base mainnet is similar.

Subscribe to webhooks

For production workloads, prefer webhooks — they remove the polling latency and free up your runtime. Subscribe to payment.confirmed and payment.failed events; the webhook payload includes the same Payment shape you got back from create(). See Webhooks for the subscription mechanics and signature verification.

Receiving USDC

Agent wallets receive USDC the same way any Ethereum-compatible wallet does — send to the address. Tally indexes inbound transfers automatically and emits inbound.received webhook events, attributed to the receiving wallet’s account. Wallet addresses are visible in the dashboard. Share the address with the sender out-of-band, or record it from your own UI when you create the wallet. Inbound transfers don’t require a grant — anyone can send to a wallet. They’re attributed in your dashboard to the receiving wallet (and, by extension, the account that owns it).

Failures and retries

Tally retries network-level failures (RPC flakes, timeouts hitting Privy) internally — you won’t see those bubble up. Application-level failures (a revert because the recipient is invalid, the chain rejected the gas estimate, the policy edge case Privy enforced server-side) surface as status: "failed". You won’t be billed for failed transactions. The transaction record stays in the log so audit and reconciliation aren’t affected.

What’s not yet implemented

  • Refund flow — sending USDC back to the sender on-chain is just another payment, but a first-class payments.refund() that handles the bookkeeping is on the roadmap.
  • Multi-asset support — USDC only today. The architecture supports other ERC-20s via the contract allowlist, but the SDK and dashboard are USDC-shaped.