Skip to content

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

StatusMeaning
pending_authorizationWaiting for user approval
authorizedApproved; waiting for worker to sign
signingWorker is signing the EIP-3009 authorization
signedSigned; waiting to broadcast
broadcastingBroadcasting to chain
succeededBroadcast submitted; waiting for on-chain confirmation
confirmedConfirmed on chain (≥3 blocks); final success state
failedFailed (see failureReason)

Create payout

Agent initiates a payout.

Request:

bash
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

FieldTypeRequiredDefaultDescription
payoutIdstringYes-Idempotency key, unique per user. Repeat requests with the same payoutId return the existing result.
toAddressstringYes-Destination address; EVM format (0x + 40 hex chars).
amountstringYes-Amount as a positive integer string in atomic units (USDC: 1000000 = 1.0 USDC).
mandateIdstringNonullPre-signed mandate ID. If provided and the budget is sufficient, the payout is auto-approved, skipping manual user approval.
bizIdstringNonullExternal business ID, unique per user. Prevents duplicate payouts — only one active payout per bizId.
networkstringNo"base"Blockchain network (base / base-sepolia).
currencystringNo"USDC"Currency symbol.
assetAddressstringNonetwork default USDCToken contract address.
descriptionstringNonullDescription.
metadataobjectNonullCustom JSON metadata.
webhookUrlstringNonullCallback URL.
ttlSecondsnumberNo604800Expiry (60–604800 seconds; default 7 days).

Response

201 Created (newly created):

json
{
  "payoutId": "po_abc123",
  "status": "authorized",
  "txHash": null,
  "approvalUrl": null,
  "expiresAt": 1713340800,
  "mandateId": "m_xxxxxxxxxxxx",
  "bizId": "order_20260416_001"
}

200 OK (idempotent hit, identical params):

json
{
  "payoutId": "po_abc123",
  "status": "authorized",
  "txHash": null,
  "approvalUrl": null,
  "expiresAt": 1713340800,
  "mandateId": "m_xxxxxxxxxxxx",
  "bizId": "order_20260416_001"
}

Response fields

FieldDescription
payoutIdPayout ID
statusCurrent status. authorized when mandate auto-approves; pending_authorization on the standard path.
txHashOn-chain tx hash (available after broadcast)
approvalUrlUser approval URL. Only returned in pending_authorization; null on auto-approve.
expiresAtExpiry (Unix timestamp)
mandateIdAssociated mandate ID
bizIdExternal business ID

Error responses

HTTPCodeScenario
400-Missing required field / invalid format
400mandate_not_signedMandate not yet signed
400mandate_disabledMandate disabled
400mandate_expiredMandate expired or not yet active
400mandate_currency_mismatchMandate currency doesn't match payout
400mandate_insufficient_budgetInsufficient mandate budget (returns remaining)
403mandate_mismatchMandate doesn't belong to the current agent/user
404mandate_not_foundMandate 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:

  1. Lock mandate (SELECT ... FOR UPDATE)
  2. Validate: same agent/user, status=signed, is_enabled, within validity window, currency matches
  3. Budget check: remaining = limit_amount - spent_amount - pending_spent_amount >= amount
  4. Reserve budget: pending_spent_amount += amount
  5. 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 statusNew payout with same bizIdReason
pending_authorization / authorized / signing / signed / broadcasting / succeeded / confirmed409 RejectActive — no duplicates allowed
failed (signing_failed / user_denied / tx_reverted, etc. — deterministic failures)AllowTerminal death — bizId released
failed (tx_confirmation_timeout)409 RejectNon-terminal — tx may still land on chain

A database unique index enforces concurrency safety:

sql
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. Same payoutId + same params → existing result (200). Same payoutId + different params (including bizId) → 409.
  • bizId: business-level deduplication. Different payoutId but same bizId → 409.
  • Independent: use either, both, or neither.

Get payout status

Fetch details for a single payout. See Get payout status for full response fields.

bash
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:

TransitionCAS conditionMandate effectTransaction
→ authorized (mandate)N/A (create time)pending += amountsingle
→ authorized (standard)N/A (create time)(none)single
signing / broadcasting → failedWHERE status IN ('signing','broadcasting')pending -= amountsingle
succeeded → confirmedWHERE status = 'succeeded'pending → spentsingle
succeeded → failed (reverted)WHERE status = 'succeeded'pending -= amountsingle
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 bizId is released; requires manual reconciliation.

Released under the MIT License.