Disbursement
A disbursement is triggered against your own disbursement agent, a process you run rather than an endpoint on the Ligetron platform. The agent holds your GPU-backed prover and, in its default configuration, no private keys at all; it talks outbound to the platform only over an authenticated edge (/agent-edge). Concretely, per disbursement, the agent:
- Proves: a headless WASM prover builds note commitments for every recipient and generates the FUND proof, having already confirmed each recipient's eligibility against the pool-gate (see Eligibility).
- Signs: the funder authorization for each batch is produced by whichever
FunderSigneryou've configured (a local key, a signing enclave, Fireblocks, or DFNS), never a browser wallet popup. - Settles: the proved, signed batch is submitted to the platform, whose relayer verifies the proof and calls
fund()on-chain with a dual signature (the platform's own relayer key plus your funder's).
No amounts or recipient identities appear on-chain, only opaque note commitments and an eligibility proof.
Prerequisites
- Each recipient must be whitelisted; see Eligibility. An unregistered recipient causes that recipient's proof step to fail; whether the whole call fails or just skips them is controlled by the
unregisteredfield below. - You've completed
POST /api/employers/onboardand hold a Funding Party API key (lgk_live_...). - A disbursement agent is running and reachable; see Configuring the agent.
- The funder wallet has sufficient token balance; the agent submits an ERC-20 approval automatically if the pool doesn't already have enough allowance.
New to this flow? Getting Started walks through onboarding, running an agent, and sending a first disbursement end to end.
Endpoints
The agent listens on loopback by default (http://127.0.0.1:8787) and exposes:
| Method | Path | Purpose |
|---|---|---|
POST | /api/disburse | Submit a disbursement batch (idempotent on Idempotency-Key). |
GET | /api/disburse/:key | Poll a submitted job to its terminal state. |
POST | /api/validate | Dry run: validate recipients and allowance without disbursing. |
GET | /api/readyz | Readiness: accelerator, signer, pool-gate, and platform all reachable. |
GET | /api/livez | Liveness. |
The same process also serves /api/balance, /api/transfer and /api/send — the holder's operations on its own pool balance, on separate capability-scoped credentials. Those are documented in Account Operations → Driving these operations over HTTP.
Auth
Every route except the two health probes requires a bearer credential:
Authorization: Bearer <credential>
either your Funding Party API key or the agent's own loopback token; see Authentication & API Access for how each is obtained and when to use which.
Submitting a disbursement
POST /api/disburse
Authorization: Bearer lgk_live_...
Idempotency-Key: disbursement-2026-08-run-1
Content-Type: application/json
{
"token": "USDC",
"chain": "base_sepolia",
"unregistered": "fail",
"recipients": [
{ "recipientAddress": "0xAbC123...", "amount": "500000" },
{ "recipientAddress": "0xDeF456...", "amount": "1000000" }
]
}
| Field | Type | Required | Description |
|---|---|---|---|
token | string | Yes | Token symbol configured for this deployment. |
chain | string | No | Target chain key. Omitted → the agent's configured default (DISBURSE_CHAIN). |
recipients | array | Yes | Each entry an address and an amount in base units, as a decimal string (no floats). Capped per call (the target chain's configured maximum); an oversized list is rejected with 400 batch-too-large before any proving starts, and the message names the ceiling. There's no server-side splitting today, so if you hit it, split the roster into separate /api/disburse calls yourself, each with its own Idempotency-Key. |
unregistered | "fail" | "skip" | No | Whether an unwhitelisted recipient fails the whole call (default) or is silently skipped and reported in skipped. |
The Idempotency-Key header is required. Replaying the same key with the same body returns the original job; replaying it with a different body is a 409.
Response
202 for a newly accepted job, 200 if the key already resolved to a terminal job:
{ "key": "disbursement-2026-08-run-1", "state": "accepted", "statusUrl": "/api/disburse/disbursement-2026-08-run-1" }
Errors
A malformed body (wrong types, missing fields) fails schema validation: 400, code: "invalid_request". Everything else is a semantic check against the batch itself:
code | Status | Meaning |
|---|---|---|
invalid-chain | 400 | chain isn't routable: unknown key, unsupported platform, no pool configured, or no funder signer for its curve. |
invalid-token | 400 | token isn't a pool symbol configured for the chain (or is a raw address, which is never accepted). |
batch-too-large | 400 | recipients exceeds the chain's configured per-call maximum; see the recipients row above. |
invalid-amount | 400 | A recipient's amount isn't a positive integer string. |
invalid-address | 400 | A recipient's recipientAddress doesn't parse for the target chain. |
duplicate-recipient | 400 | The same recipient address appears twice in one call. |
unregistered-recipient | 400 | A recipient is missing W or P — either hasn't signed in, or has no encryption key registered for notes to be sealed to. Both are refused before proving rather than failing inside the prover. Only raised under unregistered: "fail", the default; under "skip" that recipient lands in skipped instead. For a recipient whose wallet is in MPC custody, neither will ever arrive on its own — see Eligibility. |
idempotency_conflict | 409 | The Idempotency-Key was reused with a different body. |
not_found | 404 | GET /api/disburse/:key named a key this agent has never seen. |
POST /api/disburse stops at the first issue it finds and returns it alone, flattened to { code, message }. If you want every problem in one pass instead of fixing them one 400 at a time, use POST /api/validate: same body, same checks, but it never errors on these; it always returns 200 with the complete list in issues[]. See Dry-running first.
Polling for the result
GET /api/disburse/disbursement-2026-08-run-1
Job lifecycle
A job moves through at most one non-terminal state before landing on one of two terminal ones:
state | Meaning | What to do |
|---|---|---|
accepted | Queued; the body validated but proving and settlement haven't started. | Keep polling. |
submitted | Proving and settlement are underway. An optional stage field, when present, names the current step. | Keep polling. |
done | Terminal: an on-chain settlement attempt was made. See result.status below for whether it succeeded. | Read result for per-recipient detail. |
failed | Terminal: no on-chain effect occurred before the job failed (rejected at validation, or failed during proving). | Read error.code / error.message, then see Retrying a failure. |
{
"key": "disbursement-2026-08-run-1",
"state": "done",
"result": {
"status": "completed",
"batches": [
{ "index": 0, "txHash": "0x...", "recipientCount": 2, "status": "success" }
],
"notes": [
{ "requestIndex": 0, "recipientAddress": "0xAbC123...", "commitment": "0x...", "amount": "500000", "status": "success" },
{ "requestIndex": 1, "recipientAddress": "0xDeF456...", "commitment": "0x...", "amount": "1000000", "status": "success" }
],
"skipped": []
}
}
result.status says whether that on-chain attempt actually landed:
result.status | Meaning | What to do |
|---|---|---|
completed | The fund() call confirmed on-chain. | Read result.notes for per-recipient settlement detail. |
partial | The fund() call reverted or otherwise failed after being submitted, distinct from state: "failed" above, where nothing ever reached the chain. | Read batches[0].error for the on-chain failure reason; nothing in this batch settled. |
result.batches and failedBatchIndex exist because one job is designed to eventually cover several fund() calls, but that isn't built yet: right now every job proves and settles exactly one batch, always index: 0. partial doesn't currently mean "some batches succeeded, others failed" the way the field names might suggest; it means this one attempt failed on-chain rather than before reaching it. A recipient list too large for one fund() call is rejected up front (see the recipients row above), it isn't split into multiple batches within a job.
A failed job carries error: { code, message } instead of result; no DisburseResult is ever produced for a failure that occurred before any on-chain effect.
Retrying a failure
Replaying the same Idempotency-Key and body against a failed job doesn't have a predictable outcome from the caller's side: internally, the platform may treat the failure as retryable and re-run it, or it may just hand back the same failed job again, and that decision isn't exposed in the response you get back. Don't rely on it. Once you've fixed whatever error named, submit again with a new Idempotency-Key to force a genuine fresh attempt.
Dry-running first
POST /api/validate takes the same body as /api/disburse and runs the same checks (see Errors) without proving or submitting anything. Unlike /api/disburse, it always responds 200, with every problem it found rather than just the first:
{
"ok": false,
"allowanceState": "unknown",
"issues": [
{ "requestIndex": 1, "code": "invalid-amount", "message": "bad amount \"0\"", "severity": "error" }
]
}
An empty issues array and ok: true means the batch would go through as submitted.
Configuring the agent
The agent needs, at minimum, a platform edge to settle through and a way to authorize funder transactions:
| Variable | Required | Purpose |
|---|---|---|
DISBURSE_PLATFORM_URL | Yes | The platform's /agent-edge base URL the agent settles through. |
DISBURSE_PLATFORM_TOKEN | Yes | Agent-to-platform edge token. |
DISBURSE_PLATFORM_VERIFY_URL | For API-key auth | Points at the platform's POST /api/employers/verify-key, enabling lgk_live_... auth on incoming /api/disburse calls. |
DISBURSE_CHAIN | No | Default target chain; can also be vended by /agent-edge/bootstrap. |
DISBURSE_POOL_GATE_URL + POOL_GATE_OAUTH_* | No | Pool-gate reachability and read credentials; can also be vended by bootstrap. |
DISBURSE_PROVER | No | wasm (default, real GPU prover) or mock (dry runs only). |
Pick exactly one funder signer:
| Signer | Variables |
|---|---|
| Enclave (preferred) | DISBURSE_SIGNER=enclave + enclave connection vars |
| Local key (dev-oriented) | Leave DISBURSE_SIGNER unset + DISBURSE_FUNDER_PRIVATE_KEY |
| Fireblocks (MPC custody) | DISBURSE_SIGNER=fireblocks + DISBURSE_FIREBLOCKS_* |
| DFNS (MPC custody) | DISBURSE_SIGNER=dfns + DISBURSE_DFNS_* — see Delegated Custody with DFNS |
DISBURSE_FUNDER_ED25519_KEY additively enables Solana/Stellar signing alongside any of the above.
Fireblocks and DFNS keep the funder key out of the agent's environment, but neither stops the agent from requesting a signature over something it should not. Both therefore require a gate running beside the custody service — a second deployment of the same binary, on a different host, that recomputes the funder digest and refuses anything that does not match its own policy. For DFNS this is disburse-agent approve; the full setup is in Delegated Custody with DFNS.
Configuring DISBURSE_FUNDER_PRIVATE_KEY puts the funder's raw private key in the agent's environment. It's meant for development; the agent logs a warning at boot if it's configured in a production-flagged environment. Use the enclave, Fireblocks, or DFNS signer for anything holding real funds.
Reference integration
scripts/utils/disburse-e2e-offbox.mjs in the ligeroclear repo is the canonical example of a real, off-box integration: it spawns the packaged agent binary on a host separate from the platform, points it at the platform's authenticated /agent-edge with a minted lgk_live_... key, waits on /api/readyz for a real GPU accelerator, then posts to /api/disburse and polls it to completion. That's exactly the shape a production caller should follow.
scripts/utils/disburse-e2e.ts is an older sibling that exercises a co-located agent topology (SSMing onto a platform-owned box to curl a loopback agent there). That topology has been retired; treat this script as historical reference only, not a template for new integrations.