Withdrawals

A withdrawal moves funds out of a user’s wallet to a bank account they have linked, as an ACH credit. POST /v1/users/{userId}/wallet/withdrawals accepts the partner Api-Key, so the request can be made server-to-server from your own backend. The hosted Withdraw Funds iframe calls the same endpoint with a user-scoped client token. The contract is identical in both cases.

Four properties shape the integration:

  • Withdrawals are irreversible. An ACH credit cannot be recalled once it has been submitted. Every request therefore requires an idempotencyKey, and a request that returns no response is an unknown outcome rather than a failure.
  • Destinations must be bank accounts. A card can fund a wallet but cannot receive an ACH credit, so a card paymentMethodId is rejected with VALIDATION_ERROR.
  • Settlement is asynchronous. The wallet is debited immediately and the response status is always pending. Funds typically arrive within one to two business days. No fee is charged.
  • Withdrawals are configured per program. If a request returns FORBIDDEN, contact your account contact to have withdrawals configured. This is the only reference to that requirement in this guide; the sections that follow assume it has been met.

Prerequisites

  • A user record. POST /v1/users:upsert returns the id used as {userId} throughout this guide.
  • A linked bank account. Complete Plaid Link with POST .../wallet/link-token and POST .../wallet/bank-accounts. An account linked for funding can also receive withdrawals, so a single linking flow covers both directions.
  • Credentials. Use the partner Api-Key for server-to-server requests; it may act on any userId. For requests that originate on a client, mint a user-scoped token with POST /v1/users/{userId}/auth/client-token. Do not embed the API key in an application binary.

Making a withdrawal

  1. List the destinations. Retrieve the wallet’s payment methods and keep the entries whose type is bank_account. If the wallet has none, complete Plaid Link first.
  2. Read the withdrawable balance. GET .../wallet/withdrawable-balance returns the amount a withdrawal will accept, together with the wallet balance. Bind any maximum control to withdrawableBalance.
  3. Collect an amount. The minimum is $1.00 and the ceiling is withdrawableBalance.
  4. Create an intent. When the user confirms, generate one UUID for that amount and destination and persist it alongside the request.
  5. Submit the request. On a 2xx, report the withdrawal as pending and display the balance returned in the response.
step 1 — list bank destinations
// Only bank accounts can receive an ACH credit, so filter cards out
// before presenting a destination list.
const { paymentMethods } = await fetch(
  `${UPTOP_API_BASE}/v1/users/${userId}/wallet/payment-methods`,
  { headers: { "Api-Key": process.env.UPTOP_API_KEY } },
).then((r) => r.json());

const banks = paymentMethods.filter((m) => m.type === "bank_account");
// -> [{ id: "pm-3c8b1e94", type: "bank_account", brand: "Chase",
//       last4: "0000", preferred: false }]
step 2 — read the withdrawable balance
// The ceiling a withdrawal will accept. Also returns the wallet balance,
// read at the same instant, so the two cannot disagree.
const { balance, withdrawableBalance, reason } = await fetch(
  `${UPTOP_API_BASE}/v1/users/${userId}/wallet/withdrawable-balance`,
  { headers: { "Api-Key": process.env.UPTOP_API_KEY } },
).then((r) => r.json());

// -> { balance: 15336, withdrawableBalance: 13336, currency: "usd",
//      reason: "held_recent_load" }

// Bind the maximum to withdrawableBalance, never to balance.
const max = withdrawableBalance;

// reason is present only when the two figures differ, so branch on it
// before looking anything up. Explain the gap without stating dates or
// per-reason amounts.
const explanation = reason
  ? {
      open_card_hold:
        "A card purchase is still pending. Funds will be available once purchases finalize.",
      held_recent_load:
        "Recently added funds are still processing and can't be withdrawn yet.",
      non_cash_funds:
        "Some of your balance is promotional credit, which can't be withdrawn.",
      maximum_withdrawal:
        "This is the most you can withdraw at once. The rest stays in your wallet.",
      below_minimum_withdrawal: "Withdrawals start at $1.00.",
    }[reason] ?? "Some of your balance isn't available to withdraw yet."
  : null;
steps 4–5 — create the intent and submit
import { randomUUID } from "crypto";

