Withdrawals

Withdrawals

AbstraPay supports two approval modes for withdrawals. Your operator is configured for one of them by the AbstraPay team.

ModeWho approvesHow
Synchronous (default)You, in real time + an admin in the panelYou approve/deny inline on the WITHDRAW_REQUEST webhook, then finalize in the management panel
External approval (async)Your backend, via a callbackWe notify you asynchronously; your backend calls us back APPROVE/REJECT and we settle automatically

Both modes end the same way: on approval AbstraPay pays out on-chain and sends a WITHDRAW_COMPLETE webhook so you debit the user.

There is also a third, widgetless path — Mode C — Headless — for operators who approve withdrawals in their own backoffice and drive the whole payout backend-to-backend, with no hosted widget rendered to the user. It is EVM / ERC-20 only and enabled per operator by the AbstraPay team.


Starting a withdrawal

Create a withdraw session with its own endpoint that takes the amount you're authorizing:

curl -X POST https://api.ensopay.io/session/withdraw \
  -H "Authorization: Bearer <OPERATOR_SECRET_KEY>" -H "Content-Type: application/json" \
  -d '{ "externalUserId": "user-123", "lang": "en", "platformFiatCurrency": "USD", "userBalance": "500", "amount": "100" }'
# → { "status": "success", "message": "Withdraw session created", "data": { "url": "https://widget.ensopay.io/?sid=<SESSION_ID>", "sessionRef": "<SESSION_REF>" } }

Required: externalUserId, lang, platformFiatCurrency, userBalance, and amount — the withdrawal amount in your platform fiat. Optional: email, operatorReference. Embed the returned url (or redirect to it) exactly like the deposit widget — the same close event and Redirect URL apply.

The amount is operator-set and locked: the user picks the destination address, network and token in the widget but cannot change the amount — the withdrawal is always valued from the amount you set here. From there it enters one of the two approval modes below.


Mode A — Synchronous (default)

A single withdrawal passes through your system twice:

  1. Auto-validation — the moment a user submits a withdrawal, AbstraPay calls your WITHDRAW_REQUEST webhook synchronously (before anything is created) so your backend can approve/deny based on the user's balance. A denial stops it immediately.
  2. Manual approval — if your backend approved it, the request lands in your management panel at PENDING for an admin to finalize.

Flow

user requests withdrawal
   → WITHDRAW_REQUEST webhook   (you validate balance/limits — approve or deny in real time)
   → status PENDING, shown in your management panel
   → you start processing        (PATCH status: PENDING → PROCESSING)
   → you finalize                (PATCH status: PROCESSING → COMPLETED / REJECTED)
   → on COMPLETED: on-chain payout, then WITHDRAW_COMPLETE webhook → you debit the user

1. Request — the user submits the withdrawal from the widget (they enter the destination address and pick the network/token). The amount is the operator-set amount from the withdraw session — the widget cannot change it.

2. WITHDRAW_REQUEST webhook (synchronous gate) — AbstraPay calls you and waits. Validate the user's balance/limits and respond:

  • Approve → { "error": 0, "description": "ok" }
  • Deny → { "error": <code>, "description": "..." } (see error codes below)

A denial stops the withdrawal immediately.

3. Manual approval (management panel) — approved requests sit at PENDING. Your admin reviews and finalizes them in the AbstraPay management panel (see Management Panel) — no integration work needed on your side. The finalize is a two-step state machine: a request must first move PENDING → PROCESSING, then PROCESSING → COMPLETED (or REJECTED). You cannot jump straight from PENDING to COMPLETED. Under the hood the panel makes two calls:

# Step 1 — claim the request for processing
curl -X PATCH https://api.ensopay.io/admin/withdrawals/<id>/status \
  -H "Authorization: Bearer <ADMIN_TOKEN>" -H "Content-Type: application/json" \
  -d '{ "status": "PROCESSING", "adminNote": "reviewing" }'
 
# Step 2 — finalize (COMPLETED or REJECTED)
curl -X PATCH https://api.ensopay.io/admin/withdrawals/<id>/status \
  -H "Authorization: Bearer <ADMIN_TOKEN>" -H "Content-Type: application/json" \
  -d '{ "status": "COMPLETED", "adminNote": "approved" }'

statusPROCESSING · COMPLETED · REJECTED. Note: <ADMIN_TOKEN> is a panel admin login token, not your OPERATOR_SECRET_KEY — these admin endpoints use a separate dashboard login.

