Deposit addresses
Let users swap or bridge by sending tokens to an address, with no wallet connection or signing in your app. Each deposit becomes an order with the usual webhooks; the address belongs to the liquidity provider, so LayerHop never holds funds.
A deposit address lets your user swap or bridge just by sending tokens to an address: no wallet connection, no signature in your app. You create an address for a route (for example USDC on Base → USDC on Arbitrum, delivered to your user's address), show it to your user, and every deposit to it is swapped or bridged and delivered to the recipient. It fits exchanges, games, payroll, and wallets with a "deposit from any chain" button.
Deposit addresses are non-custodial. The address is created by the liquidity provider (Relay), which picks up each deposit and delivers dstToken on dstChain. LayerHop never holds the funds; it creates the address, detects the deposits, records each one as an order and sends you webhooks.
Deposit addresses work with live (
lh_live_) and test (lh_test_) keys. Build and check your integration in test mode first: test addresses are fake and deposits are simulated.
How it works#
- Create an address with an API key: the source chain and token, the destination chain and token, the
recipient, and arefundToaddress on the source chain. - Show
addressto your user, with the token and chain to send (tokenonchainId), for example as text and a QR code. - Your user sends
srcTokenonsrcChainto the address from any wallet or exchange. - LayerHop detects the deposit and records it as an order (
quoteIdstarting withdep_,depositAddressIdset). You getorder.submitted, thenorder.filled(ororder.refunded/order.failed) when it settles.
1. Create a deposit address#
curl -X POST https://api.layerhop.com/v1/deposit-addresses \
-H "x-api-key: $LAYERHOP_API_KEY" -H "content-type: application/json" \
-d '{
"srcChain": "base",
"srcToken": "USDC",
"dstChain": "arbitrum",
"dstToken": "USDC",
"recipient": "0xUserAddressOnArbitrum",
"refundTo": "0xUserAddressOnBase",
"label": "user-4821"
}'With the TypeScript SDK:
import { LayerHop } from "@layerhop/sdk";
const lh = new LayerHop({ apiKey: process.env.LAYERHOP_API_KEY });
const da = await lh.depositAddresses.create({
srcChain: "base",
srcToken: "USDC",
dstChain: "arbitrum",
dstToken: "USDC",
recipient: "0xUserAddressOnArbitrum",
refundTo: "0xUserAddressOnBase",
label: "user-4821",
});
console.log(da.address); // show this to your user: send USDC on Base here
console.log(da.estimate.dstAmount); // what 100 USDC would deliver, in base units| Field | Notes |
|---|---|
srcChain, srcToken |
Where your user deposits, and the only token to send. Chains and tokens are given as for quotes (id or slug; address, symbol or native symbol). |
dstChain, dstToken, recipient |
What is delivered, where, and to whom. |
refundTo |
Your user's address on the source chain. Refunds (an underpaid one-time address, a deposit that cannot be routed) go here. Never use an exchange (CEX) deposit address. |
reusable |
true (default): the address can be used again and again for the same route, with any amount; each deposit is a separate order. false: a one-time address for exactly amount. |
amount |
Base units of srcToken. Required for a one-time address. For a reusable one it only sizes the estimate (default about $100). |
label, metadata |
Your reference (up to 64 characters, filterable in the list) and up to 20 string key-value pairs. |
integrator, integratorFeeBps |
Your partner fee, with the same rules as for quotes. |
The response (201) is the deposit address: id (da_…), livemode, status (active), provider (relay, or sandbox in test mode), address, chainId and token (what to send where), dstChainId, dstToken, recipient, refundTo, reusable, amount (one-time addresses only), fees (ourFeeBps, integratorFeeBps, integrator), label, metadata, depositCount, lastDepositAt and createdAt, plus an estimate: { amount, dstAmount, dstAmountMin, estimatedSeconds }. The estimate is a guide only: each deposit is priced when it arrives.
Creating addresses uses the stricter rate-limit bucket. A route the provider cannot serve returns UNSUPPORTED_CHAIN.
2. Tell your user what to send#
Show the address together with the exact token and chain, and these rules:
- Send only
srcTokenonsrcChain. Other tokens, or the same token on another chain, sent to this address may be lost for good. Say it clearly next to the address. - Send at least $1 worth. Smaller deposits may never be picked up (the provider ignores deposits worth $0.05 or less). Creating an address with a smaller
amountreturnsAMOUNT_TOO_SMALL. - One-time addresses take exactly
amount, once. An underpayment is refunded torefundTo; do not reuse the address afterwards. - Withdrawing from an exchange works, but refunds always go to
refundTo, never back to the exchange. That is whyrefundTomust be an address your user controls.
3. Track deposits#
Every deposit LayerHop detects becomes an order with quoteId starting with dep_ and depositAddressId set to the address id. Subscribe a webhook endpoint to the order events:
| Event | Sent when |
|---|---|
order.submitted |
A deposit is seen and is being processed. |
order.filled |
dstToken was delivered to recipient. |
order.refunded |
The deposit was refunded to refundTo. |
order.failed |
The deposit could not be processed. |
{
"id": "evt_…",
"type": "order.filled",
"livemode": true,
"createdAt": "2026-10-19T10:04:12.000Z",
"data": {
"order": {
"quoteId": "dep_0x5f2c…",
"status": "filled",
"provider": "relay",
"srcChainId": 8453,
"dstChainId": 42161,
"amount": "25000000",
"dstAmount": "24940000",
"depositAddressId": "da_01k7x2m4c4x8q6r0v3b5n7p9d",
"srcTxHash": "0x…",
"dstTxHash": "0x…"
}
}
}Match the event to your user with depositAddressId (and your label or metadata on the address). The order also shows up in GET /v1/dashboard/orders, and GET /v1/deposit-addresses/{id} returns the address with its 20 most recent deposits in deposits.
How fast deposits are detected. LayerHop checks an address about every 15 seconds while a deposit is in flight or it was recently active, then backs off to at most every 15 minutes when it is idle. When your user says "I sent it", call refresh to check right away:
await lh.depositAddresses.refresh(da.id); // POST /v1/deposit-addresses/{id}/refresh → 2024. Manage addresses#
| Action | API | SDK |
|---|---|---|
List (newest first, ?status=active|disabled, label, limit, cursor) |
GET /v1/deposit-addresses |
lh.depositAddresses.list({ status, label }) |
| Get one, with its 20 most recent deposits | GET /v1/deposit-addresses/{id} |
lh.depositAddresses.get(id) |
| Check for deposits now | POST /v1/deposit-addresses/{id}/refresh |
lh.depositAddresses.refresh(id) |
| Disable | POST /v1/deposit-addresses/{id}/disable |
lh.depositAddresses.disable(id) |
| Simulate a deposit (test mode) | POST /v1/deposit-addresses/{id}/simulate |
lh.depositAddresses.simulate(id, { amount }) |
Each address belongs to the account and mode (live or test) of the key that created it; other accounts' addresses return NOT_FOUND. Disabling stops LayerHop tracking the address (status: disabled); refreshing or simulating it then returns CONFLICT. Stop showing a disabled address to your user: the provider may still process funds sent to it later, but you will not get orders or webhooks for them.
From the CLI or an AI agent#
The CLI covers every endpoint with layerhop deposit-address (API key in --api-key or LAYERHOP_API_KEY). Amounts are in human units unless you pass --base-units; add --json for the raw API response.
export LAYERHOP_API_KEY=lh_test_…
layerhop deposit-address create USDC base USDC arbitrum --recipient 0xUserAddressOnArbitrum --refund-to 0xUserAddressOnBase --label user-4821
layerhop deposit-address simulate da_… 25 # test keys only
layerhop deposit-address get da_… # the address and its recent deposits
layerhop deposit-address list --status active # also: refresh <id>, disable <id>The MCP server has the same operations as tools: create_deposit_address, list_deposit_addresses, get_deposit_address, refresh_deposit_address, disable_deposit_address and simulate_deposit (test keys only). They need an API key (LAYERHOP_API_KEY locally, the x-layerhop-api-key header on the hosted server), and every response includes the rules the agent must pass on to the user: send only srcToken on srcChain, at least $1 worth, with a refundTo that is never an exchange deposit address.
Manage them in the dashboard#
The Deposit addresses page of the dashboard lists every address of the current mode (live or test), whether it was created with an API key or in the dashboard, with its route, label, deposit count and last deposit. Open one to see its QR code, its 20 most recent deposits (status, amounts and transaction links) and its details. From there you can:
- Create an address with the same fields as the API (source token, destination token,
recipient,refundTo, reusable or one-time,label). Addresses created in the dashboard belong to your account and have no API key. - Check for deposits now (the same as
refresh) and disable an address. - Simulate a deposit in test mode, with the same magic amounts as the API.
The dashboard uses the same rules as the API: live mode needs deposit addresses enabled for your account, and each address belongs to one account and mode.
Fees#
A deposit pays the same fees as any API swap: LayerHop's API channel fee (20 bps by default, see Fees & channels) plus your partner fee if you set one. Both are fixed when you create the address and shown in fees; each deposit's order records the fee actually applied (ourFeeBps, partnerFeeBps), and your partner share counts towards your earnings and payouts like any other order.
Test mode#
With an lh_test_ key, deposit addresses are served by the sandbox: provider is sandbox and address is a fake address in the source chain's format. Never send real funds to it. Record a deposit instead:
curl -X POST https://api.layerhop.com/v1/deposit-addresses/da_…/simulate \
-H "x-api-key: $LAYERHOP_TEST_KEY" -H "content-type: application/json" \
-d '{ "amount": "25000000" }'const order = await lh.depositAddresses.simulate(da.id, { amount: "25000000" });
// order.quoteId starts with dep_, order.depositAddressId === da.id, status "submitted"The deposit becomes a submitted test order that the worker settles like any test order, with webhooks to your test endpoints. The same magic amounts choose the outcome:
amount ends in |
Result |
|---|---|
4001 |
The deposit is refunded (order.refunded) |
4002 |
The deposit fails (order.failed) |
| anything else | The deposit is filled (order.filled) |
A one-time test address accepts a single simulated deposit of exactly its amount. simulate on a live address returns FORBIDDEN.
Errors#
| Code | When |
|---|---|
INVALID_PARAMS |
A missing or malformed field, an address in the wrong format for its chain, a one-time address without amount, or a simulated amount that does not match a one-time address. |
AMOUNT_TOO_SMALL |
amount is worth less than $1. |
UNSUPPORTED_CHAIN |
The chain is unknown, or deposit addresses are not available for the route. |
UNAUTHORIZED |
No API key. |
FORBIDDEN |
A live key before live mode is enabled for you, or simulate on a live address. |
NOT_FOUND |
The address does not exist or belongs to another account or mode. |
CONFLICT |
The address is disabled, or a one-time address was already used. |
See Error handling for retries and the API reference for every field.
Last updated