// Generate the idempotency key when the user confirms the withdrawal and
// persist it with the intent, before the first request is sent.
async function startWithdrawal(userId, amountCents, paymentMethodId) {
  const intent = await db.withdrawalIntents.create({
    userId,
    amountCents,
    paymentMethodId,
    idempotencyKey: randomUUID(),
    state: "submitting",
  });
  return submit(intent);
}

// A single submit path serves the first attempt and every retry. The amount,
// destination, and key are read from the stored intent so that a retry sends
// an identical request.
async function submit(intent) {
  const response = await fetch(
    `${UPTOP_API_BASE}/v1/users/${intent.userId}/wallet/withdrawals`,
    {
      method: "POST",
      headers: {
        "Api-Key": process.env.UPTOP_API_KEY,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        amountCents: intent.amountCents,
        paymentMethodId: intent.paymentMethodId,
        idempotencyKey: intent.idempotencyKey,
      }),
    },
  );

  if (response.ok) {
    const withdrawal = await response.json();
    // Retiring the key marks the intent complete, so the user's next
    // withdrawal is treated as a new one rather than a replay of this one.
    await db.withdrawalIntents.update(intent.id, {
      state: "pending",
      transactionId: withdrawal.transactionId,
    });
    return withdrawal;
  }

  const { error } = await response.json();
  // 400, 402, and 403 are refusals: the wallet was not debited. Display
  // error.message and allow the user to adjust the request.
  throw new WithdrawalRefused(error.code, error.message);
}

Withdrawable balance

The wallet balance is an upper bound, not the amount available to withdraw. GET /v1/users/{userId}/wallet/withdrawable-balance returns both figures from a single read, so a screen can show the difference without a second request. When withdrawableBalance is below balance, a reason field explains why.

reasonMeaningClears
open_card_holdA pending card authorization blocks withdrawals. withdrawableBalance is 0 regardless of the balance.When the purchase finalizes
held_recent_loadRecently added funds have not finished clearing.On its own, once processing completes
non_cash_fundsPart of the balance is promotional or otherwise not cashable.Never
maximum_withdrawalThe balance exceeds the per-withdrawal maximum. withdrawableBalance is that maximum, so the remainder can be withdrawn in a later request.On the next withdrawal
below_minimum_withdrawalWhat remains is under the per-withdrawal minimum, so withdrawableBalance is 0 rather than an amount that would be refused.When the balance grows
  • One reason at a time. Where several constraints apply, the response reports whichever one determines the returned figure. Treat an unrecognized value as a generic explanation rather than an error: further values may be added.
  • Do not state clearing dates or per-reason amounts. The response deliberately carries neither. The holding period is a fraud control, so publishing when funds unlock, or how much each constraint holds, exposes that control. “Some funds are still processing” tells the user what they need to know.
  • A snapshot, not a reservation. A card authorization or a settling load can change the figure moments later, so the withdrawal endpoint remains the authority. Read the balance again before offering a retry.
  • The per-withdrawal limits are already applied. withdrawableBalance never exceeds the largest amount one withdrawal accepts, and is reported as 0 rather than as an amount below the minimum. A control bound to it cannot produce an out-of-bounds amount, and no integration needs to hardcode either limit.

A FORBIDDEN response usually means withdrawals are not configured for the program, in which case the withdrawal endpoint returns the same code and the screen should be hidden rather than retried. The code is also returned when a client token is scoped to a different user than the one in the path, so confirm the token subject matches before concluding the program is not configured.

Endpoint reference

The reference card below also appears under Docs → Wallet. It is repeated here so that the contract and the guide can be read together. Try it submits a live withdrawal against https://insomniac-api-dev.uptop.xyz.

Create a withdrawal

Submits an ACH credit from the user's wallet to a bank account they have linked. The wallet is debited immediately and the response status is always `pending`; the credit settles over the following business days and its outcome appears as later entries in `GET /users/{userId}/wallet/transactions` rather than on this response. `paymentMethodId` is required and must identify a bank account, so complete Plaid Link before calling this endpoint. Accepts either the partner `Api-Key`, which may act on any user, or a user-scoped client token from `POST /users/{userId}/auth/client-token`. Every request requires an `idempotencyKey`; see the notes for its lifecycle.

POST/v1/users/{userId}/wallet/withdrawals

Path parameters

FieldTypeRequiredDescription
userIdstringrequiredUptop user id returned from `POST /users:upsert`.

Request body

