Skip to main content
The payments endpoints are where money moves. See Payments (concept) for the enforcement model and SDK payments for the TypeScript wrapper.

The payment object

{
  "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"
}
FieldTypeDescription
idstringTally’s internal payment id, used as the path param on GET /v1/payments/{id}.
status"pending" | "confirmed" | "failed"pending until the chain confirms.
tx_hashstring | nullOn-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_usdcstringDecimal USDC amount. Always a string to preserve precision.
tostringRecipient address.
fromstringSender wallet address.
memostring | nullOptional caller-provided memo.
idempotency_keystring | nullThe key the payment was created with, if any.
created_atISO 8601 stringRecord creation time.

POST /v1/payments

Sign and broadcast a USDC transfer.

Request

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 fieldTypeRequiredDescription
agent_idstring (1–64)YesThe agent’s public id. Must have an active grant on wallet.
walletstring (EVM address)YesSender wallet, owned by this account, matching the key’s mode.
tostring (EVM address)YesRecipient.
amount_usdcstringYesPositive decimal, up to 6 decimal places (USDC’s native precision).
memostring (≤200)NoStored alongside the transaction.
idempotency_keystring (1–64)NoSee Idempotency.

Response

201 Created:
{
  "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

StatustypecodeWhen
400validation_failedamount_invalidamount_usdc is malformed.
400validation_failedaddress_invalidwallet or to isn’t a valid EVM address.
400validation_failedBody shape failed Zod. details has the issues.
401unauthenticatedAPI key invalid.
403forbiddenamount_too_largeExceeds the grant’s per-tx max.
403forbiddendaily_cap_exceededWould push 24h spend over the cap.
403forbiddenrecipient_not_allowedto is not in the grant’s recipient allowlist.
403forbiddencontract_not_allowedDestination contract isn’t in the allowlist (USDC is default).
403forbiddenpermission_expiredThe grant’s expires_at has passed.
404not_foundagent_not_foundNo agent with that id in this (account, mode).
404not_foundwallet_not_foundThe wallet isn’t owned by this account.
404not_foundpermission_not_foundThe agent has no active grant on the wallet.
429rate_limitedrate_limit_exceededPayments bucket exceeded (30/min).
500internalprivy_call_failedPrivy’s API failed during signing. Retry with the same idempotency key.
See 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.

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

curl https://api.tally.example.com/v1/payments/tx_01HXYZ... \
  -H "Authorization: Bearer $TALLY_API_KEY"
Path paramDescription
idTally’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

StatustypeWhen
401unauthenticatedAPI key invalid.
404not_foundNo payment with that id, or it belongs to a different (account, mode).
429rate_limitedDefault bucket exceeded.

Patterns

Poll until confirmed

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