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

> REST endpoints for sending and retrieving payments.

The payments endpoints are where money moves. See [Payments (concept)](/payments) for the enforcement model and [SDK payments](/sdk/payments) for the TypeScript wrapper.

## The payment object

```json theme={null}
{
  "id": "tx_01HXYZ...",
  "status": "pending",
  "tx_hash": "0xabc...",
  "amount_usdc": "4.50",
  "to": "0xC0fee...",
  "from": "0x7a3f...",
  "memo": "arxiv API access",
  "idempotency_key": "invoice-2026-04-117",
  "created_at": "2026-05-18T12:00:00Z"
}
```

| Field             | Type                                       | Description                                                                                                                                      |
| ----------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id`              | string                                     | Tally's internal payment id, used as the path param on `GET /v1/payments/{id}`.                                                                  |
| `status`          | `"pending"` \| `"confirmed"` \| `"failed"` | `pending` until the chain confirms.                                                                                                              |
| `tx_hash`         | string \| null                             | On-chain transaction hash. Set as soon as Privy accepts the signed RPC; null only on rejected pre-checks (which don't produce a payment record). |
| `amount_usdc`     | string                                     | Decimal USDC amount. Always a string to preserve precision.                                                                                      |
| `to`              | string                                     | Recipient address.                                                                                                                               |
| `from`            | string                                     | Sender wallet address.                                                                                                                           |
| `memo`            | string \| null                             | Optional caller-provided memo.                                                                                                                   |
| `idempotency_key` | string \| null                             | The key the payment was created with, if any.                                                                                                    |
| `created_at`      | ISO 8601 string                            | Record creation time.                                                                                                                            |

***

## `POST /v1/payments`

Sign and broadcast a USDC transfer.

### Request

```bash theme={null}
curl https://api.tally.example.com/v1/payments \
  -X POST \
  -H "Authorization: Bearer $TALLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "research-bot",
    "wallet": "0x7a3fA1B9c4D2e6F8a0B1c2D3e4F5a6B7c8D9e0b21c",
    "to": "0xC0fee04...",
    "amount_usdc": "4.50",
    "memo": "arxiv API access",
    "idempotency_key": "invoice-2026-04-117"
  }'