FieldTypeRequiredDescription
amountCentsintegerrequiredAmount to withdraw, in cents. The minimum is `100` ($1.00). Amounts above the per-withdrawal cap are refused with `VALIDATION_ERROR`.
paymentMethodIdstringrequiredDestination bank account: an `id` from `GET /users/{userId}/wallet/payment-methods` with `type: "bank_account"`, or the `paymentMethodId` returned when the account was linked. Cards are rejected. The field has no default, so the destination is always explicit.
idempotencyKeystring (UUID)requiredIdentifies one withdrawal intent and must stay constant across every retry of that intent, so that a replay returns the original transaction rather than submitting a second ACH credit. Generate a new key whenever the amount or destination changes; reusing a key with a different amount returns 409.
example body
{
  "amountCents": 5000,
  "paymentMethodId": "pm-3c8b1e94",
  "idempotencyKey": "6f8b0d4a-2e51-4b7c-9a13-8c5d2f0e7b64"
}

Response — 201

ReturnsWithdrawal

Click the type for the full field-by-field shape.

example response (201)
{
  "transactionId": "txn-7b21c4de",
  "status": "pending",
  "amountCents": 5000,
  "paymentMethodId": "pm-3c8b1e94",
  "balance": 10336,
  "currency": "usd",
  "createdAt": "2026-08-24T15:04:00.000Z"
}

Errors

  • UNAUTHORIZED401Missing or invalid `Api-Key` or client token.
  • VALIDATION_ERROR400`amountCents` is below `100` or over the per-withdrawal cap, `idempotencyKey` is missing or not a UUID, or `paymentMethodId` is a card rather than a bank account.
  • VALIDATION_ERROR409The `idempotencyKey` was already used for a withdrawal of a different amount, which identifies a stale key rather than a replay. Create a new intent with a new key; do not retry this request.
  • INSUFFICIENT_FUNDS402The withdrawable balance is below `amountCents`, because the wallet holds less than that amount, a recent load has not cleared, or a card hold is open. Retriable once the funds clear.
  • WITHDRAWAL_LIMIT_EXCEEDED400The request exceeds the daily outbound limit. Retriable with a smaller amount or on a later day.
  • FORBIDDEN403Declined by a risk check, or withdrawals are not yet configured for the program. Not retriable; contact your account contact.
  • NOT_FOUND404`userId` does not exist, or `paymentMethodId` is not a payment method on this user's wallet.
  • INTERNAL_ERROR502The wallet service was unavailable or did not respond in time. The withdrawal may or may not have been created; retry with the same `idempotencyKey`.

Notes

  • **Reuse the `idempotencyKey` on every retry.** A request that times out and is retried under a new key submits a second ACH credit. Generate one UUID per withdrawal intent, meaning the amount and destination the user confirmed, persist it before the first request, and send that same key, amount, and destination on every retry. Discard the key after a 2xx, and generate a new one when the user changes the amount or destination.
  • **A timeout or a 502 is an unknown outcome, not a failure.** The withdrawal may already exist. Retry with the same key, since a replay returns the original transaction rather than submitting a second ACH credit. Do not resolve the ambiguity by creating a new withdrawal, and note that the transaction list cannot identify one: transaction records carry no idempotency key, so two withdrawals of the same amount to the same destination are indistinguishable.
  • **Check the withdrawable balance before submitting.** The wallet balance is an upper bound: recently added funds are held until they clear, an open card hold blocks withdrawals entirely, and the per-withdrawal limits bound what one request may take. `GET /users/{userId}/wallet/withdrawable-balance` reports the amount this endpoint will accept, and any maximum offered to the user should come from there. That figure is a snapshot rather than a reservation, so still handle `INSUFFICIENT_FUNDS` and read it again before retrying.
  • **Withdrawing recently added funds can deduct points.** Programs may award points when funds are added to the wallet; when recently added funds leave as cash rather than being spent, the points awarded for the withdrawn amount are deducted again. Withdrawing part of a recent load deducts the bonus on that part only, and one load is never deducted twice across withdrawals. The deduction posts as a negative-`delta` entry of type `other` in `GET /users/{userId}/points/events`, and the points balance can go negative when those points were already redeemed. In-venue spending never triggers this. The lookback window, like the funds-availability holds, is a fraud control and is not disclosed.

Try it (curl)

curl
Set an API key in the top-right API settings menu —$UPTOP_API_KEY in the curl will be substituted for it on send.

Edit anything above, then send through the local proxy.

Idempotency and retries

