Top up wallets
Getting native currency into wallets, bundled with an order or on its own. One deposit, one payment, one plan to follow.
Buying a wallet gets you its history. Topping it up gets it working capital. This part of the API does the second thing.
The unit of work is a funding plan: a list of destinations, an amount for each, and a single deposit address that pays for all of them. You send one transfer, and the plan delivers to every destination. What lands is the net amount you asked for, because delivery costs are priced into the deposit rather than deducted from the wallet.
Two things worth saying up front, because they shape everything below.
A plan does not expire. The deposit address stays valid until you cancel the plan. There is no clock to race.
Destinations do not have to be ours. A plan can target wallets you bought
here, by wallet_id, and addresses you got anywhere else, by address, in the
same plan. We never need the destination's key.
Two ways in
Paid for with the order
Add a top-up to POST /quote and it is priced into the same bill. The buyer
sends one transfer covering the wallets and their funding, so by the time the
order settles the funding is already paid for.
// the same amount on every wallet
{ "chain": "solana", "count": 20, "topup_per_wallet": "50000000" }
// a total, spread across the lot
{ "chain": "solana", "count": 20,
"topup_total": "1000000000", "topup_min": "30000000", "topup_max": "80000000" }
// an amount per named wallet
{ "chain": "solana", "wallet_ids": ["0f8c…", "7d21…", "b40a…"],
"topup_allocations": [
{ "wallet_id": "0f8c…", "amount": "300000000" },
{ "wallet_id": "7d21…", "amount": "120000000" },
{ "wallet_id": "b40a…", "amount": "0" }
] }The three forms are mutually exclusive. Do not reach for the first one: twenty wallets funded with the identical figure, in the same window, is itself a signature, and it is the pattern the routing exists to avoid producing.
topup_total when you do not care which wallet gets what. The spread draws
a different amount for each, all inside your bounds, summing exactly to your
total. The draw is deterministic, so a settlement that runs twice produces the
same plan rather than a different one. Shares stay close to an even split: no
wallet walks away with most of the order.
A share is also either nothing or a workable amount, never dust. If the total
cannot give every wallet a workable share, the spread funds fewer wallets rather
than spreading crumbs over all of them, and a wallet left out stays out of the
plan entirely, costing you no delivery. Set topup_min if you would rather
decide where that line sits.
topup_allocations when you do. One entry per wallet, naming what it should
receive. This is the form to use when the wallets have jobs: one trades, one
holds, one only ever pays gas, and no draw of ours is going to guess that shape.
Four things to know about it:
- It needs an explicit
wallet_idslot. On a server-selected lot the ids you name are not the ones that would be reserved, so the ask has nothing to attach to and the call is refused. - It must cover that lot exactly: one entry per wallet, no wallet missing, no id that is not in the order. A wallet left out is far more likely to be one you forgot than one you meant to fund with nothing, so the API will not guess.
"0"is how you say "this one I am buying without funding". It stays out of the funding plan entirely and costs you no delivery either, since a wallet that receives nothing has nothing to route. At least one entry must be non-zero.- Amounts are whole atomic native units, like everywhere else: lamports on Solana, wei on EVM. A decimal is rejected rather than scaled.
Whichever form you used, the quote echoes it back. topup_allocations on
GET /quote/{id} is the answer to "what did this order ask for", long after the
order was placed, and it is the split the plan opens on.
Watch total_due on the quote rather than user_price_total. The top-up sits
in topup_required and both are included in what you owe.
When the order settles, a plan opens against the wallets that were sold, and its
id appears on the quote as topup_plan_id. Such a plan is marked prepaid: true, which is how a client tells "waiting for money" apart from "waiting for
you to make a decision". Nothing more is owed on it.
What happens next depends on the size of the lot.
One wallet. There is exactly one destination and one amount, so there is no
decision to make. The plan opens quoted and starts on its own.
Several. The plan opens draft and waits for you to say how the money is
split, with POST /funding/plans/{plan_id}/configure. It arrives pre-filled
with the shape you asked for, so accepting it as-is is a single call. The total
may not exceed what was paid, since there is no second payment in this flow, and
routing starts as soon as the call returns.
Authorization
bearerAuth JWT from /auth/login (users) or an org API key (mk_...) for integrations.
In: header
Path Parameters
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
curl -X POST "https://example.com/funding/plans/string/configure" \ -H "Content-Type: application/json" \ -d '{ "wallets": [ { "amount": "string" } ] }'{ "chain": "string", "completed_at": "string", "created_at": "string", "deposit_address": "string", "fee_amount": "string", "fee_bps": 0, "funding_delay": 0, "funds_seen_at": "string", "items": [ { "address": "string", "amount": "string", "delivered_amount": "string", "funded_at": "string", "hops_done": 0, "hops_total": 0, "is_initial": true, "progress_pct": 0.1, "routed_amount": "string", "short_by": "string", "status": "string", "wallet_id": "d9a8fbfc-848f-43a2-9168-911ed3a04a48" } ], "net_total": "string", "plan_id": "00713021-9aea-41da-9a88-87760c08fa72", "prepaid": true, "quote_id": "3c071a1d-db86-46a7-9dc8-72ba3fbca992", "received_amount": "string", "refund_address": "string", "refund_amount": "string", "refund_requested_at": "string", "refund_tx": "string", "refunded_at": "string", "rescue_attempts": 0, "rescue_completed": true, "rescue_completed_at": "string", "rescue_failed_probes": 0, "rescue_requested": true, "rescue_requested_at": "string", "rescue_swept_count": 0, "routing_total": "string", "started_at": "string", "status": "string", "total_required": "string", "wallet_count": 0}On its own
POST /funding/plans builds a plan from nothing. Name the destinations and the
amounts, and get back a priced plan with a deposit address.
{
"chain": "solana",
"wallets": [
{ "wallet_id": "0f8c…", "amount": "50000000" },
{ "address": "7Yq2…", "amount": "120000000" }
],
"funding_delay": 30
}quote_id is accepted here and is pure provenance. It records which order this
funding belongs to, so GET /funding/plans?quote_id=… can answer "what was
funded for that order". It is never the authorisation: the wallets still have to
be yours, and they need not come from that order.
Send total_required to deposit_address, then call POST /funding/plans/{plan_id}/start. That call reads the deposit on-chain and either
starts the delivery or tells you exactly how much is still missing.
{
"started": false,
"status": "awaiting_funds",
"expected_amount": "170300000",
"received_amount": "120000000",
"missing_amount": "50300000",
"deposit_address": "…",
"message": "funds not received yet: 120000000 of 170300000 on …"
}It is safe to call repeatedly. On a plan already running it reports the state instead of starting a second time.
Authorization
bearerAuth JWT from /auth/login (users) or an org API key (mk_...) for integrations.
In: header
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
curl -X POST "https://example.com/funding/plans" \ -H "Content-Type: application/json" \ -d '{ "chain": "string", "wallets": [ { "amount": "string" } ] }'{ "chain": "string", "completed_at": "string", "created_at": "string", "deposit_address": "string", "fee_amount": "string", "fee_bps": 0, "funding_delay": 0, "funds_seen_at": "string", "items": [ { "address": "string", "amount": "string", "delivered_amount": "string", "funded_at": "string", "hops_done": 0, "hops_total": 0, "is_initial": true, "progress_pct": 0.1, "routed_amount": "string", "short_by": "string", "status": "string", "wallet_id": "d9a8fbfc-848f-43a2-9168-911ed3a04a48" } ], "net_total": "string", "plan_id": "00713021-9aea-41da-9a88-87760c08fa72", "prepaid": true, "quote_id": "3c071a1d-db86-46a7-9dc8-72ba3fbca992", "received_amount": "string", "refund_address": "string", "refund_amount": "string", "refund_requested_at": "string", "refund_tx": "string", "refunded_at": "string", "rescue_attempts": 0, "rescue_completed": true, "rescue_completed_at": "string", "rescue_failed_probes": 0, "rescue_requested": true, "rescue_requested_at": "string", "rescue_swept_count": 0, "routing_total": "string", "started_at": "string", "status": "string", "total_required": "string", "wallet_count": 0}Authorization
bearerAuth JWT from /auth/login (users) or an org API key (mk_...) for integrations.
In: header
Path Parameters
Response Body
application/json
curl -X POST "https://example.com/funding/plans/string/start"{ "deposit_address": "string", "expected_amount": "string", "message": "string", "missing_amount": "string", "received_amount": "string", "started": true, "status": "string"}What it costs
total_required is the whole bill, and the plan breaks it down for you.
| Field | What it is |
|---|---|
net_total | what you asked to land in the destinations |
routing_total | delivery cost, which we front on your behalf (zero on EVM) |
fee_amount | the platform take, at fee_bps |
The fee is charged on the routed total, net plus delivery, because delivery is real money moving on your behalf. The default rate is 10 bps (0.10%), with a small floor per chain. The rate is frozen onto the plan when it is priced, so a plan you are already holding a deposit address for is never repriced.
Half of the fee is credited back to your organisation's balance.
Pacing
funding_delay is the gap, in seconds, between one destination starting and the
next. It is capped at 7200, two hours.
Leave it at 0 and the destinations still do not all go at once. They go in
waves. Set it and they go one at a time, spaced exactly as you asked.
Following a plan
GET /funding/status/{plan_id} is the polling endpoint. It returns the plan,
its items, and a progress_pct across the whole thing.
Per item:
| Field | Meaning |
|---|---|
status | pending, running, funded, failed |
hops_done / hops_total | on-chain steps completed out of the total for this destination |
progress_pct | that ratio, for this destination alone |
delivered_amount | what the route confirmed into the wallet: the transfers we sent it that landed, summed. Null until the route finishes or fails |
short_by | set when the route stopped before sending everything. The funds are stranded in it, and a rescue recovers them |
On Solana a single destination is many on-chain steps, so progress_pct moves
long before anything has landed. Do not read it as "almost funded". Read
status and delivered_amount for that.
delivered_amount is deliberately not the wallet's balance. A wallet you bought
is yours to trade with, and one that spends while its funding is on the way
would otherwise read as short-delivered. What the route sent it is a number
nothing else can move. It follows that an item which reached funded cannot be
short: it got there by confirming every hop.
The plan's own history
The status response also carries events, an append-only log of what was
done to the plan's money after delivery: withdraw_requested,
withdraw_confirmed and withdraw_failed for refunds, rescue_requested,
rescue_completed and rescue_sweep_failed for rescues, oldest first, each
with its time, destination, amount and transaction where there is one. A failed
attempt stays in the log once the next one is queued, so the history reads as
what happened rather than as the current word.
The current word is there too. refund_status follows the refund sweep
(pending, broadcasting, awaiting_confirm, confirmed, failed),
refund_error says why when it failed, and rescue_failed_sweeps counts rescue
sweeps that died before returning their funds. Non-zero means money may still be
stranded: rescue again.
Authorization
bearerAuth JWT from /auth/login (users) or an org API key (mk_...) for integrations.
In: header
Path Parameters
Response Body
application/json
curl -X GET "https://example.com/funding/status/string"{ "buyer_ref": "string", "chain": "string", "completed_at": "string", "created_at": "string", "deposit_address": "string", "events": [ { "amount": "string", "at": "string", "kind": "string", "message": "string", "to_address": "string", "tx_hash": "string" } ], "failed": 0, "fee_amount": "string", "fee_bps": 0, "funded": 0, "funding_delay": 0, "funds_seen_at": "string", "items": [ { "address": "string", "amount": "string", "delivered_amount": "string", "funded_at": "string", "hops_done": 0, "hops_total": 0, "is_initial": true, "progress_pct": 0.1, "routed_amount": "string", "short_by": "string", "status": "string", "wallet_id": "d9a8fbfc-848f-43a2-9168-911ed3a04a48" } ], "net_total": "string", "pending": 0, "plan_id": "00713021-9aea-41da-9a88-87760c08fa72", "prepaid": true, "progress_pct": 0.1, "quote_id": "3c071a1d-db86-46a7-9dc8-72ba3fbca992", "received_amount": "string", "refund_address": "string", "refund_amount": "string", "refund_error": "string", "refund_requested_at": "string", "refund_status": "string", "refund_tx": "string", "refund_updated_at": "string", "refunded_at": "string", "rescue_attempts": 0, "rescue_completed": true, "rescue_completed_at": "string", "rescue_failed_probes": 0, "rescue_failed_sweeps": 0, "rescue_requested": true, "rescue_requested_at": "string", "rescue_sweep_error": "string", "rescue_swept_count": 0, "routing_total": "string", "started_at": "string", "status": "string", "total_required": "string", "wallet_count": 0}Plan statuses
| Status | Meaning |
|---|---|
draft | paid for with an order, waiting for you to choose the split |
quoted | priced, deposit address issued, nothing received yet |
awaiting_funds | start was called and the deposit is short |
funded | the full amount arrived, delivery can begin |
running | delivery in flight |
completed | every destination funded |
partial | terminal, some funded and some failed |
failed | terminal, nothing landed |
cancelled | discarded before delivery started |
refunded | the deposit went back to you and the transaction confirmed |
When something goes wrong
Three different remedies, and they are not interchangeable.
Cancel, before delivery starts. Drops the plan. If the deposit already holds
funds you must pass a refund_address, because it is your money and we do not
pick the destination. Once delivery is in flight, cancel is refused: money is
already moving and stopping halfway would strand it.
Rescue, after a delivery failed partway. Queues a background sweep that goes looking for whatever a failed delivery left behind and pulls it back to the plan's deposit wallet. It returns as soon as the job is queued, because a single plan can mean thousands of on-chain reads. It is refused while the plan is still running, and it is Solana only, since on EVM there is nowhere for funds to get stranded.
Follow it on the plan itself, with rescue_requested, rescue_completed,
rescue_attempts, rescue_swept_count and rescue_failed_probes. That last
one is the important one. Non-zero means the sweep could not read everything it
needed to, so money may still be out there and the scan will retry.
Refund, to get a deposit back. Sweeps whatever is left on the plan's deposit
wallet to an address you name. Use it on a plan that ended failed or
partial. It is refused on a completed plan, where the remaining balance is
the fee and the reserve, both already accounted for. refund_requested_at is
set when the sweep is queued, and refunded_at only once it confirms. The pair
is how you tell "refund in flight" from "money back".
Authorization
bearerAuth JWT from /auth/login (users) or an org API key (mk_...) for integrations.
In: header
Path Parameters
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
curl -X POST "https://example.com/funding/plans/string/cancel" \ -H "Content-Type: application/json" \ -d '{}'{ "chain": "string", "completed_at": "string", "created_at": "string", "deposit_address": "string", "fee_amount": "string", "fee_bps": 0, "funding_delay": 0, "funds_seen_at": "string", "net_total": "string", "plan_id": "00713021-9aea-41da-9a88-87760c08fa72", "prepaid": true, "quote_id": "3c071a1d-db86-46a7-9dc8-72ba3fbca992", "received_amount": "string", "refund_address": "string", "refund_amount": "string", "refund_requested_at": "string", "refund_tx": "string", "refunded_at": "string", "rescue_attempts": 0, "rescue_completed": true, "rescue_completed_at": "string", "rescue_failed_probes": 0, "rescue_requested": true, "rescue_requested_at": "string", "rescue_swept_count": 0, "routing_total": "string", "started_at": "string", "status": "string", "total_required": "string", "wallet_count": 0}Authorization
bearerAuth JWT from /auth/login (users) or an org API key (mk_...) for integrations.
In: header
Path Parameters
Response Body
application/json
curl -X POST "https://example.com/funding/plans/string/rescue"{ "message": "string", "requested": true}Authorization
bearerAuth JWT from /auth/login (users) or an org API key (mk_...) for integrations.
In: header
Path Parameters
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
curl -X POST "https://example.com/funding/plans/string/refund" \ -H "Content-Type: application/json" \ -d '{ "refund_address": "string" }'{ "message": "string", "refunding": "string"}Listing and totals
GET /funding/plans is the plan list behind an operations screen. Beyond
status, it takes a group, the coarse bucket a dashboard tab filters on
(in_progress, completed, rescued, failed or refunded), and q, a
case-insensitive substring search across plan id, quote id, deposit address,
funded wallet address and buyer reference. quote_id is the exact match, for
listing one order's funding without guessing at a substring.
GET /funding/stats gives the dashboard tiles: plans and wallets funded,
volume, fees billed and the half credited to you, split by chain and filterable
by date range. It also separates initial fundings, a wallet's first ever,
from top-ups on wallets already funded.
Authorization
bearerAuth JWT from /auth/login (users) or an org API key (mk_...) for integrations.
In: header
Query Parameters
Coarse bucket, what the dashboard tabs filter on: in_progress (quoted/awaiting_funds/funded/running), completed, rescued (a finished rescue, whatever the plan's own status), failed (failed/partial/cancelled) or refunded. Combines with status.
int64int64Free text: plan id, quote id, deposit address, a funded wallet address, or a buyer ref. Substring match, case-insensitive.
Only the plans raised on this order. Exact — the way to list one order's funding without guessing at a substring.
uuidExact plan status: quoted, awaiting_funds, funded, running, completed, partial, failed, cancelled, refunded.
Response Body
application/json
curl -X GET "https://example.com/funding/plans"{ "items": [ { "buyer_ref": "string", "chain": "string", "completed_at": "string", "created_at": "string", "deposit_address": "string", "failed_count": 0, "fee_amount": "string", "fee_bps": 0, "funded_count": 0, "funding_delay": 0, "funds_seen_at": "string", "initial_count": 0, "net_total": "string", "org_id": "a40f5d1f-d889-42e9-94ea-b9b33585fc6b", "org_name": "string", "plan_id": "00713021-9aea-41da-9a88-87760c08fa72", "prepaid": true, "quote_id": "3c071a1d-db86-46a7-9dc8-72ba3fbca992", "received_amount": "string", "refund_address": "string", "refund_amount": "string", "refund_requested_at": "string", "refund_tx": "string", "refunded_at": "string", "rescue_attempts": 0, "rescue_completed": true, "rescue_completed_at": "string", "rescue_failed_probes": 0, "rescue_requested": true, "rescue_requested_at": "string", "rescue_swept_count": 0, "routing_total": "string", "started_at": "string", "status": "string", "topup_count": 0, "total_required": "string", "wallet_count": 0 } ], "total": 0}Authorization
bearerAuth JWT from /auth/login (users) or an org API key (mk_...) for integrations.
In: header
Query Parameters
RFC3339 lower bound (inclusive) on plan creation.
RFC3339 upper bound (exclusive).
Response Body
application/json
curl -X GET "https://example.com/funding/stats"{ "by_chain": [ { "chain": "string", "completed_plans": 0, "fee_org_total": "string", "fee_pending_total": "string", "fee_platform_total": "string", "fee_swept_total": "string", "fee_total": "string", "initial_plans": 0, "initial_wallets": 0, "net_total": "string", "plans": 0, "topup_plans": 0, "topup_wallets": 0, "wallets_funded": 0 } ], "completed_plans": 0, "fee_org_total": "string", "fee_pending_total": "string", "fee_platform_total": "string", "fee_swept_total": "string", "fee_total": "string", "initial_plans": 0, "initial_wallets": 0, "net_total": "string", "plans": 0, "topup_plans": 0, "topup_wallets": 0, "wallets_funded": 0}Last updated on
Security
How private keys are stored, where the decryption key lives, and the two moments a key exists in plaintext.
External farming
Hand us wallets you already hold and we give them a life. Daily on-chain activity under a persona, for as long as you ask, with the keys returned or forgotten at the end.