Withdrawals
AbstraPay supports two approval modes for withdrawals. Your operator is configured for one of them by the AbstraPay team.
| Mode | Who approves | How |
|---|---|---|
| Synchronous (default) | You, in real time + an admin in the panel | You approve/deny inline on the WITHDRAW_REQUEST webhook, then finalize in the management panel |
| External approval (async) | Your backend, via a callback | We 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:
- Auto-validation — the moment a user submits a withdrawal, AbstraPay calls your
WITHDRAW_REQUESTwebhook synchronously (before anything is created) so your backend can approve/deny based on the user's balance. A denial stops it immediately. - Manual approval — if your backend approved it, the request lands in your management panel at
PENDINGfor 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 user1. 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" }'status ∈ PROCESSING · 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 us | A webhook endpoint (already configured) to receive the notification |
| You send us | An APPROVE / REJECT decision, by calling POST /withdraw/{id}/decision |
| You get from us | The 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. withdrawRequestIdis 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 (settlementTxHashis set); aWITHDRAW_COMPLETEwebhook follows.REJECTED— rejected; no payout, no webhook.AWAITING_APPROVAL— held, 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:
heldReason | Meaning |
|---|---|
CAP_EXCEEDED | Above your per-transaction or rolling-24h daily cap. |
APPROVAL_NOT_ENABLED | Your operator isn't enabled to release withdrawals. |
OPERATOR_INACTIVE | Your operator account is inactive. |
EXPIRED | The request passed its TTL before you decided. |
TEMPORARILY_UNAVAILABLE | Can'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 do | Approve the withdrawal in your own backoffice; hold/reserve the user's balance on your side |
| You send us | The locked intent (POST /session/withdraw with executionMode:"HEADLESS"), then an execute call |
| You get from us | A 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
operatorReferencewith the same immutable payload returns the samesessionRef. A different immutable payload for the same reference is a409 OPERATOR_REFERENCE_CONFLICT. - TTL — an un-executed
CREATEDintent 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 webhooks — fiatAmount (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 safereasonCode. It never leaks raw RPC or internal errors.POST /withdrawals/{sessionRef}/cancelsucceeds only while no transaction hash exists yet. It releases the reservation and emitsWITHDRAW_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).
| Callback | Your action |
|---|---|
WITHDRAW_COMPLETE | Debit 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_CANCELLED | Release the reserve — cancelled before broadcast |
See Webhooks → Event Types for the full WITHDRAW_FAILED / WITHDRAW_CANCELLED payloads.
Recovery
If a headless payout stalls before broadcast — WAITING_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:
PENDING→PROCESSING→COMPLETED· orREJECTED. - Mode B:
AWAITING_APPROVAL→COMPLETED· orREJECTED. - Mode C (headless):
CREATED→EXECUTION_QUEUED→ (WAITING_FOR_GAS/RECONCILING) →COMPLETED· orFAILED(withretryable) · orCANCELLED.BROADCASTED/CONFIRMINGare derived views of aRECONCILINGrow, not separate statuses. - All modes:
CANCELLED(superseded by a newer request / session cancelled) andEXPIRED(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).
| code | meaning |
|---|---|
| 0 | approve |
| 1 | declined by operator |
| 2 | insufficient balance |
| 3 | temporarily unavailable |
| 4 | user not eligible |
| 5 | daily limit exceeded |
| 6 | amount too low |
| 7 | amount too high |
| 8 | verification required |
| 9 | account suspended |