> ## Documentation Index
> Fetch the complete documentation index at: https://docs.services.payward.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Test deposits and withdrawals

> Run a crypto deposit and withdrawal on Ethereum Sepolia, and test Bitcoin on-chain and Lightning deposits.

> [!TIP]
>
> **Recommended approach:** fund your sandbox account with a simulated deposit. Use testnet coins only when you need to
> test the on-chain flow itself.

This page is structured in five parts:

1. [Fund your account with a simulated deposit](#fund-your-account-with-a-simulated-deposit)
2. [Deposit testnet coins](#deposit-testnet-coins), with a USDC deposit on Ethereum Sepolia
3. [Withdraw](#withdraw) the USDC back to your test wallet
4. [Sample scripts](#sample-scripts) that run the deposit and withdrawal flows end to end
5. [Bitcoin](#bitcoin) on-chain and Lightning flows

Before you start:

* [Set up local development](/tabs/developer-documentation/testing/setup.md) and check your credentials.
* Confirm with your Payward contact that crypto deposits and withdrawals are enabled for your sandbox account.
* Check [Sandbox test networks](/tabs/developer-documentation/testing/networks.md) for the supported networks and tokens.

Every request below goes to `https://api.sandbox.services.payward.com`, signed as described in
[Authentication](/tabs/developer-documentation/get-started/authentication.md). The
[Deposits and withdrawals](/tabs/developer-documentation/payments/deposits-and-withdrawals.md) guide covers each request in
full. To send these requests without writing code, use the [Postman collection](https://www.postman.com/payward-services-2406981/payward-services-s-workspace/collection/wqmz0xf/payward-services-api), which signs
each request for you. Set its `baseUrl` variable to the sandbox base URL.

## Fund your account with a simulated deposit

To get a test balance without sending testnet coins, call
[**Simulate funding deposit**](/api-reference/funds/simulate-funding-deposit.md). It credits a synthetic, successful deposit to
the account. It's available only in the sandbox.

```http theme={null}
POST /v1/accounts/{account_id}/funds/testing/deposit
Content-Type: application/json

{
  "method_id": "REPLACE_WITH_SANDBOX_DEPOSIT_METHOD_ID",
  "amount": "25",
  "reference": "my-test-1"
}
```

* Use a deposit method [`id`](/api-reference/funds/list-deposit-methods#response-data-items-id) as [`method_id`](/api-reference/funds/simulate-funding-deposit#body-method-id), from [**List deposit methods**](/api-reference/funds/list-deposit-methods.md) (`GET /v1/accounts/{account_id}/funds/deposits/methods`), such as the
  Ethereum USDC method. See [Select and activate the method](#select-and-activate-the-method).
* The response is `{"data":{"simulated":true}}`. It doesn't include a transaction ID.
* The deposit appears in [**List portfolio transactions**](/api-reference/portfolio/list-portfolio-transactions.md) (`GET /v1/accounts/{account_id}/portfolio/transactions`) as
  `in_progress`, then `successful`, usually within a minute. Its [`reference.kind`](/api-reference/portfolio/list-portfolio-transactions#response-data-items-reference-kind) is `funding`.
* After that, the balance is available to trade and transfer. Use it to test [quotes and swaps](/tabs/developer-documentation/trade/swap.md)
  or [transfers](/tabs/developer-documentation/payments/transfers.md).

## Deposit testnet coins

To run these steps as one program, use the [deposit script](#deposit-script).

### Select and activate the method

1. Call [**List deposit methods**](/api-reference/funds/list-deposit-methods.md)
   (`GET /v1/accounts/{account_id}/funds/deposits/methods?asset_symbol=USDC`).
2. Select the method whose [`network`](/api-reference/funds/list-deposit-methods#response-data-items-network) is `Ethereum Sepolia Testnet`, and keep its [`id`](/api-reference/funds/list-deposit-methods#response-data-items-id) and
   [`minimum`](/api-reference/funds/list-deposit-methods#response-data-items-minimum). Match on `network`, not on the [`method`](/api-reference/funds/list-deposit-methods#response-data-items-method) name: several networks use the name `Standard`. In the sandbox, the Ethereum method uses Sepolia, even though its [`network_info`](/api-reference/funds/list-deposit-methods#response-data-items-network-info) shows
   the mainnet explorer and contract. See [Select the sandbox method](/tabs/developer-documentation/testing/networks.md#select-the-sandbox-method).
   The maximum [`page_size`](/api-reference/funds/list-deposit-methods#parameter-page-size) is 25.
3. If the method's [`activation_status`](/api-reference/funds/list-deposit-methods#response-data-items-activation-status) is `needed`, activate it with [**Activate deposit method**](/api-reference/funds/activate-deposit-method.md) (`POST /v1/accounts/{account_id}/funds/deposits/methods/{method_id}/activation`). A `204`
   response means the request was accepted; list the methods again until its
   `activation_status` is `completed`.
4. Check the account's deposit limits with [**List deposit limits**](/api-reference/funds/list-deposit-limits.md) (`GET /v1/accounts/{account_id}/funds/deposits/limits/{asset_symbol}`).

### Get a deposit address

[**List deposit addresses**](/api-reference/funds/list-deposit-addresses.md) (`GET /v1/accounts/{account_id}/funds/deposits/addresses`) for the method, and reuse one if it exists.
Otherwise, claim one with [**Claim deposit address**](/api-reference/funds/claim-deposit-address.md)
(`POST /v1/accounts/{account_id}/funds/deposits/addresses`), and send the method's `id` as [`method_id`](/api-reference/funds/claim-deposit-address#body-method-id) in the body.

> [!WARNING]
>
> Claiming an address isn't idempotent. Each successful request can create another address, up to a limit that returns
> `409 deposit_address_limit_reached`. If a request times out, list addresses before you try again.

### Send the deposit

1. Set your wallet to Ethereum Sepolia, and add Circle's test USDC token, `0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238`.
2. Get test USDC from [Circle's faucet](https://faucet.circle.com), plus Sepolia ETH for the transaction fee. See
   [Get test funds](/tabs/developer-documentation/testing/networks.md#get-test-funds).
3. Send at least the method's [`minimum`](/api-reference/funds/list-deposit-methods#response-data-items-minimum) (2 USDC for Ethereum USDC in the sandbox), and stay within the deposit limits.
4. Keep the transaction hash. You use it to find your deposit in the next step.

### Confirm the credit

Poll [**List portfolio transactions**](/api-reference/portfolio/list-portfolio-transactions.md)
(`GET /v1/accounts/{account_id}/portfolio/transactions?types=deposit`) until the deposit's
[`status`](/api-reference/portfolio/list-portfolio-transactions#response-data-items-status) reaches `successful` or `failed`.

* Match your deposit by `details.crypto_transaction_id`, the on-chain transaction hash in the transaction's `details`
  object. Results are newest
  first, so don't assume the first deposit is yours.
* Don't filter by status while you wait. That hides deposits that are still `in_progress`.
* Follow [`next_page_token`](/api-reference/portfolio/list-portfolio-transactions#response-next-page-token) until it's absent, even when a page is empty.
* In the sandbox, a USDC deposit on Ethereum Sepolia usually reaches `successful` within a few minutes of being mined.
* A confirmed transaction in the block explorer doesn't mean your account has been credited. Wait for the portfolio
  transaction.

## Withdraw

Withdraw a small amount back to your test wallet. Follow these steps in order. To run them as one program, use the
[withdrawal script](#withdrawal-script).

1. **List withdrawal methods.** Call [**List withdrawal methods**](/api-reference/funds/list-withdrawal-methods.md) (`GET /v1/accounts/{account_id}/funds/withdrawals/methods`) for USDC.
   Withdrawal method IDs are separate from deposit method IDs. Pick the one whose
   [`network`](/api-reference/funds/list-withdrawal-methods#response-data-items-network) is `Ethereum Sepolia Testnet`.
2. **Check limits.** Call [**List withdrawal limits**](/api-reference/funds/list-withdrawal-limits.md) (`GET /v1/accounts/{account_id}/funds/withdrawals/limits/{asset_symbol}`).
3. **Register the destination.** Reuse or create a funding address for your test wallet with
   [**Create funding address**](/api-reference/funds/create-funding-address.md) (`POST /v1/accounts/{account_id}/funds/addresses`).
   Scope the address to one withdrawal method with
   [`scope.method_id`](/api-reference/funds/create-funding-address#body-scope-method-id), or to every method on its
   network with [`scope.network_id`](/api-reference/funds/create-funding-address#body-scope-network-id), using the
   method's [`network_id`](/api-reference/funds/list-withdrawal-methods#response-data-items-network-id). Either way,
   register your wallet again for each network you withdraw on.
4. **Quote the fee.** Call [**Calculate funding fees**](/api-reference/funds/calculate-funding-fees.md) (`POST /v1/accounts/{account_id}/funds/fees`) to get the fee and a
   short-lived [`fee_token`](/api-reference/funds/calculate-funding-fees#response-data-fee-token). Do this right before you create the withdrawal, so the token doesn't expire.
5. **Create the withdrawal.** Call [**Create withdrawal**](/api-reference/funds/create-withdrawal.md) (`POST /v1/accounts/{account_id}/funds/withdrawals`) with an
   [`Idempotency-Key`](/api-reference/funds/create-withdrawal#parameter-idempotency-key) header and the
   [`fee_token`](/api-reference/funds/create-withdrawal#body-fee-token).
6. **Verify the address, if asked.** A `409 withdrawal_verification_required` response means you need to verify that
   the user owns the destination. Complete a `self_attestation` verification for the user, then submit a new
   withdrawal. When you withdraw from your own `main` account, the user ID is your Console Client ID. See [Address-ownership verification](/tabs/developer-documentation/payments/deposits-and-withdrawals.md#address-ownership-verification).
7. **Confirm completion.** Poll portfolio transactions with `types=withdrawal`, without a status filter. Match the
   transaction whose
   [`reference.kind`](/api-reference/portfolio/list-portfolio-transactions#response-data-items-reference-kind) is
   `funding` and whose
   [`reference.id`](/api-reference/portfolio/list-portfolio-transactions#response-data-items-reference-id) is the
   [`withdrawal_id`](/api-reference/funds/create-withdrawal#response-data-withdrawal-id) from the response, and wait until the
   withdrawal reaches `successful` or `failed`. Its `details.crypto_transaction_id` holds the on-chain transaction hash.
   Then check that the funds arrived in your wallet. In the sandbox, a small USDC withdrawal on Ethereum Sepolia
   usually completes within a few minutes.

If a withdrawal request has an unknown outcome, reconcile it through the portfolio before you retry. Use the same
idempotency key and the same request body.

A withdrawal can fail even when your account shows enough balance, because the sandbox pays withdrawals from a shared
testnet wallet. See [Keep sandbox withdrawals small](/tabs/developer-documentation/testing/networks.md#keep-sandbox-withdrawals-small).

## Sample scripts

These scripts run the [deposit](#deposit-testnet-coins) and [withdrawal](#withdraw) flows above as one program each. They
read the variables from [Set up local development](/tabs/developer-documentation/testing/setup.md#configure-your-client),
plus `PWS_ACCOUNT_ID`. Each was run end to end against the sandbox.

### Helpers

Save the helpers next to the scripts. They sign each request as described in
[Authentication](/tabs/developer-documentation/get-started/authentication.md).

```python pws_sandbox.py expandable theme={null}
import base64
import hashlib
import hmac
import json
import os
import time
from urllib.parse import quote

import requests

BASE_URL = os.environ["PWS_BASE_URL"]
API_KEY = os.environ["PWS_API_KEY"]
API_SECRET = os.environ["PWS_API_SECRET"]
ACCOUNT = f"/v1/accounts/{os.environ['PWS_ACCOUNT_ID']}"
# The Ethereum funding methods' network name: "Ethereum Sepolia Testnet" in the sandbox, "Ethereum" in production.
ETHEREUM = {"Ethereum Sepolia Testnet", "Ethereum"}

last_nonce = 0


def next_nonce():
    global last_nonce
    last_nonce = max(time.time_ns(), last_nonce + 1)
    return str(last_nonce)


def call(method, path, body=None, headers=None):
    nonce = next_nonce()
    data = "" if body is None else json.dumps(body, separators=(",", ":"))
    digest = hashlib.sha256((nonce + data).encode()).digest()
    mac = hmac.new(base64.b64decode(API_SECRET), path.encode() + digest, hashlib.sha512)
    signed = {"API-Key": API_KEY, "API-Nonce": nonce, "API-Sign": base64.b64encode(mac.digest()).decode()}
    if body is not None:
        signed["Content-Type"] = "application/json"
    response = requests.request(
        method, BASE_URL + path, data=data or None, headers={**signed, **(headers or {})}, timeout=15
    )
    if not response.ok:
        raise SystemExit(f"{method} {path}: HTTP {response.status_code} {response.text}")
    return response.json() if response.content else {}


def find_transaction(kind, matches):
    """Search every page of portfolio transactions of one type for the first match."""
    path = f"{ACCOUNT}/portfolio/transactions?types={kind}"
    while path:
        page = call("GET", path)
        found = next((t for t in page["data"] if matches(t)), None)
        if found:
            return found
        token = page.get("next_page_token")
        path = f"{ACCOUNT}/portfolio/transactions?types={kind}&page_token={quote(token)}" if token else None
    return None
```

```javascript pws-sandbox.mjs expandable theme={null}
import crypto from 'node:crypto';

const BASE_URL = process.env.PWS_BASE_URL;
const API_KEY = process.env.PWS_API_KEY;
const API_SECRET = process.env.PWS_API_SECRET;
export const ACCOUNT = `/v1/accounts/${process.env.PWS_ACCOUNT_ID}`;
// The Ethereum funding methods' network name: "Ethereum Sepolia Testnet" in the sandbox, "Ethereum" in production.
export const ETHEREUM = new Set(['Ethereum Sepolia Testnet', 'Ethereum']);

let lastNonce = 0n;
function nextNonce() {
  const now = BigInt(Date.now()) * 1_000_000n;
  lastNonce = now > lastNonce ? now : lastNonce + 1n;
  return lastNonce.toString();
}

export async function call(method, path, body = undefined, headers = {}) {
  const nonce = nextNonce();
  const data = body === undefined ? '' : JSON.stringify(body);
  const digest = crypto
    .createHash('sha256')
    .update(nonce + data)
    .digest();
  const sign = crypto
    .createHmac('sha512', Buffer.from(API_SECRET, 'base64'))
    .update(Buffer.concat([Buffer.from(path), digest]))
    .digest('base64');
  const response = await fetch(BASE_URL + path, {
    method,
    body: data || undefined,
    signal: AbortSignal.timeout(15_000),
    headers: {
      'API-Key': API_KEY,
      'API-Nonce': nonce,
      'API-Sign': sign,
      ...(data ? { 'Content-Type': 'application/json' } : {}),
      ...headers,
    },
  });
  const text = await response.text();
  if (!response.ok) throw new Error(`${method} ${path}: HTTP ${response.status} ${text}`);
  return text ? JSON.parse(text) : {};
}

// Search every page of portfolio transactions of one type for the first match.
export async function findTransaction(kind, matches) {
  let path = `${ACCOUNT}/portfolio/transactions?types=${kind}`;
  while (path) {
    const page = await call('GET', path);
    const found = page.data.find(matches);
    if (found) return found;
    const token = page.next_page_token;
    path = token ? `${ACCOUNT}/portfolio/transactions?types=${kind}&page_token=${encodeURIComponent(token)}` : null;
  }
  return null;
}

export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
```

### Deposit script

Run it once to get the deposit address. Send at least the method's minimum, then run it again with `TX_HASH` set to the
transaction hash.

```python deposit.py expandable theme={null}
import os
import sys
import time

from pws_sandbox import ACCOUNT, ETHEREUM, call, find_transaction

tx_hash = os.environ.get("TX_HASH", "").lower()

# 1. Find the Ethereum USDC deposit method. Its network is "Ethereum Sepolia Testnet" in the sandbox.
def ethereum_method():
    methods = call("GET", f"{ACCOUNT}/funds/deposits/methods?asset_symbol=USDC")["data"]
    return next(m for m in methods if m.get("network") in ETHEREUM)

method = ethereum_method()
if method.get("activation_status") == "needed":
    call("POST", f"{ACCOUNT}/funds/deposits/methods/{method['id']}/activation")
    while method.get("activation_status") != "completed":
        time.sleep(5)
        method = ethereum_method()
print(f"Method {method['id']}, minimum {method['minimum']['amount']} USDC")

# 2. Reuse a deposit address, or claim one. Claiming isn't idempotent, so don't retry it blindly.
addresses = call("GET", f"{ACCOUNT}/funds/deposits/addresses?method_id={method['id']}")["data"]
if addresses:
    address = addresses[0]["address_details"]["crypto"]["address"]
else:
    address = call("POST", f"{ACCOUNT}/funds/deposits/addresses", {"method_id": method["id"]})["data"]["address"]
print(f"Send Sepolia USDC to {address}")
if not tx_hash:
    sys.exit("Then run this script again with TX_HASH set to the transaction hash.")

# 3. Poll until the deposit with that transaction hash is successful or failed.
deadline = time.time() + 15 * 60
while time.time() < deadline:
    deposit = find_transaction(
        "deposit", lambda t: (t.get("details") or {}).get("crypto_transaction_id", "").lower() == tx_hash
    )
    status = deposit["status"] if deposit else "not seen yet"
    print(f"Deposit: {status}")
    if status in ("successful", "failed"):
        break
    time.sleep(20)
```

```javascript deposit.mjs expandable theme={null}
import { ACCOUNT, ETHEREUM, call, findTransaction, sleep } from './pws-sandbox.mjs';

const txHash = (process.env.TX_HASH ?? '').toLowerCase();

// 1. Find the Ethereum USDC deposit method. Its network is "Ethereum Sepolia Testnet" in the sandbox.
async function ethereumMethod() {
  const { data } = await call('GET', `${ACCOUNT}/funds/deposits/methods?asset_symbol=USDC`);
  return data.find(m => ETHEREUM.has(m.network));
}

let method = await ethereumMethod();
if (method.activation_status === 'needed') {
  await call('POST', `${ACCOUNT}/funds/deposits/methods/${method.id}/activation`);
  while (method.activation_status !== 'completed') {
    await sleep(5_000);
    method = await ethereumMethod();
  }
}
console.log(`Method ${method.id}, minimum ${method.minimum.amount} USDC`);

// 2. Reuse a deposit address, or claim one. Claiming isn't idempotent, so don't retry it blindly.
const { data: addresses } = await call('GET', `${ACCOUNT}/funds/deposits/addresses?method_id=${method.id}`);
const address = addresses.length
  ? addresses[0].address_details.crypto.address
  : (await call('POST', `${ACCOUNT}/funds/deposits/addresses`, { method_id: method.id })).data.address;
console.log(`Send Sepolia USDC to ${address}`);
if (!txHash) {
  console.log('Then run this script again with TX_HASH set to the transaction hash.');
  process.exit(1);
}

// 3. Poll until the deposit with that transaction hash is successful or failed.
const deadline = Date.now() + 15 * 60_000;
while (Date.now() < deadline) {
  const deposit = await findTransaction(
    'deposit',
    t => (t.details?.crypto_transaction_id ?? '').toLowerCase() === txHash,
  );
  const status = deposit?.status ?? 'not seen yet';
  console.log(`Deposit: ${status}`);
  if (status === 'successful' || status === 'failed') break;
  await sleep(20_000);
}
```

```bash Python theme={null}
TX_HASH=0xYOUR_TRANSACTION_HASH python3 deposit.py
```

```bash JavaScript theme={null}
TX_HASH=0xYOUR_TRANSACTION_HASH node deposit.mjs
```

### Withdrawal script

Set `WALLET_ADDRESS` to your test wallet, and optionally `AMOUNT` (the default is `1` USDC).

```python withdraw.py expandable theme={null}
import os
import time
import uuid

from pws_sandbox import ACCOUNT, ETHEREUM, call, find_transaction

wallet = os.environ["WALLET_ADDRESS"]
amount = os.environ.get("AMOUNT", "1")

# 1. Find the Ethereum USDC withdrawal method. Its network is "Ethereum Sepolia Testnet" in the sandbox.
methods = call("GET", f"{ACCOUNT}/funds/withdrawals/methods?asset_symbol=USDC")["data"]
method = next(m for m in methods if m.get("network") in ETHEREUM)

# 2. Reuse the funding address for your wallet, or register it.
addresses = call("GET", f"{ACCOUNT}/funds/addresses?method_id={method['id']}")["data"]
match = next(
    (a for a in addresses if a["address_details"].get("crypto", {}).get("address", "").lower() == wallet.lower()), None
)
if match:
    address_id = match["address_id"]
else:
    address_id = call(
        "POST",
        f"{ACCOUNT}/funds/addresses",
        {"scope": {"method_id": method["id"]}, "name": f"test-wallet-{wallet[-6:]}",
         "address_details": {"crypto": {"address": wallet}}},
    )["data"]["address_id"]

# 3. Quote the fee right before you withdraw. The fee token is short-lived.
fee = call("POST", f"{ACCOUNT}/funds/fees", {"method_id": method["id"], "amount": amount})["data"]
print(f"Fee {fee['fee']['amount']} USDC, you receive {fee['net_amount']['amount']} USDC")

# 4. Create the withdrawal. To retry after an unknown outcome, reuse the same key and body.
# A 409 withdrawal_verification_required means you must verify the address first; see the guide.
idempotency_key = str(uuid.uuid4())
withdrawal = call(
    "POST",
    f"{ACCOUNT}/funds/withdrawals",
    {"scope": {"method_id": method["id"]}, "destination": {"type": "funding_address", "address_id": address_id},
     "symbol": "USDC", "amount": amount, "fee_token": fee["fee_token"]},
    headers={"Idempotency-Key": idempotency_key},
)["data"]
print(f"Withdrawal {withdrawal['withdrawal_id']}")

# 5. Poll until it's successful or failed.
deadline = time.time() + 15 * 60
while time.time() < deadline:
    found = find_transaction("withdrawal", lambda t: t["reference"]["id"] == withdrawal["withdrawal_id"])
    status = found["status"] if found else "not seen yet"
    print(f"Withdrawal: {status}")
    if status in ("successful", "failed"):
        if found:
            print(f"Transaction hash: {(found.get('details') or {}).get('crypto_transaction_id')}")
        break
    time.sleep(20)
```

```javascript withdraw.mjs expandable theme={null}
import crypto from 'node:crypto';
import { ACCOUNT, ETHEREUM, call, findTransaction, sleep } from './pws-sandbox.mjs';

const wallet = process.env.WALLET_ADDRESS;
const amount = process.env.AMOUNT ?? '1';

// 1. Find the Ethereum USDC withdrawal method. Its network is "Ethereum Sepolia Testnet" in the sandbox.
const { data: methods } = await call('GET', `${ACCOUNT}/funds/withdrawals/methods?asset_symbol=USDC`);
const method = methods.find(m => ETHEREUM.has(m.network));

// 2. Reuse the funding address for your wallet, or register it.
const { data: addresses } = await call('GET', `${ACCOUNT}/funds/addresses?method_id=${method.id}`);
const match = addresses.find(a => a.address_details.crypto?.address?.toLowerCase() === wallet.toLowerCase());
const addressId = match
  ? match.address_id
  : (
      await call('POST', `${ACCOUNT}/funds/addresses`, {
        scope: { method_id: method.id },
        name: `test-wallet-${wallet.slice(-6)}`,
        address_details: { crypto: { address: wallet } },
      })
    ).data.address_id;

// 3. Quote the fee right before you withdraw. The fee token is short-lived.
const { data: fee } = await call('POST', `${ACCOUNT}/funds/fees`, { method_id: method.id, amount });
console.log(`Fee ${fee.fee.amount} USDC, you receive ${fee.net_amount.amount} USDC`);

// 4. Create the withdrawal. To retry after an unknown outcome, reuse the same key and body.
// A 409 withdrawal_verification_required means you must verify the address first; see the guide.
const idempotencyKey = crypto.randomUUID();
const { data: withdrawal } = await call(
  'POST',
  `${ACCOUNT}/funds/withdrawals`,
  {
    scope: { method_id: method.id },
    destination: { type: 'funding_address', address_id: addressId },
    symbol: 'USDC',
    amount,
    fee_token: fee.fee_token,
  },
  { 'Idempotency-Key': idempotencyKey },
);
console.log(`Withdrawal ${withdrawal.withdrawal_id}`);

// 5. Poll until it's successful or failed.
const deadline = Date.now() + 15 * 60_000;
while (Date.now() < deadline) {
  const found = await findTransaction('withdrawal', t => t.reference.id === withdrawal.withdrawal_id);
  const status = found?.status ?? 'not seen yet';
  console.log(`Withdrawal: ${status}`);
  if (status === 'successful' || status === 'failed') {
    if (found) console.log(`Transaction hash: ${found.details?.crypto_transaction_id}`);
    break;
  }
  await sleep(20_000);
}
```

```bash Python theme={null}
WALLET_ADDRESS=0xYOUR_TEST_WALLET python3 withdraw.py
```

```bash JavaScript theme={null}
WALLET_ADDRESS=0xYOUR_TEST_WALLET node withdraw.mjs
```

## Bitcoin

Bitcoin uses two different testnets in the sandbox. Use a separate wallet for each.

### On-chain Bitcoin on Signet

Follow the same deposit and withdrawal steps as above, with `asset_symbol=BTC`, and pick the methods whose
[`network`](/api-reference/funds/list-deposit-methods#response-data-items-network) is `Bitcoin`. Set your wallet to the default Signet: coins sent from testnet3, testnet4, or a custom signet are never
credited, even though the addresses look the same.

Bitcoin differs from the Ethereum Sepolia flow in a few ways:

* A deposit appears as `in_progress` within seconds of being broadcast, and becomes `successful` after 3 confirmations.
  Signet blocks are irregular, so allow up to an hour. The [sample scripts](#sample-scripts) stop polling after 15
  minutes, so raise their timeout for Bitcoin.
* Bitcoin transaction hashes have no `0x` prefix. Compare `details.crypto_transaction_id` with the hash exactly as your
  wallet reports it. The deposit's `details.vout` is the output index within that transaction.
* Withdrawals have a network fee, 0.000015 BTC in the sandbox. The withdrawal method's
  [`fee`](/api-reference/funds/list-withdrawal-methods#response-data-items-fee) shows it, and
  [**Calculate funding fees**](/api-reference/funds/calculate-funding-fees.md) returns the exact amount. In the sandbox, a
  0.0005 BTC withdrawal reached `successful` in about 20 minutes.

### Lightning on testnet3

For Lightning deposits, use [**Create Lightning invoice**](/api-reference/funds/create-lightning-invoice.md) (`POST /v1/accounts/{account_id}/funds/deposits/lightning-invoices`) instead of claiming
an address. Send [`asset_symbol`](/api-reference/funds/create-lightning-invoice#body-asset-symbol) `BTC` and the exact
[`amount`](/api-reference/funds/create-lightning-invoice#body-amount) the invoice should request, for example `0.0005`. Each invoice is single-use, for an exact amount, and must be paid in full before it expires. Creating an
invoice isn't idempotent, so don't retry a timed-out request automatically. Create a new invoice instead, and let the
unpaid one expire.

Pay the invoice from a Lightning wallet on testnet3. Test Bitcoin withdrawals on Signet.

## If a test fails

Send your Payward contact the account ID, asset, network, method IDs, request IDs or references, the transaction hash, and
the last status the API returned. Don't include API keys, secrets, or signed request headers.

## Next steps

* Before you go live, follow the [Production](/tabs/developer-documentation/testing/overview.md#production) checklist.
* To react to deposits and withdrawals as they happen instead of polling, subscribe to the
  [deposit webhook events](/tabs/developer-documentation/payments/deposits-and-withdrawals.md#deposit-webhook-events) and
  [withdrawal webhook events](/tabs/developer-documentation/payments/deposits-and-withdrawals.md#withdrawal-webhook-events).
* For every request field, error, and edge case, see the
  [Deposits and withdrawals](/tabs/developer-documentation/payments/deposits-and-withdrawals.md) guide.
