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

# Track a swap to finality

> Confirm single-chain swaps from the transaction receipt, read from your RPC or from Olympex, and poll cross-chain transfers with POST /tx-status until they succeed or fail.

Olympex returns calldata and your wallet broadcasts it, so tracking happens in two places. A single-chain swap is final when its transaction is: read the receipt from your RPC, or poll `GET /transactions/{hash}`. A cross-chain transfer finishes on another chain: poll `POST /tx-status` until the provider reports success or failure.

<Info>
  You need the hash of the transaction you broadcast. For cross-chain transfers you also need the source chain ID and the `dexHash` from the cross-chain `POST /swap` response. See [Execute a swap](/guides/execute-a-swap) and [Cross-chain swap end-to-end](/guides/cross-chain-swap-end-to-end).
</Info>

## Prerequisites

* An RPC URL for each chain you track, as `POLYGON_RPC_URL` in the examples, and viem 2 or ethers v6. To read single-chain receipts through Olympex instead, `GET /transactions/{hash}` needs only your API credentials.
* For cross-chain transfers: API credentials in your server's environment and the signing helper from [Sign requests](/authentication/sign-requests). TypeScript examples run on Node.js 22.18 or later, in an ES module project (`npm pkg set type=module`); Python examples need 3.8 or later with `requests`.
* A place to store tracking records (a database table or a queue), so tracking survives restarts.

## Steps

