> ## 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.

# Payments

> tally.payments — send USDC and inspect transaction state.

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

## Types

### `Payment`

```ts theme={null}
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;
  /** Destination address (external recipient for outbound; your wallet for inbound). */
  to: string;
  /** Source address (your wallet for outbound; external sender for inbound). */
  from: string;
  memo: string | null;
  idempotency_key: string | null;
  created_at: string;

  // Populated on responses from `list()`:
  direction?: "inbound" | "outbound";
  /** Your wallet's address — always YOUR wallet regardless of direction. */
  wallet_address?: string;
  wallet_display_name?: string;
  agent_id?: string | null;
  block_timestamp?: string | null;
  /** Human-readable reason for `status: "failed"`. */
  error_reason?: string | null;
};
```

The fields below the comment are populated on responses from `list()` (which includes inbound payments). `create()` and `get()` only describe outbound payments so they emit the slim shape.

### `PaymentListFilters`

```ts theme={null}
type PaymentListFilters = {
  status?: "pending" | "confirmed" | "failed";
  direction?: "inbound" | "outbound";
  /** Filter by agent externalId. */
  agent_id?: string;
  /** Filter by wallet address (case-insensitive). */
  wallet?: string;
  /** Free-text search over tx hash, addresses, memo. */
  q?: string;
  /** Page size. Default 50, max 100. */
  limit?: number;
};
```

### `PaymentCreateInput`

```ts theme={null}
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.

```ts theme={null}
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**

| Error                 | When                                                                                                                                                                                                                                                                                     |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ValidationError`     | Input shape failed Zod validation. `err.code` may be `amount_invalid` or `address_invalid`.                                                                                                                                                                                              |
| `NotFoundError`       | The agent, wallet, or grant doesn't exist in this (account, mode). `err.code`: `agent_not_found`, `wallet_not_found`, or `permission_not_found`.                                                                                                                                         |
| `AuthenticationError` | The 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. |
| `RateLimitError`      | The account is over its payments-per-minute ceiling (30/min). Honor `Retry-After`.                                                                                                                                                                                                       |
| `TallyError`          | `err.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`.

```ts theme={null}
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`.

### `payments.list(filters?)`

Returns an auto-paginating list of payments. The result is an `AsyncResourcePage<Payment>` — iterate with `for await`, or call `.toArray(n)` for a bounded read. Each row carries the rich fields (`direction`, `wallet_address`, `agent_id`, etc.) the slim `create()` response omits.

```ts theme={null}
// Iterate every confirmed outbound payment
for await (const p of tally.payments.list({ status: "confirmed", direction: "outbound" })) {
  console.log(`${p.created_at} → ${p.to}: $${p.amount_usdc}`);
}

// Or bounded (last 20 across all status/direction)
const recent = await tally.payments.list().toArray(20);

// Or manual paging for resumable iteration
const first = await tally.payments.list().firstPage();
// … persist first.next_cursor; resume later:
const next = await tally.payments.list().pageAfter(savedCursor);
```

Pending outbound rows are lazily refreshed from the chain on read — polling `list({ status: "pending" })` is a valid way to wait for confirmations without webhooks.

**Returns:** `AsyncResourcePage<Payment>`.

## Idempotency

`idempotency_key` makes retries safe across network failures:

```ts theme={null}
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

```ts theme={null}
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](/sdk/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.code`              | SDK class             | HTTP | Meaning                                                                                  |
| ----------------------- | --------------------- | ---- | ---------------------------------------------------------------------------------------- |
| `agent_not_found`       | `NotFoundError`       | 404  | No agent with that id in this (account, mode).                                           |
| `wallet_not_found`      | `NotFoundError`       | 404  | The wallet address isn't owned by this account or wrong mode.                            |
| `permission_not_found`  | `NotFoundError`       | 404  | The agent has no active grant on this wallet.                                            |
| `amount_invalid`        | `ValidationError`     | 400  | Malformed `amount_usdc` (non-decimal, >6 decimals, etc).                                 |
| `address_invalid`       | `ValidationError`     | 400  | `to` or `wallet` is not a valid EVM address.                                             |
| `amount_too_large`      | `AuthenticationError` | 403  | Exceeds the grant's per-transaction max.                                                 |
| `daily_cap_exceeded`    | `AuthenticationError` | 403  | Would push the rolling 24h spend over the grant's daily cap.                             |
| `recipient_not_allowed` | `AuthenticationError` | 403  | `to` is not in the grant's recipient allowlist.                                          |
| `contract_not_allowed`  | `AuthenticationError` | 403  | The destination contract isn't on the policy's contract allowlist (USDC is the default). |
| `permission_expired`    | `AuthenticationError` | 403  | The grant's `expires_at` has passed.                                                     |
| `privy_call_failed`     | `TallyError`          | 500  | Privy'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.
