Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.tallyforagents.com/llms.txt

Use this file to discover all available pages before exploring further.

The tally.payments resource is where money moves. See Payments (concept) for the two-layer enforcement model and statuses.

Types

Payment

type Payment = {
  id: string;
  /** "pending" until the on-chain tx confirms; updated via get() or webhook. */
  status: "pending" | "confirmed" | "failed";
  /** Transaction hash. Set as soon as Privy accepts the signed RPC. */
  tx_hash: string | null;
  amount_usdc: string;
  to: string;
  from: string;
  memo: string | null;
  idempotency_key: string | null;
  created_at: string;
};

PaymentCreateInput

type PaymentCreateInput = {
  /** Agent's user-provided id, e.g. "research-bot". */
  agent_id: string;
  /** Sender wallet address. Must be in this API key's (account, mode)
   *  and must have an active grant for the agent. */
  wallet: string;
  /** Recipient EVM address. */
  to: string;
  /** Decimal USDC amount as a string, e.g. "10" or "2.50". */
  amount_usdc: string;
  /** Optional memo, max 200 chars. Stored alongside the transaction. */
  memo?: string;
  /** Optional idempotency key, max 64 chars. Scoped to (account, mode). */
  idempotency_key?: string;
};

Methods

payments.create(input)

Signs and broadcasts a USDC transfer. Returns once Privy accepts the signed RPC — does not wait for chain confirmation.
const payment = await tally.payments.create({
  agent_id: "research-bot",
  wallet: "0x7a3f...",
  to: "0xC0fee...",
  amount_usdc: "4.50",
  memo: "arxiv API access",
  idempotency_key: "invoice-2026-04-117",
});

console.log(payment.status, payment.tx_hash);
// → "pending" 0xabc...
Parameters All fields are validated server-side. amount_usdc accepts up to 6 decimal places (USDC’s native precision); higher precision is rejected. Returns: Promise<Payment> with status: "pending" and a populated tx_hash. Enforcement layers (in order):
  1. Tally pre-check. Validates the agent has an active grant on wallet, that the policy allows this payment (per-tx max, recipient/contract allowlist, expiry, daily cap), and that the API key is in the right mode. Failures throw ValidationError with structured error.code.
  2. Privy enclave. Independently re-checks the per-tx max and allowlists when signing. Failures bubble up as status: "failed" on the resulting Payment — there’s no record-less rejection at this layer.
Throws
ErrorWhen
ValidationErrorInput shape failed Zod validation. err.code may be amount_invalid or address_invalid.
NotFoundErrorThe agent, wallet, or grant doesn’t exist in this (account, mode). err.code: agent_not_found, wallet_not_found, or permission_not_found.
AuthenticationErrorThe API key is invalid, or a policy check rejected the payment (per-tx max, daily cap, recipient/contract allowlist, expiry). The SDK class is named for auth because the server returns 403/forbidden; inspect err.code to distinguish policy violations from real auth failures.
RateLimitErrorThe account is over its payments-per-minute ceiling (30/min). Honor Retry-After.
TallyErrorerr.code === "privy_call_failed" if Privy’s API call failed. Retry with the same idempotency key.

payments.get(id)

Fetches the current state of a payment by id. If still pending on Tally’s side, this call lazily refreshes from the chain — so polling get is the canonical way to wait for confirmed or failed.
const p = await tally.payments.get("pmt_01HXYZ...");
Returns: Promise<Payment> reflecting the latest known state. If the chain has confirmed since the last call, status will already be flipped. Throws: NotFoundError, AuthenticationError.

Idempotency

idempotency_key makes retries safe across network failures:
async function chargeWithRetry() {
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      return await tally.payments.create({
        agent_id,
        wallet,
        to,
        amount_usdc,
        idempotency_key: `invoice-${invoiceId}`, // deterministic
      });
    } catch (err) {
      if (err instanceof RateLimitError) await sleep(1000 * 2 ** attempt);
      else throw err;
    }
  }
}
Two create() calls with the same idempotency_key within the same (account, mode) return the original Payment without resubmitting on-chain. The payload is not revalidated — if your second call passes a different to or amount_usdc, the server still returns the original payment as written the first time. Make sure your key derivation is tied to the payload (e.g. include the invoice id in the key), not just the operation. Use deterministic keys derived from your own data — invoice IDs, request IDs, anything stable. Avoid timestamps or fresh UUIDs; those defeat the purpose by making every retry a fresh request.

Waiting for confirmation

create() returns pending. The chain confirms a few seconds later. Two ways to wait:

Poll

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 the on-chain failure.
}
payments.get() lazily refreshes from the chain when called on a pending payment, so polling it is the right shape. Don’t poll faster than every 2 seconds; you’ll burn rate-limit budget without seeing a meaningfully fresher status.

Subscribe to webhooks

For production, prefer webhooks. Subscribe to payment.confirmed and payment.failed and use the SDK’s verifier — see Webhooks.

Error codes

payments.create() can fail with one of these structured err.code values. The first column tells you which SDK exception class wraps the response — branch on instanceof first, then on err.code.
err.codeSDK classHTTPMeaning
agent_not_foundNotFoundError404No agent with that id in this (account, mode).
wallet_not_foundNotFoundError404The wallet address isn’t owned by this account or wrong mode.
permission_not_foundNotFoundError404The agent has no active grant on this wallet.
amount_invalidValidationError400Malformed amount_usdc (non-decimal, >6 decimals, etc).
address_invalidValidationError400to or wallet is not a valid EVM address.
amount_too_largeAuthenticationError403Exceeds the grant’s per-transaction max.
daily_cap_exceededAuthenticationError403Would push the rolling 24h spend over the grant’s daily cap.
recipient_not_allowedAuthenticationError403to is not in the grant’s recipient allowlist.
contract_not_allowedAuthenticationError403The destination contract isn’t on the policy’s contract allowlist (USDC is the default).
permission_expiredAuthenticationError403The grant’s expires_at has passed.
privy_call_failedTallyError500Privy’s API failed during signing. Retry with the same idempotency key.
The five 403/forbidden codes are policy violations, not auth failures — surface them in your UI as “this permission doesn’t allow that,” not “your credentials are bad.” The SDK wraps them as AuthenticationError because that’s what 403 maps to in the class hierarchy; the semantic distinction lives in err.code.

Not yet in the SDK

  • payments.list({ filters? }) — cursor-paginated list with status/agent/direction filters. Currently dashboard-only.
  • payments.refund(id) — first-class refund flow. Today, send a fresh payment in the reverse direction.
Tracked in BUILD_LOG.md.