idempotencyKey is required on every request. The key identifies a withdrawal intent, so a retry of that intent returns the original withdrawal rather than submitting a second ACH credit. Four rules govern its lifecycle:

  • Scope. One key per intent, where an intent is the amount and destination the user confirmed. Generate the key at that moment and persist it. A key generated inside the request handler changes on every attempt and provides no protection.
  • Replay. A retry must carry the same key, amount, and destination. Recalculating the amount from a balance that the first attempt already debited produces a different request and a second credit.
  • Rotation. A change of amount or destination is a new intent and requires a new key. Reusing a key with a different amount returns 409.
  • Retirement. Discard the key after a 2xx so that the user’s next withdrawal is treated as a new intent.
retrying an unresolved request
// A timeout or a 502 leaves the outcome unknown rather than failed, so the
// same intent is retried. A replay returns the original withdrawal instead
// of submitting a second ACH credit.
async function submitWithRetry(intent) {
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      return await submit(intent);
    } catch (err) {
      const unresolved =
        err instanceof WithdrawalRefused
          ? err.code === "INTERNAL_ERROR"
          : true; // network error or timeout: no response at all
      if (!unresolved) throw err;
      await sleep(2 ** attempt * 1000);
    }
  }
  // Still unresolved. Keep the intent open with its key and replay it later.
  // Do not create a second withdrawal, and do not attempt to identify this
  // one in the transaction list: transactions carry no idempotency key, so
  // two similar debits cannot be distinguished.
  throw new WithdrawalUnresolved(intent.idempotencyKey);
}

The key is evaluated before the balance and hold checks, so a retry cannot be refused by a hold that appeared after the first attempt. If a request remains unresolved after several replays, keep the intent open and contact your account contact with the idempotency key. The wallet transaction list cannot be used to resolve it: transaction records carry no idempotency key, so two withdrawals of the same amount to the same destination cannot be distinguished.

Error handling

Refusals are returned in the standard error envelope. Branch on error.code. The accompanying error.message is written for presentation to the user, so displaying it directly keeps the wording consistent across integrations.

CodeWallet debitedResolution
INSUFFICIENT_FUNDSNoThe withdrawable balance is lower than the requested amount, typically because a recent load has not cleared or a card hold is open. Offer a smaller amount or prompt the user to try again later. The key remains valid.
WITHDRAWAL_LIMIT_EXCEEDEDNoThe request exceeds the daily outbound limit. Retry with a smaller amount or on a later day.
VALIDATION_ERRORNoAn invalid amount, a card as the destination, or, with status 409, a key already used for a different amount. Correct the request; for a 409, create a new intent with a new key.
FORBIDDENNoDeclined by a risk check, or withdrawals are not yet configured for the program (see Overview). Do not retry.
NOT_FOUNDNoUnknown user, or a paymentMethodId that is not attached to this wallet. Retrieve the payment methods again.
INTERNAL_ERRORUnknownNo usable response was returned. Retry with the same key, as described under Idempotency and retries. A network timeout with no response is the same case.

Withdrawable balance. Reading the withdrawable balance first prevents most INSUFFICIENT_FUNDS refusals, but not all of them: the figure is a snapshot rather than a reservation, and a card authorization or a settling load can change it moments later. Keep the refusal path, and read the figure again before offering a retry.

Settlement

A 2xx response confirms that the wallet was debited and the ACH credit was submitted, not that the funds have arrived. status is always pending on this response. Settlement appears as later entries in GET /v1/users/{userId}/wallet/transactions, matched on transactionId.

matching a withdrawal against the transaction list
// Applies once a 2xx response has supplied a transactionId. Settlement
// appears as later entries in the wallet transaction list.
const { transactions } = await fetch(
  `${UPTOP_API_BASE}/v1/users/${userId}/wallet/transactions?limit=20`,
  { headers: { "Api-Key": process.env.UPTOP_API_KEY } },
).then((r) => r.json());

const match = transactions.find((t) => t.id === withdrawal.transactionId);

Funds typically arrive within one to two business days, and an ACH credit can still be returned after that. Report the withdrawal as pending rather than complete until a settlement entry appears.

Hosted alternative

The Withdraw Funds embed is a single <iframe> that implements everything described in this guide: destination selection, amount entry, idempotency, and refusal handling. It calls this endpoint with a short-lived client token. Calling the API directly and embedding the hosted screen are alternatives rather than layers, so choose one per surface.