Withdraw (Cash out)
Stripe Connect withdrawal — pay out earned wallet balance to a user's bank. Connect onboarding, POST /io/wallet/withdraw, and withdrawal history. 1 credit = $1.
Withdrawal lets a user cash out the eligible part of their wallet balance to their own bank account via Stripe Connect Express. It is available from any Misar product through the central wallet API.
POST https://api.misar.io/io/wallet/connect/onboard
GET https://api.misar.io/io/wallet/connect/status
POST https://api.misar.io/io/wallet/withdraw
GET https://api.misar.io/io/wallet/withdrawals
Only EARNED balance is withdrawable
The wallet balance is mostly funded by card top-ups. Allowing those to be cashed out to a bank would enable card-refund/chargeback arbitrage. So a separate withdrawable balance tracks only genuinely earned income (affiliate/referral payouts, creator earnings, received transfers, manual grants) credited via the internal credit_withdrawable_balance RPC. Top-ups, subscription bonuses and migration adjustments never increase it. A withdrawal can never exceed min(withdrawable, balance), and spending clamps the withdrawable figure down — so earned credits, once spent, can't be re-withdrawn after a later top-up.
Policy defaults
| Policy | Default |
|---|---|
| Minimum withdrawal | $10 |
| Maximum per withdrawal | $100,000 |
| KYC | Stripe Connect Express onboarding; payoutsEnabled must be true |
| Concurrent open requests | 3 |
| Rolling-24h requested volume | $10,000 |
| Eligible balance | Earned only — min(withdrawable, balance) |
Authentication
SSO bearer (own wallet) or service key (+ user_id), same as the rest of the wallet API.
Authorization: Bearer <sso_access_token>
# — or —
x-wallet-service-key: <WALLET_SERVICE_KEY> # with body.user_id / ?user_id=<uuid>
1. Connect onboarding — POST /connect/onboard
Creates (first time) or resumes a Stripe Connect Express account and returns a hosted onboarding link. returnUrl must be an absolute *.misar.io URL.
curl -X POST "https://api.misar.io/io/wallet/connect/onboard" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "content-type: application/json" \
-d '{ "returnUrl": "https://reach.misar.io/wallet" }'{ "url": "https://connect.stripe.com/setup/e/acct_.../...", "accountId": "acct_..." }Redirect the user to url. Identity/bank verification is handled entirely inside Stripe. When Stripe clears the account it fires an account.updated webhook that flips payoutsEnabled on — withdrawals are blocked until then.
2. Status — GET /connect/status
One call returns balances, the cash-out-eligible amount, and Connect state.
{
"balance": 150,
"withdrawable": 100,
"effectiveWithdrawable": 100,
"minWithdrawalDollars": 10,
"currency": "usd",
"connectAccountId": "acct_...",
"hasConnectAccount": true,
"detailsSubmitted": true,
"payoutsEnabled": true,
"connectStatus": "complete",
"pendingWithdrawals": 0,
"totalWithdrawn": 40
}3. Withdraw — POST /withdraw
curl -X POST "https://api.misar.io/io/wallet/withdraw" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "content-type: application/json" \
-d '{ "amountDollars": 30, "idempotency_key": "wd-2026-07-31-abc" }'| Field | Type | Notes |
|---|---|---|
amountDollars | number | 1 credit = $1. Whole cents; min $10. |
idempotency_key | string? | Strongly recommended — a retried request with the same key never double-debits. |
user_id | string? | Required only with a service key. |
{ "status": "processing", "requestId": "…", "amountDollars": 30, "balanceAfter": 120 }How it settles
- The wallet atomically validates policy and debits the ledger first (idempotent on
idempotency_key) — money is reserved before it moves. - A Stripe Transfer moves funds platform → the user's connected account (idempotent on the request id). If this fails, the debit is reversed and the balance restored — you get
502and the user is not charged. - An explicit Payout (connected account → bank) captures a
po_id. If only the payout fails, funds are safe in the user's Connect balance and the request staysprocessing. payout.paid/payout.failed/payout.canceledwebhooks move the request to its terminal status. A bank-payout failure does not reverse the ledger — the funds remain in the user's Stripe balance to retry.
This ordering makes a double-payout impossible and a negative balance impossible.
Rejections
4xx responses carry a machine-readable reason:
| reason | HTTP | Meaning |
|---|---|---|
no_connect_account | 409 | No payout account — run onboarding first. |
kyc_required | 409 | payoutsEnabled is false — finish Stripe onboarding. |
below_minimum | 400 | Below the $10 minimum. |
insufficient_withdrawable | 400 | Exceeds the earned, cash-out-eligible balance. |
too_many_pending | 429 | Too many withdrawals already in progress. |
daily_cap_exceeded | 429 | Rolling-24h withdrawal cap reached. |
4. History — GET /withdrawals
Cursor-paginated (same shape as transactions).
{
"items": [
{
"id": "…", "amount_credits": 30, "amount_cents": 3000, "status": "paid",
"stripe_transfer_id": "tr_…", "stripe_payout_id": "po_…",
"failure_reason": null, "created_at": "…", "completed_at": "…"
}
],
"nextCursor": null
}SDK
import { createWalletClient } from "@misar/wallet"; // ≥ 1.2.0
const wallet = createWalletClient({
baseUrl: "https://api.misar.io/io/wallet",
serviceKey: process.env.WALLET_SERVICE_KEY,
});
const summary = await wallet.getWithdrawalSummary(userId);
if (!summary.payoutsEnabled) {
const { url } = await wallet.startConnectOnboarding({ userId, returnUrl });
// redirect the user to `url`
} else {
await wallet.withdraw({ userId, amountDollars: 30, idempotencyKey: "wd-…" });
}
const history = await wallet.listWithdrawals(userId);