<Steps>
  <Step title="Store what you need before you broadcast">
    Write a tracking record as soon as you have the calldata, and add the hash when the wallet returns it. If your service restarts mid-transfer, it resumes from these records.

    | Field | Single-chain | Cross-chain | Source |
    | - | - | - | - |
    | Transaction hash | Yes | Yes | The wallet, after it sends. |
    | Chain ID | Yes | Yes (the source chain) | Your request. |
    | `account` | Yes | Yes | Your request. |
    | `minOutAmount` or `minimumReceived` | Yes | Yes | The `POST /swap` or `POST /quotes` response. |
    | `dexHash` | No | Yes | The cross-chain `POST /swap` response. |
  </Step>

  <Step title="Single-chain: wait for the receipt">
    The transaction receipt is the result: `success` means the swap executed and paid at least `minOutAmount`; `reverted` means no tokens moved and the wallet paid only gas. Read it from your RPC, as below, or poll [`GET /transactions/{hash}`](/api-reference/transactions/get-transaction), which reports `pending`, `success`, `reverted` or `not_found` with the block number and confirmations. That endpoint doesn't detect a replaced transaction: once the replacement is mined, it reports the original hash as `not_found`.

    Two more outcomes need handling. The receipt can take longer than your timeout, and the user can speed up, replace or cancel the transaction from their wallet with the same nonce. A sped-up transaction ("repriced") is the same swap; a replaced or cancelled one means the swap never ran, even though the replacement's receipt can say `success`.

    <CodeGroup>
      ```ts viem theme={null}
      import { WaitForTransactionReceiptTimeoutError, createPublicClient, http, type Hash } from "viem";
      import { polygon } from "viem/chains";

      const publicClient = createPublicClient({ chain: polygon, transport: http(process.env.POLYGON_RPC_URL) });

      type SwapOutcome = { state: "success" | "reverted" | "replaced" | "pending"; hash: Hash };

      async function waitForSwap(hash: Hash): Promise<SwapOutcome> {
        let replaced = false;
        try {
          const receipt = await publicClient.waitForTransactionReceipt({
            hash,
            confirmations: 1, // raise to match your own finality policy
            timeout: 5 * 60_000,
            onReplaced: (replacement) => {
              replaced = replacement.reason !== "repriced"; // "repriced" is the same swap with a higher fee
            },
          });
          if (replaced) return { state: "replaced", hash: receipt.transactionHash }; // the swap itself never ran
          return { state: receipt.status === "success" ? "success" : "reverted", hash: receipt.transactionHash };
        } catch (error) {
          if (error instanceof WaitForTransactionReceiptTimeoutError) return { state: "pending", hash }; // check again later
          throw error;
        }
      }
      ```

      ```ts ethers v6 theme={null}
      import { JsonRpcProvider, isError } from "ethers";

      const provider = new JsonRpcProvider(process.env.POLYGON_RPC_URL);

      type SwapOutcome = { state: "success" | "reverted" | "replaced" | "pending"; hash: string };

      async function waitForSwap(hash: string): Promise<SwapOutcome> {
        const tx = await provider.getTransaction(hash);
        if (!tx) return { state: "pending", hash }; // this node hasn't seen it yet
        try {
          const receipt = await tx.wait(1, 5 * 60_000); // throws CALL_EXCEPTION on revert, TIMEOUT after 5 minutes
          return { state: "success", hash: receipt?.hash ?? hash };
        } catch (error) {
          if (isError(error, "TRANSACTION_REPLACED")) {
            // "repriced" is the same swap with a higher fee; "replaced" and "cancelled" mean the swap never ran.
            if (error.reason === "repriced") return { state: error.receipt.status === 1 ? "success" : "reverted", hash: error.receipt.hash };
            return { state: "replaced", hash: error.receipt.hash };
          }
          if (isError(error, "CALL_EXCEPTION")) return { state: "reverted", hash };
          if (isError(error, "TIMEOUT")) return { state: "pending", hash };
          throw error;
        }
      }
      ```
    </CodeGroup>

    Choose the number of confirmations from your own risk policy for each chain. A `pending` result isn't a failure: keep the record open and check the hash again later.
  </Step>

  <Step title="Cross-chain: confirm the source transaction first">
    Wait for the source transaction's receipt with the same code. If it reverted, nothing was bridged: mark the transfer failed and stop. If it succeeded, the source leg is done and the transfer is in the provider's hands; start polling.
  </Step>

  <Step title="Cross-chain: poll POST /tx-status with backoff">
    Send the source transaction hash as `hash`, the source chain ID as an integer in `chainId`, and the lowercase `dexHash`. Poll every 15 to 30 seconds and stop at a cap you choose.

    A `500 TX_STATUS_ERROR` means the status is unknown, not that the transfer failed. Olympex returns it right after the broadcast, before the provider has a status for it, and also for some transfers the provider marks failed. The response doesn't tell the two apart, so keep polling, rely on your cap, and save the `requestId` from the last error for support:

    ```json 500 TX_STATUS_ERROR theme={null}
    {
      "success": false,
      "error": {
        "code": "TX_STATUS_ERROR",
        "message": "Unexpected transaction status handler error",
        "details": [
          {
            "message": "Could not retrieve transaction status"
          }
        ]
      },
      "meta": {
        "requestId": "ENUmOj8JoAMEMsw=",
        "version": "v1",
        "accountType": "integrator",
        "apiKeyId": "00000000-0000-4000-8000-000000000000"
      }
    }
    ```

    The same error comes back on every poll, and never clears, when `hash`, `chainId` or `dexHash` doesn't identify a transfer. If it persists, check all three against the broadcast transaction and the `POST /swap` response.

    The poller signs every call again, backs off from 15 to 30 seconds with jitter, treats `TX_STATUS_ERROR`, other 5xx responses and network failures as "no status yet", signs a gateway `401` or `403` again once, throws on any other 4xx, and stops on a final status or at your cap:

    <CodeGroup>
      ```ts wait-for-transfer.ts theme={null}
      // Poll POST /tx-status until a cross-chain transfer reaches a final state.
      import { OlympexApiError, olympexRequest } from "./sign-request.ts"; // /authentication/sign-requests

      export type TransferStatus = {
        status: string; // the provider's own value
        detailStatus: string;
        fromChainId: number;
        toChainId: number;
        fromTxHash?: string;
        toTxHash?: string | null;
        fromAmount?: string; // as the provider reports it: check the unit
        toAmount?: string; // as the provider reports it: check the unit
        bridgeHash?: string | null;
      };

      export type TransferOutcome =
        | { state: "success"; transfer: TransferStatus }
        | { state: "failure"; transfer: TransferStatus }
        | { state: "unknown"; transfer?: TransferStatus; requestId?: string }; // cap reached

      const SUCCESS = new Set(["SUCCESS", "DONE", "success", "Success"]);
      const FAILURE = new Set(["FAILURE", "FAILED", "failed", "REFUND", "Reverted", "FROM_FAILURE", "INVALID"]);

      const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

      export async function waitForTransfer(
        transfer: { hash: string; chainId: number; dexHash: string },
        options: { maxWaitMs?: number; onUpdate?: (status: TransferStatus) => void } = {},
      ): Promise<TransferOutcome> {
        const { maxWaitMs = 60 * 60_000, onUpdate } = options; // your cap, not an Olympex limit
        const body = { hash: transfer.hash, chainId: transfer.chainId, dexHash: transfer.dexHash.toLowerCase() };
        const deadline = Date.now() + maxWaitMs;
        let delayMs = 15_000;
        let last: TransferStatus | undefined;
        let requestId: string | undefined;
        let signedAgain = false;

        while (Date.now() < deadline) {
          await sleep(delayMs + Math.random() * 3_000); // 15 s growing to 30 s, with jitter
          delayMs = Math.min(delayMs * 1.5, 30_000);
          try {
            last = await olympexRequest<TransferStatus>("POST", "/tx-status", body); // signed again on every call
          } catch (error) {
            if (error instanceof OlympexApiError && error.status < 500) {
              // A gateway 401 or 403 gets one new signature. Any other 4xx won't fix itself: fix the request.
              const gatewayAuth = error.code === "HTTP_401" || error.code === "HTTP_403";
              if (!gatewayAuth || signedAgain) throw error;
              signedAgain = true;
              continue;
            }
            if (error instanceof OlympexApiError) requestId = error.requestId ?? requestId; // 500 TX_STATUS_ERROR: unknown yet
            else if (!(error instanceof TypeError) && !(error instanceof Error && error.name === "TimeoutError")) throw error;
            continue; // no status yet, or a transient failure: poll again
          }
          onUpdate?.(last);
          if (SUCCESS.has(last.status)) return { state: "success", transfer: last };
          if (FAILURE.has(last.status)) return { state: "failure", transfer: last };
        }
        return { state: "unknown", transfer: last, requestId };
      }
      ```

      ```python wait_for_transfer.py theme={null}
      """Poll POST /tx-status until a cross-chain transfer reaches a final state."""
      import random
      import time

      import requests

      from sign_request import OlympexApiError, olympex_request  # /authentication/sign-requests

      SUCCESS = {"SUCCESS", "DONE", "success", "Success"}
      FAILURE = {"FAILURE", "FAILED", "failed", "REFUND", "Reverted", "FROM_FAILURE", "INVALID"}


      def wait_for_transfer(tx_hash, chain_id, dex_hash, max_wait_s=3600, on_update=None):
          """Return {"state": "success" | "failure" | "unknown", "transfer": ..., "request_id": ...}."""
          body = {"hash": tx_hash, "chainId": int(chain_id), "dexHash": dex_hash.lower()}
          deadline = time.monotonic() + max_wait_s  # your cap, not an Olympex limit
          delay, last, request_id, signed_again = 15.0, None, None, False
          while time.monotonic() < deadline:
              time.sleep(delay + random.uniform(0, 3))  # 15 s growing to 30 s, with jitter
              delay = min(delay * 1.5, 30.0)
              try:
                  last = olympex_request("POST", "/tx-status", body)  # signed again on every call
              except OlympexApiError as error:
                  if error.status < 500:
                      # A gateway 401 or 403 gets one new signature. Any other 4xx won't fix itself: fix the request.
                      if error.code not in ("HTTP_401", "HTTP_403") or signed_again:
                          raise
                      signed_again = True
                      continue
                  request_id = error.request_id or request_id  # 500 TX_STATUS_ERROR: unknown yet
                  continue
              except (requests.ConnectionError, requests.Timeout):
                  continue  # transient network failure: poll again
              if on_update:
                  on_update(last)
              if last["status"] in SUCCESS:
                  return {"state": "success", "transfer": last}
              if last["status"] in FAILURE:
                  return {"state": "failure", "transfer": last}
          return {"state": "unknown", "transfer": last, "request_id": request_id}
      ```
    </CodeGroup>

    Call it with the record you stored:

    <CodeGroup>
      ```ts TypeScript theme={null}
      import { waitForTransfer } from "./wait-for-transfer.ts";

      const outcome = await waitForTransfer(
        {
          hash: "0x5f2b0a3c3e8f9d1b7a6c4e2d0f8b6a4c2e0d8f6b4a2c0e8d6f4b2a0c8e6d4f2b", // source-chain transaction
          chainId: 137,
          dexHash: "0x6c2142a4113d1a2ef94717109dd5ac71f69439465f95a170fb9cf0f56d76ad7c", // from POST /swap
        },
        { onUpdate: (status) => console.log(status.status, status.detailStatus) },
      );
      console.log(outcome.state);
      ```

      ```python Python theme={null}
      from wait_for_transfer import wait_for_transfer

      outcome = wait_for_transfer(
          "0x5f2b0a3c3e8f9d1b7a6c4e2d0f8b6a4c2e0d8f6b4a2c0e8d6f4b2a0c8e6d4f2b",  # source-chain transaction
          137,
          "0x6c2142a4113d1a2ef94717109dd5ac71f69439465f95a170fb9cf0f56d76ad7c",  # from POST /swap
          on_update=lambda status: print(status["status"], status["detailStatus"]),
      )
      print(outcome["state"])
      ```
    </CodeGroup>

    An illustrative response for a completed transfer:

    ```json Response (illustrative) theme={null}
    {
      "success": true,
      "data": {
        "fromChainId": 137,
        "toChainId": 1,
        "fromTxHash": "0x5f2b0a3c3e8f9d1b7a6c4e2d0f8b6a4c2e0d8f6b4a2c0e8d6f4b2a0c8e6d4f2b",
        "toTxHash": "0x8e1d4c7b2a9f6e3d0c5b8a7f4e1d2c9b6a3f0e7d4c1b8a5f2e9d6c3b0a7f4e1d",
        "fromAmount": "10000000",
        "toAmount": "9985000",
        "fromTokenAddress": "0xc2132d05d31c914a87c6611c10748aeb04b58e8f",
        "toTokenAddress": "0xdac17f958d2ee523a2206206994597c13d831ec7",
        "bridgeHash": "0x8e1d4c7b2a9f6e3d0c5b8a7f4e1d2c9b6a3f0e7d4c1b8a5f2e9d6c3b0a7f4e1d",
        "errorMsg": null,
        "status": "SUCCESS",
        "detailStatus": "SUCCESS"
      },
      "meta": {
        "requestId": "ENWRgDn5IAMEMEw=",
        "version": "v1",
        "accountType": "integrator",
        "apiKeyId": "00000000-0000-4000-8000-000000000000"
      }
    }
    ```

    `status`, `detailStatus`, `fromChainId` and `toChainId` are always present. `fromTxHash`, `toTxHash`, `fromAmount`, `toAmount`, the token addresses and `bridgeHash` appear when the provider reports them. `fromAmount` and `toAmount` pass through from the provider without conversion, so check their unit before you do arithmetic with them. `errorMsg` is always `null` in a `200` response: when the provider reports an error, Olympex returns `500 TX_STATUS_ERROR` instead, without the provider's message. To show the user what happened, use `detailStatus`.
  </Step>

  <Step title="Map provider statuses to your states">
    `status` and `detailStatus` are the provider's own values, so their casing varies. Compare `status` against these sets exactly:

    | Your state | `status` values | What to show |
    | - | - | - |
    | Completed | `SUCCESS`, `DONE`, `success`, `Success` | The destination transaction (`toTxHash`) and the amount received (`toAmount`, in the unit the provider reports: check it before you format it). |
    | Failed | `FAILURE`, `FAILED`, `failed`, `REFUND`, `Reverted`, `FROM_FAILURE`, `INVALID` | The source transaction and `detailStatus`. For `REFUND`, tell the user the provider reports a refund instead of a delivery. |
    | In progress | Any other value | Progress, with the source transaction linked. |
    | Unknown | `500 TX_STATUS_ERROR` | Keep showing progress. It isn't a failure. |

    Use `detailStatus` for display only, and never branch on it.
  </Step>

  <Step title="Fall back when you reach your cap">
    If the transfer has no final status when your cap expires, don't mark it failed. Show it as delayed, link the source transaction on the source chain's block explorer, and keep a slower background check running. If it stays unresolved, contact [partners@olympex.io](mailto:partners@olympex.io) with the transaction hash, source chain ID, `dexHash` and the last `requestId`. See [Support](/resources/support).
  </Step>
