API - Recovery Integration Guide
Use zerohash's API's to allow your customers to recover funds
Overview & Concepts
Platform-hosted Recovery lets your Platform build its own recovery experience directly into your product, rather than routing customers to zerohash's hosted recovery UI. Your Platform calls zerohash's withdrawal APIs directly, owns the UI/UX end-to-end, and is responsible for surfacing quote details, confirming intent, and tracking settlement status back to the customer.
This guide covers the API-only path: locking a network fee quote, executing the withdrawal, and tracking it to completion - via GET /withdrawals/locked_network_fee, POST /withdrawals/execute, webhooks, and GET /withdrawals/requests.
The solution encompasses the following steps:
- Listen to webhooks to become aware of a failure scenario
- Notify your customer
- Create an experience that collects the return address
- Get a withdrawal quote via GET /withdrawals/locked_network_fee
- Execute the withdrawal quote via POST /withdrawals/execute
- Track settlement via webhooks and/or GET /withdrawals/requests
Fee Model
By default, the customer pays the network fee - it's deducted from the withdrawal amount, not charged on top of it. If a customer requests to withdraw 500 USDC on Ethereum, and the network fee quote comes back at 2.15 USDC, the customer receives net_withdrawal_quantity: 497.85 USDC on-chain - the requested amount minus the fee, not the requested amount in full.
Authentication
Authentication follows our standard API Authentication practices of HMAC signing. Your API key must have write permission on the /withdrawals resource to call either endpoint in this flow.
What triggers recovery
For customers depositing via Classic Transfer (i.e., scanning a QR code and sending from their own external wallet, rather than a custodial/API-linked flow), the following scenarios cause the deposit to fail rather than complete normally:
- The amount sent is below the platform's configured minimum threshold
- The amount sent is above the platform's configured maximum threshold
- The customer sends an asset the platform hasn't configured for the product (e.g., the customer sends ETH, but the platform only supports USDC deposits)
NoteWhen any of these scenarios occurs, the funds are not lost - they land in a dedicated
recovery_quarantineaccount label under the customer's participant record, rather than being credited to their normal available balance.
Steps
1 - Listen to webhooks to become aware of a failure scenario
See scenarios here (all rows except the first one) for all failure scenario. When you receive one of these webhooks, you know that the participant_code in question will want to recover their funds.
2 - Notify your customer
A typical flow involves one or both of the following options:
- Option 1: Programmatically send an email to your customer notifying them that their funds are safe, but the transaction didn't succeed. The call to action should direct them to a UI you've built that allows them to authenticate into their account and supply a return address, or
- Option 2: Trigger a notification in your customer-facing app to alert them that they need to supply a return address
3 - Create an experience that collects the return address
Before letting the customer enter a withdrawal address, confirm that the person logging in is who you expect them to be. Since this flow asks for a destination for recovered funds, it should sit behind the same authentication your Platform already requires for other sensitive account actions. Don’t accept a return address from an unauthenticated session.
Once authenticated, build a UI where the customer can submit their return address. Before accepting it, validate the address by calling GET /withdrawals/validate_address, passing the customer-entered value as address and the fund_asset from the Account Funding webhook (or GET /fund/transactions) as asset. This confirms the address is correctly formatted for the asset’s network before it’s ever submitted for withdrawal.
4 - Get a withdrawal quote
GET /withdrawals/locked_network_fee
Request either a specific amount or max_amount: true to quote the full available balance. You must also supply either withdrawal_account_id (a pre-whitelisted destination) or withdrawal_address (a raw address).
Example request:
GET /withdrawals/locked_network_fee?participant_code=ABCDEF&asset=USDC.ETH&amount=500&withdrawal_address=0x7f41A26C0CB9D5a1e57BE9cF10F8354b77CD8ab3
Example response:
{
"participant_code": "ABCDEF",
"account_group": "JMCH0H",
"account_label": "general",
"asset": "USDC.ETH",
"amount": "500",
"max_amount": false,
"withdrawal_account_id": "",
"withdrawal_address": "0x7f41A26C0CB9D5a1e57BE9cF10F8354b77CD8ab3",
"destination_tag": "",
"no_destination_tag": false,
"withdrawal_quote_id": "bdc4c936-8505-4e61-b859-f3a5a6bdc880",
"network_fee": "2.15",
"network_fee_notional": "2.15",
"net_withdrawal_quantity": "497.85",
"net_withdrawal_notional": "497.85",
"amount_notional": "500.00",
"withdrawal_fee": "0"
}
NoteTime-sensitive
withdrawal_quote_idis only valid for 30 seconds. Your Platform should present the quote (network fee, net amount the customer will receive) and confirm intent quickly, then call/executeimmediately upon confirmation - a stale quote will need to be re-requested.
5 - Execute the withdrawal quote
POST /withdrawals/execute
Reference the withdrawal_quote_id from step 1. No participant, asset, or amount fields are passed again - everything was already locked into the quote.
Example request:
{
"withdrawal_quote_id": "bdc4c936-8505-4e61-b859-f3a5a6bdc880",
"client_withdrawal_request_id": "b30739eb-173d-45f9-a03e-281e7c4db22c"
}Example response:
{
"network_fee": "2.15",
"network_fee_notional": "2.15",
"on_chain_status": "PENDING",
"withdrawal_fee": "0",
"client_withdrawal_request_id": "b30739eb-173d-45f9-a03e-281e7c4db22c"
}on_chain_status starts as PENDING immediately upon execution and transitions to CONFIRMED once the transaction settles on-chain - tracked going forward via webhooks and GET /withdrawals/requests, covered next.
6 - Track withdrawal settlement
Webhooks (recommended for real-time tracking). Subscribe to account_balance.changed - set x-zh-hook-payload-type to that value. Whenever the withdrawal moves the participant's balance, you'll get a notification carrying a withdrawal_pending movement, tied back to your withdrawal via withdrawal_request_id:
{
"account_group": "JMCH0H",
"account_label": "general",
"account_type": "available",
"asset": "USDC.ETH",
"balance": "497.85",
"movements": [
{
"account_id": "a33bb05d-7e06-4069-921d-a83deebfc319",
"change": "500",
"movement_id": "c979b77b-80a0-49da-b287-0cc044731954",
"movement_timestamp": 1746113199139,
"movement_type": "withdrawal_pending",
"withdrawal_request_id": "44b23087-8845-4667-9fa8-03b7055dba6b"
}
],
"participant_code": "ABCDEF",
"run_id": "4150535",
"run_type": "withdrawal",
"timestamp": 1746113199073
}
NoteThis webhook fires whenever the balance changes — it tells you that a withdrawal debited the account and by how much, tied to
withdrawal_request_id. It doesn't carryon_chain_status(PENDING/CONFIRMED) or the ledgerstatus(PENDING/APPROVED/SETTLED) — for those, pollGET /withdrawals/requestsusing the samewithdrawal_request_idto get the full state.
GET /withdrawals/requests (recommended as a fallback/reconciliation check, or if webhooks aren't configured). Poll for the withdrawal's current state directly:
GET /withdrawals/requests?client_withdrawal_request_id=b30739eb-173d-45f9-a03e-281e7c4db22c
You can also filter by on_chain_status PENDING or CONFIRMED) or status, useful for a periodic reconciliation job that catches any withdrawal your Platform might have missed a webhook for.
Core APIs
The following APIs are used in this solution:
Updated 11 days ago