Overview
Sometimes a deposit can’t be filled: it was flagged by screening, sent in the wrong currency or on the wrong chain, or the order failed after the deposit landed. Those funds sit in the Depository until the original depositor reclaims them. relay.link/withdraw is a thin frontend over three public API endpoints, and your app can drive the same flow directly. This is useful if you want to handle recovery inside your own app instead of sending users to the self-serve UI, or run it programmatically on a server. It works especially well when you control the depositing wallet and can complete the signing flow yourself. There is no API key. Authorization is cryptographic: only the wallet that made the deposit (theowner) can produce a valid signature, so anyone can call these endpoints but only the depositor can move funds. The base URL is https://api.relay.link.
Supported Chains and Signers
User-triggered withdrawals are supported on EVM chains, Hyperliquid, Solana, Tron, and TON. Bitcoin and Lighter withdrawals are rejected with a400. Recovering those funds currently goes through support.
The owner must be the exact depositor address, and it must be able to sign an arbitrary message with its own key. Smart contract wallets, exchange-custodied addresses, and other signers that can’t produce a raw message signature can’t complete this flow, since there is no contract-signature (ERC-1271) path today. If the depositor key is inaccessible, contact support.
Withdrawal Flow
Step 1: Check Eligibility
Look up the request with the requests API:GET /requests/v3?user={wallet}&status=failure and filter on the protocol object.
In each returned request, the protocol object tells you what you need:
protocol.isWithdrawable—truemeans the deposit is recoverable via this flowprotocol.deposit.origin.depositor— the wallet that must sign (theownerbelow)protocol.deposit.origin— also carries the deposit’schainId,currency,amount, andtransactionIdfor the next steps
Step 2: Attest the Deposit
If the deposit hasn’t been claimed onto the protocol hub yet, attest it:chainId here is the numeric Relay chain id of the deposit chain. This is the one exception in the flow — every other chainId is a protocol slug (see the next step). A successful attestation returns { "success": true }.
The call is idempotent and safe to retry. A 503 (for example “Transaction not yet finalized” or “Recovery in progress”) means wait and retry. A 400 is terminal for that transaction.
Step 3: Prepare
Call the request endpoint without a signature to get signing parameters:chainId/ownerChainId— protocol chain slugs (base,bnb,solana), not numeric ids. Read them fromchains[].protocol.v2.chainIdin the chains API.currency— the token address on the withdrawal chain, or the zero address for the native tokenamount— raw base units as an integer stringrecipient— where the funds go. May differ fromowner.
{ "nonce": "0x…", "amount": "211891421", "additionalData": { … } }. Use the returned amount in every following step — it’s validated against the available hub balance, and requesting more than is available returns a 400. additionalData may be absent; when present, pass it through untouched.
The nonce is deterministic per one-minute window for a given (chain, owner, currency, recipient) tuple and expires quickly, so prepare, sign, and execute in one sitting. If the job later reports expired, restart from this step.
Step 4: Sign the Digest
Build a SHA-256 digest over the stable-stringified request and sign it with the owner wallet:operation: "withdrawal", the nonce, and additionalData exactly as prepare returned them. Key order doesn’t matter (json-stable-stringify sorts keys), but changing any value — including signing your original amount instead of the returned one — produces an invalid signature.
Sign the digest with the owner key and submit the signature as 0x-prefixed hex:
On TON, the wallet’s
signData response also includes a timestamp and domain. Submit them in the execute call as additionalData["ton-vm"] = { timestamp, domain }. They are needed for verification but are not part of the signed digest above.
Step 5: Execute
Repeat the same call with thenonce, additionalData, and signature added:
{ "jobId": "…", "status": "processing" }. Only one withdrawal per (chain, owner, currency) balance can be in flight at a time — a second request returns a 409 with code WITHDRAWAL_IN_PROGRESS and the existingJobId to poll instead.
Step 6: Poll for Status
Status entries are retained for 24 hours. An unknown or evicted id reports
processing, so don’t poll ids older than a day expecting a terminal state.
Step 7: Broadcast the Transaction
ready is not done. On TON the solver broadcasts for you and the status moves to executed on its own, but on every other supported chain, ready hands you a transaction object that the owner wallet must sign and broadcast on-chain, paying its own gas. Depending on the chain this is an EVM transaction request, Solana instructions, or a Tron TriggerSmartContract payload.
A job stuck at ready because the transaction was never broadcast is the most common integration mistake. After broadcasting, keep polling until the status reaches executed. If the owner is on a different VM than the withdrawal chain, the returned transaction is built for the recipient to broadcast instead.
Caveats
chainIdis a protocol slug (base, not8453) everywhere exceptattest-deposit, which takes the numeric id.- Sign and execute with the
amountthat prepare returned, not your original input. readyis not done — broadcast the returned transaction and keep polling.- Move quickly between prepare, sign, and execute. The nonce is short-lived; on
expired, re-prepare. - One withdrawal at a time per (chain, owner, currency). A second in-flight request returns a
409with the existingjobId, and the balance unlocks when the job reaches a terminal state.
Building with an AI assistant? Use the Copy page button at the top of
this page to hand it to your agent, or see Integrating using
AI to connect the Relay docs MCP server and
llms.txt.