</Steps>

## Verify

* Single-chain: a confirmed swap has `status: "success"` in its receipt, and a replaced or cancelled transaction shows as not executed even when the replacement succeeded.
* Cross-chain: the poller returns `success` with a `toTxHash` that opens on the destination chain's explorer, and the destination transaction delivered at least the quote's `minimumReceived` to `account`.
* A `500 TX_STATUS_ERROR` during polling leads to another attempt, not a failure.
* After a restart, open tracking records resume from storage.

## Common pitfalls

<Warning>
  **Declaring success from the source receipt.** On a cross-chain swap the source leg can succeed while the transfer fails or is refunded. Only a success status from `POST /tx-status` means the funds arrived.
</Warning>

<Warning>
  **Treating `TX_STATUS_ERROR` as a failure.** It means the status is unknown for now. Telling a user a transfer failed while it's still in flight can lead them to swap again.
</Warning>

<Warning>
  **Trusting a replacement's receipt.** When a user replaces or cancels the swap from their wallet, the transaction that lands isn't the swap, and its receipt can still say `success`.
</Warning>

<Warning>
  **Wrong `/tx-status` inputs.** `chainId` is the source chain ID as an integer (`137`), `hash` is the source-chain transaction, and `dexHash` comes from the cross-chain `POST /swap` response, in lowercase. A string `chainId` returns `400`, but a wrong value of the right type doesn't: it returns `500 TX_STATUS_ERROR` on every poll until your cap.
</Warning>

## What's next

<CardGroup cols={2}>
  <Card title="Get cross-chain transfer status" icon="code" href="/api-reference/transactions/get-transaction-status">
    Every field of `POST /tx-status`.
  </Card>

  <Card title="Cross-chain swap end-to-end" icon="link" href="/guides/cross-chain-swap-end-to-end">
    The full flow from quote to delivery.
  </Card>

  <Card title="Handle errors and retries" icon="circle-exclamation" href="/guides/handle-errors-and-retries">
    Retries, backoff and request IDs.
  </Card>

  <Card title="Cross-chain mechanics" icon="route" href="/concepts/cross-chain-mechanics">
    What happens between the two chains.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.