```

| Body field        | Type                 | Required | Description                                                         |
| ----------------- | -------------------- | -------- | ------------------------------------------------------------------- |
| `agent_id`        | string (1–64)        | Yes      | The agent's public id. Must have an active grant on `wallet`.       |
| `wallet`          | string (EVM address) | Yes      | Sender wallet, owned by this account, matching the key's mode.      |
| `to`              | string (EVM address) | Yes      | Recipient.                                                          |
| `amount_usdc`     | string               | Yes      | Positive decimal, up to 6 decimal places (USDC's native precision). |
| `memo`            | string (≤200)        | No       | Stored alongside the transaction.                                   |
| `idempotency_key` | string (1–64)        | No       | See [Idempotency](/api/idempotency).                                |

### Response

`201 Created`:

```json theme={null}
{
  "payment": {
    "id": "tx_01HXYZ...",
    "status": "pending",
    "tx_hash": "0xabc...",
    "amount_usdc": "4.50",
    "to": "0xC0fee...",
    "from": "0x7a3f...",
    "memo": "arxiv API access",
    "idempotency_key": "invoice-2026-04-117",
    "created_at": "2026-05-18T12:00:00Z"
  }
}
```

The endpoint returns as soon as Privy accepts the signed RPC. The on-chain receipt arrives a few seconds later — poll `GET /v1/payments/{id}` or subscribe to `payment.confirmed` webhooks.

### Enforcement layers

In order, before the response is built:

1. **Tally pre-check.** The agent must exist in this (account, mode); the wallet must belong to the account; an active grant must exist; the policy (per-tx max, daily cap, recipient/contract allowlist, expiry) must permit the payment.
2. **Privy enclave.** Independently re-validates the per-tx max and allowlists when signing. A compromised Tally server can't sneak a payment past this check.

Pre-check failures (layer 1) return 4xx with a structured error code — see below. Enclave failures (layer 2) result in `status: "failed"` on the resulting payment.

### Errors

| Status | `type`              | `code`                  | When                                                                    |
| ------ | ------------------- | ----------------------- | ----------------------------------------------------------------------- |
| 400    | `validation_failed` | `amount_invalid`        | `amount_usdc` is malformed.                                             |
| 400    | `validation_failed` | `address_invalid`       | `wallet` or `to` isn't a valid EVM address.                             |
| 400    | `validation_failed` | —                       | Body shape failed Zod. `details` has the issues.                        |
| 401    | `unauthenticated`   | —                       | API key invalid.                                                        |
| 403    | `forbidden`         | `amount_too_large`      | Exceeds the grant's per-tx max.                                         |
| 403    | `forbidden`         | `daily_cap_exceeded`    | Would push 24h spend over the cap.                                      |
| 403    | `forbidden`         | `recipient_not_allowed` | `to` is not in the grant's recipient allowlist.                         |
| 403    | `forbidden`         | `contract_not_allowed`  | Destination contract isn't in the allowlist (USDC is default).          |
| 403    | `forbidden`         | `permission_expired`    | The grant's `expires_at` has passed.                                    |
| 404    | `not_found`         | `agent_not_found`       | No agent with that id in this (account, mode).                          |
| 404    | `not_found`         | `wallet_not_found`      | The wallet isn't owned by this account.                                 |
| 404    | `not_found`         | `permission_not_found`  | The agent has no active grant on the wallet.                            |
| 429    | `rate_limited`      | `rate_limit_exceeded`   | Payments bucket exceeded (30/min).                                      |
| 500    | `internal`          | `privy_call_failed`     | Privy's API failed during signing. Retry with the same idempotency key. |

See [Errors](/api/errors) for the response envelope.

### Rate limit

`POST /v1/payments` uses the stricter `payments` bucket: **30 requests / 60 seconds** per API key. See [Rate limits](/api/rate-limits).

***

## `GET /v1/payments/{id}`

Retrieve a single payment by its Tally id. If the payment is still `pending` on Tally's side and has a `tx_hash`, this call lazily refreshes from the chain — so polling `GET /v1/payments/{id}` is the canonical way to wait for a confirmation.

### Request

```bash theme={null}
curl https://api.tally.example.com/v1/payments/tx_01HXYZ... \
  -H "Authorization: Bearer $TALLY_API_KEY"
```

| Path param | Description                                                            |
| ---------- | ---------------------------------------------------------------------- |
| `id`       | Tally's internal payment id (the `id` field from a previous response). |

### Response

`200 OK`. Same shape as the `POST /v1/payments` response. `status` reflects the latest known state — if the chain has confirmed since the last call, `status` will have flipped to `confirmed` or `failed`.

### Errors

| Status | `type`            | When                                                                   |
| ------ | ----------------- | ---------------------------------------------------------------------- |
| 401    | `unauthenticated` | API key invalid.                                                       |
| 404    | `not_found`       | No payment with that id, or it belongs to a different (account, mode). |
| 429    | `rate_limited`    | Default bucket exceeded.                                               |

***

## Patterns

### Poll until confirmed

```ts theme={null}
async function waitForConfirmation(id: string, timeoutMs = 30_000) {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    const res = await fetch(`${baseUrl}/v1/payments/${id}`, {
      headers: { Authorization: `Bearer ${apiKey}` },
    });
    const { payment } = await res.json();
    if (payment.status !== "pending") return payment;
    await new Promise((r) => setTimeout(r, 2_000));
  }
  throw new Error(`payment ${id} did not confirm within ${timeoutMs}ms`);
}
```

Don't poll faster than every 2 seconds — you'll burn rate-limit budget without seeing a meaningfully fresher status.

### Production: subscribe to webhooks instead

For production workloads, replace the polling loop with a webhook subscription to `payment.confirmed` and `payment.failed`. The event carries the same payment shape; verification helper lives in the [SDK webhooks](/sdk/webhooks) page.

## Not yet exposed

* `GET /v1/payments` — listing with status/agent/direction filters.
* `POST /v1/payments/{id}/refund` — first-class refund handling.

For listing today, the dashboard's Transactions tab is the source of truth.