4. Payout — on COMPLETED, AbstraPay transfers the funds on-chain to the recipient, then sends a WITHDRAW_COMPLETE webhook → debit the user.


Mode B — External approval (async callback)

In this mode you approve withdrawals from your own backend, on your own schedule, and AbstraPay settles automatically on your APPROVE — no admin panel step required.

External approval is enabled per operator by the AbstraPay team. You cannot self-enable it. It requires your webhook URL + secret to be configured (that's where the notification is delivered), and AbstraPay sets your approval caps (see below). Ask us to turn it on for your operator.

What each side does

You give usA webhook endpoint (already configured) to receive the notification
You send usAn APPROVE / REJECT decision, by calling POST /withdraw/{id}/decision
You get from usThe async WITHDRAW_REQUEST notification, then — on APPROVE — the on-chain payout + a WITHDRAW_COMPLETE webhook

Flow

user requests withdrawal
   → status AWAITING_APPROVAL, shown in your management panel
   → WITHDRAW_REQUEST webhook   (ASYNC + retried — just acknowledge with {error:0};
                                 the real decision comes from your callback, not this response)
   → your backend decides, whenever ready:
        POST /withdraw/{withdrawRequestId}/decision   { "decision": "APPROVE" | "REJECT" }
   → APPROVE → on-chain payout → WITHDRAW_COMPLETE webhook → you debit the user
   → REJECT  → status REJECTED (no payout, no webhook)

1. You receive the WITHDRAW_REQUEST notification (async)

Same event and payload as Mode A, with two differences:

  • It is asynchronous and retried (delivered via our reliable outbox), so your endpoint just needs to acknowledge it with { "error": 0, "description": "received" }. Your response is not the decision.
  • withdrawRequestId is the real withdrawal id (a UUID) — use it as the handle for your callback. (It is always populated, in both modes.)

2. You send your decision

curl -X POST https://api.ensopay.io/withdraw/{withdrawRequestId}/decision \
  -H "Authorization: Bearer <YOUR_OPERATOR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{ "decision": "APPROVE" }'
 
# reject with an optional note:
#  -d '{ "decision": "REJECT", "reason": "failed internal review" }'
⚠️

Auth here is your operator API key (the same Bearer key you use for POST /session/deposit and POST /session/withdraw) — not the dashboard admin token used by the /admin/* endpoints. You may only decide on withdrawals that belong to your own operator.

Response:

{
  "status": "success",
  "data": {
    "withdrawRequestId": "3f2b…",
    "withdrawStatus": "COMPLETED",
    "settlementTxHash": "0x…",
    "held": false,
    "heldReason": null
  }
}

withdrawStatus tells you the outcome:

  • COMPLETED — settled on-chain (settlementTxHash is set); a WITHDRAW_COMPLETE webhook follows.
  • REJECTED — rejected; no payout, no webhook.
  • AWAITING_APPROVALheld, not settled (see caps below). Retry later once conditions clear, or an AbstraPay admin resolves it.

When an APPROVE couldn't auto-settle, held is true and heldReason gives a stable code so you don't have to guess:

heldReasonMeaning
CAP_EXCEEDEDAbove your per-transaction or rolling-24h daily cap.
APPROVAL_NOT_ENABLEDYour operator isn't enabled to release withdrawals.
OPERATOR_INACTIVEYour operator account is inactive.
EXPIREDThe request passed its TTL before you decided.
TEMPORARILY_UNAVAILABLECan't settle right now (e.g. transient liquidity) — safe to retry.

Approval caps

AbstraPay bounds how much you can release, in your platform fiat:

  • Per-transaction cap — a single withdrawal above this is not auto-settled.
  • Daily cap — a rolling 24-hour cumulative limit across your settled withdrawals.

If an APPROVE would exceed either cap (or the bankroll is momentarily short), the request is held at AWAITING_APPROVAL rather than paid out — your callback response returns withdrawStatus: "AWAITING_APPROVAL". It then waits for an AbstraPay admin, or for you to retry once you're back within the caps.

Idempotency & overrides

  • Idempotent — safe to retry the callback. A decision on an already-decided withdrawal simply returns its current status; a payout is never sent twice.
  • Panel visibility — these withdrawals also appear in your management panel, where an AbstraPay admin can override (approve/reject) if needed.

Mode C — Headless (widgetless)

In headless mode you have already approved the withdrawal in your own backoffice and want to run the payout backend-to-backend, without rendering the AbstraPay widget to the user. You collect the amount, destination wallet, network and token yourself, then drive an immutable intent to settlement over your operator API key.

Headless is enabled per operator by the AbstraPay team (you cannot self-enable it) and is scoped to EVM chains and Bankroll-registered ERC-20 stablecoins only — no Tron, no native assets. See Networks & Assets. While disabled, every headless endpoint returns HEADLESS_NOT_ENABLED (see Errors).

What each side does

You doApprove the withdrawal in your own backoffice; hold/reserve the user's balance on your side
You send usThe locked intent (POST /session/withdraw with executionMode:"HEADLESS"), then an execute call
You get from usA sessionRef, then — after on-chain finality — a signed terminal callback (WITHDRAW_COMPLETE / WITHDRAW_FAILED / WITHDRAW_CANCELLED)

Flow

you approve in your own backoffice (AbstraPay is not involved yet)
   → POST /session/withdraw { executionMode:"HEADLESS", amount, address, chainId, tokenAddress, operatorReference }
        → { sessionRef, expiresAt }          (no raw sid, no widget URL; intent starts at CREATED)
   → (optional) GET /withdraw/options         (which chains/tokens can you offer? raw availability)
   → POST /withdrawals/{sessionRef}/execute    (bodyless, idempotent → 202 Accepted, EXECUTION_QUEUED)
        → AbstraPay reserves liquidity, prices, and settles on-chain asynchronously
   → after `withdrawConfirmations` on-chain confirmations:
        WITHDRAW_COMPLETE  → debit the user
   → or a terminal failure / cancellation callback (see accounting rules below)

1. Create the locked intent

POST /session/withdraw with executionMode:"HEADLESS" requires externalUserId, lang, platformFiatCurrency, userBalance, the immutable amount (a decimal string with at most two decimal places, e.g. "100" or "100.50" — it is echoed back verbatim as the two-decimal fiatAmount; more decimals are rejected with 400), destination address, chainId and tokenAddress, plus a mandatory operatorReference (your own id for this withdrawal). The address is checksum-normalized; zero, invalid, non-EVM/Tron and native inputs are rejected.

curl -X POST https://api.ensopay.io/session/withdraw \
  -H "Authorization: Bearer <OPERATOR_SECRET_KEY>" -H "Content-Type: application/json" \
  -d '{ "executionMode": "HEADLESS", "externalUserId": "user-123", "lang": "en",
        "platformFiatCurrency": "USD", "userBalance": "500", "amount": "100",
        "address": "<USER_WALLET_ADDRESS>", "chainId": 8453,
        "tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "operatorReference": "wd-9f2b" }'
# → 201 Created
#   { "status": "success", "data": { "sessionRef": "<SESSION_REF>", "expiresAt": "…" } }

The response carries a sessionRef and a 24-hour expiry — never a raw sid or widget URL. Store the sessionRef: it is the handle for every subsequent headless call.

  • Idempotent create — re-sending the same operatorReference with the same immutable payload returns the same sessionRef. A different immutable payload for the same reference is a 409 OPERATOR_REFERENCE_CONFLICT.
  • TTL — an un-executed CREATED intent expires after 24h; the same reference will not resurrect it.

2. (Optional) Check supported options and availability

GET /withdraw/options (operator key) lists the chains and tokens you can offer, with availability per asset so you can steer the user before you execute. Pass an optional sessionRef for a final pre-execute check of the locked intent's asset.

Each asset reports chainId / chainName, tokenAddress / symbol / displaySymbol / name / decimals, the bankroll balance, the amount reserved by in-flight withdrawals, the available remainder, and a status of AVAILABLE, UNAVAILABLE, or UNKNOWN. All amounts are in token units (e.g. "1000" = 1000 USDC), the same convention as the webhooks. Options does not lock a price or create a reservation — it is informational. UNKNOWN is advisory here, but execute fails closed on it (see below).

3. Execute

POST /withdrawals/{sessionRef}/execute is bodyless and idempotent. It atomically validates ownership + feature flag + intent state, locks a fresh price, converts your fiat amount to a raw ERC-20 amount, checks on-chain balance minus active reservations, creates the reservation and a durable settlement job, moves the request to EXECUTION_QUEUED, and returns 202 Accepted.

curl -X POST https://api.ensopay.io/withdrawals/<SESSION_REF>/execute \
  -H "Authorization: Bearer <OPERATOR_SECRET_KEY>"
# → 202 Accepted
#   { "status": "success", "data": { "sessionRef": "…", "withdrawalStatus": "EXECUTION_QUEUED",
#       "chainId": 8453, "tokenAddress": "0x8335…2913", "symbol": "USDC",
#       "fiatAmount": "100.00", "fiatCurrency": "USD",
#       "cryptoAmountSent": "99.98000000", "amountInUsd": "100.000000", "priceRate": "1.0002", … } }

The locked amounts come back in the same fields and units as the webhooksfiatAmount (your fiat), cryptoAmountSent (token units, 8 decimals), amountInUsd (USD). GET /withdrawals/{sessionRef} reports the same three.

⚠️

If price or availability is UNKNOWN at execute time, execute returns 503 and nothing is reserved or queued — it fails closed rather than guess. Retry once conditions clear. Because execute is idempotent, safely re-calling it never produces a second reservation or payout.

4. Track, and (optionally) cancel

  • GET /withdrawals/{sessionRef} returns a safe status — the domain status, tx-hash / confirmation state, retryability, callback state and a safe reasonCode. It never leaks raw RPC or internal errors.
  • POST /withdrawals/{sessionRef}/cancel succeeds only while no transaction hash exists yet. It releases the reservation and emits WITHDRAW_CANCELLED. Once a payout has broadcast (a tx hash exists), it cannot be cancelled — only reconciliation resolves it.

5. Settlement, finality and the terminal callback

The durable worker owns the chain interaction. A transaction hash is an uncertainty boundary: once a payout has broadcast it is never re-sent — automatically or manually — only reconciled. Completion is emitted only after the chain-configured withdrawConfirmations threshold is reached, at which point you receive a signed WITHDRAW_COMPLETE.

Headless emits no WITHDRAW_REQUEST — you already approved it before creating the intent.

Callback accounting — the one rule to get right

🚫

WITHDRAW_FAILED with retryable: true is NOT terminal. Keep the user's reserve — the same withdrawal may still reach a later WITHDRAW_COMPLETE, a terminal failure, or a cancellation. Only WITHDRAW_FAILED with retryable: false, or WITHDRAW_CANCELLED, releases the reserve. WITHDRAW_COMPLETE (after confirmations) is your debit trigger. This is an accounting signal, unrelated to webhook delivery-retry (see Retries).

CallbackYour action
WITHDRAW_COMPLETEDebit the user — funds paid out and confirmed on-chain
WITHDRAW_FAILED { retryable: true }Do nothing — keep the reserve; a later terminal event will follow
WITHDRAW_FAILED { retryable: false }Release the reserve — terminal, no payout
WITHDRAW_CANCELLEDRelease the reserve — cancelled before broadcast

See Webhooks → Event Types for the full WITHDRAW_FAILED / WITHDRAW_CANCELLED payloads.

Recovery

If a headless payout stalls before broadcastWAITING_FOR_GAS (no fundable signer) or a safe FAILED(retryable) with no tx hash — an AbstraPay ADMIN/SUPERADMIN can safely requeue it from the panel (POST /admin/withdrawals/:id/retry). This never re-broadcasts and never moves funds from the HTTP request; see Management Panel.


Statuses

  • Mode A: PENDINGPROCESSINGCOMPLETED · or REJECTED.
  • Mode B: AWAITING_APPROVALCOMPLETED · or REJECTED.
  • Mode C (headless): CREATEDEXECUTION_QUEUED → (WAITING_FOR_GAS / RECONCILING) → COMPLETED · or FAILED (with retryable) · or CANCELLED. BROADCASTED / CONFIRMING are derived views of a RECONCILING row, not separate statuses.
  • All modes: CANCELLED (superseded by a newer request / session cancelled) and EXPIRED (left unactioned past its 24h TTL).

WITHDRAW_REQUEST response codes

Used in Mode A to approve/deny inline. In Mode B, respond 0 to acknowledge (the decision is your callback).

codemeaning
0approve
1declined by operator
2insufficient balance
3temporarily unavailable
4user not eligible
5daily limit exceeded
6amount too low
7amount too high
8verification required
9account suspended