Payouts
Payouts let an agent initiate an on-chain USDC transfer from a user's FluxA Wallet. They support optional mandateId for automatic approval and bizId for business-level deduplication.
All endpoints require Authorization: Bearer <Agent JWT>.
State machine
┌─ mandateId auto-approve ─┐
│ ▼
create ──► pending_authorization ──► authorized ──► signing ──► signed ──► broadcasting ──► succeeded ──► confirmed
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
user_denied failed failed failed failed
(terminal) (terminal) (terminal) (terminal) tx_reverted (terminal)
tx_confirmation_timeout
(non-terminal; budget held)When a payout is in pending_authorization, the user approves (or denies) it inside the FluxA Wallet UI via the approvalUrl returned at creation. There is no public API to approve/deny on behalf of the user.
Status values
| Status | Meaning |
|---|---|
pending_authorization | Waiting for user approval |
authorized | Approved; waiting for worker to sign |
signing | Worker is signing the EIP-3009 authorization |
signed | Signed; waiting to broadcast |
broadcasting | Broadcasting to chain |
succeeded | Broadcast submitted; waiting for on-chain confirmation |
confirmed | Confirmed on chain (≥3 blocks); final success state |
failed | Failed (see failureReason) |
Create payout
Agent initiates a payout.
Request:
curl -X POST https://walletapi.fluxapay.xyz/api/payouts \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $AGENT_JWT" \
-d '{
"payoutId": "po_abc123",
"toAddress": "0x1234567890abcdef1234567890abcdef12345678",
"amount": "1000000",
"mandateId": "m_xxxxxxxxxxxx",
"bizId": "order_20260416_001",
"description": "Refund for order #001"
}'Request fields
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
payoutId | string | Yes | - | Idempotency key, unique per user. Repeat requests with the same payoutId return the existing result. |
toAddress | string | Yes | - | Destination address; EVM format (0x + 40 hex chars). |
amount | string | Yes | - | Amount as a positive integer string in atomic units (USDC: 1000000 = 1.0 USDC). |
mandateId | string | No | null | Pre-signed mandate ID. If provided and the budget is sufficient, the payout is auto-approved, skipping manual user approval. |
bizId | string | No | null | External business ID, unique per user. Prevents duplicate payouts — only one active payout per bizId. |
network | string | No | "base" | Blockchain network (base / base-sepolia). |
currency | string | No | "USDC" | Currency symbol. |
assetAddress | string | No | network default USDC | Token contract address. |
description | string | No | null | Description. |
metadata | object | No | null | Custom JSON metadata. |
webhookUrl | string | No | null | Callback URL. |
ttlSeconds | number | No | 604800 | Expiry (60–604800 seconds; default 7 days). |
Response
201 Created (newly created):
{
"payoutId": "po_abc123",
"status": "authorized",
"txHash": null,
"approvalUrl": null,
"expiresAt": 1713340800,
"mandateId": "m_xxxxxxxxxxxx",
"bizId": "order_20260416_001"
}200 OK (idempotent hit, identical params):
{
"payoutId": "po_abc123",
"status": "authorized",
"txHash": null,
"approvalUrl": null,
"expiresAt": 1713340800,
"mandateId": "m_xxxxxxxxxxxx",
"bizId": "order_20260416_001"
}Response fields
| Field | Description |
|---|---|
payoutId | Payout ID |
status | Current status. authorized when mandate auto-approves; pending_authorization on the standard path. |
txHash | On-chain tx hash (available after broadcast) |
approvalUrl | User approval URL. Only returned in pending_authorization; null on auto-approve. |
expiresAt | Expiry (Unix timestamp) |
mandateId | Associated mandate ID |
bizId | External business ID |
Error responses
| HTTP | Code | Scenario |
|---|---|---|
| 400 | - | Missing required field / invalid format |
| 400 | mandate_not_signed | Mandate not yet signed |
| 400 | mandate_disabled | Mandate disabled |
| 400 | mandate_expired | Mandate expired or not yet active |
| 400 | mandate_currency_mismatch | Mandate currency doesn't match payout |
| 400 | mandate_insufficient_budget | Insufficient mandate budget (returns remaining) |
| 403 | mandate_mismatch | Mandate doesn't belong to the current agent/user |
| 404 | mandate_not_found | Mandate not found |
| 404 | - | Agent not found |
| 409 | - | payoutId reused with different params / bizId already taken |
mandateId auto-approval
When mandateId is provided, the following steps run atomically in a single DB transaction:
- Lock mandate (
SELECT ... FOR UPDATE) - Validate: same agent/user,
status=signed,is_enabled, within validity window, currency matches - Budget check:
remaining = limit_amount - spent_amount - pending_spent_amount >= amount - Reserve budget:
pending_spent_amount += amount - Create approval (status=approved) + create payout (status=authorized)
If any step fails, the whole transaction rolls back. The payout enters authorized directly — no user approval needed — and the worker starts processing immediately.
Mandate budget lifecycle
create payout (with mandate) → pending_spent += amount
on-chain confirmed → pending_spent -= amount, spent += amount
on-chain reverted → pending_spent -= amount
signing / broadcast failed → pending_spent -= amount
confirmation timeout → pending_spent unchanged (non-terminal; manual reconciliation)bizId deduplication
bizId lets external systems prevent duplicate payouts. Each user can have only one active payout per bizId.
Deduplication rules
| Existing payout status | New payout with same bizId | Reason |
|---|---|---|
| pending_authorization / authorized / signing / signed / broadcasting / succeeded / confirmed | 409 Reject | Active — no duplicates allowed |
| failed (signing_failed / user_denied / tx_reverted, etc. — deterministic failures) | Allow | Terminal death — bizId released |
| failed (tx_confirmation_timeout) | 409 Reject | Non-terminal — tx may still land on chain |
A database unique index enforces concurrency safety:
CREATE UNIQUE INDEX ux_payouts_user_biz_id
ON payouts(user_id, biz_id)
WHERE is_deleted = 0 AND biz_id IS NOT NULL
AND (status != 'failed' OR failure_reason LIKE 'tx_confirmation_timeout%');Relationship to payoutId idempotency
payoutId: request-level idempotency. SamepayoutId+ same params → existing result (200). SamepayoutId+ different params (includingbizId) → 409.bizId: business-level deduplication. DifferentpayoutIdbut samebizId→ 409.- Independent: use either, both, or neither.
Get payout status
Fetch details for a single payout. See Get payout status for full response fields.
curl https://walletapi.fluxapay.xyz/api/payouts/po_abc123 \
-H "Authorization: Bearer $AGENT_JWT"Concurrency safety
CAS (compare-and-set) protection matrix
Every state transition is guarded to prevent races across multiple worker instances:
| Transition | CAS condition | Mandate effect | Transaction |
|---|---|---|---|
| → authorized (mandate) | N/A (create time) | pending += amount | single |
| → authorized (standard) | N/A (create time) | (none) | single |
| signing / broadcasting → failed | WHERE status IN ('signing','broadcasting') | pending -= amount | single |
| succeeded → confirmed | WHERE status = 'succeeded' | pending → spent | single |
| succeeded → failed (reverted) | WHERE status = 'succeeded' | pending -= amount | single |
| succeeded → failed (timeout) | WHERE status = 'succeeded' | not released (non-terminal) | CAS single statement |
Design principles
- State transition + mandate effect are always in one transaction — either both commit or both roll back.
- Every terminal transition uses CAS — with multiple instances, only the winner runs the mandate effect.
- Workers don't swallow errors — stuck-recovery handles retries.
- Confirmation timeout is ambiguous — neither budget nor
bizIdis released; requires manual reconciliation.
