Introduction

Refunds

What happens when a V5 transfer does not fill, and how to track the refund.

A transfer is refunded when it does not fill before its deadline. This is rare — the competitive relayer network fills most transfers in seconds — but it is worth handling, because the timescale is very different from a fill.

Know before you sign

Every quote carries a refundPolicy describing expected refund behaviour for that route. It is advisory, not binding — it tells you what to expect before the user commits, not what happened.

It is discriminated by supported:

// Refundable route
{
  "supported": true,
  "token": { "chainId": 42161, "address": "0xaf88..." },
  "refundAddress": "0xYourAddress",
  "expectedSeconds": 5400
}

// Irreversible route
{
  "supported": false,
  "reason": "irreversible_rail",
  "message": "This route cannot be reversed once submitted."
}

When supported is false, reason is one of irreversible_rail, permanent_deposit_address or destination_action_finalised.

Check refundPolicy.supported before asking the user to sign. On a route where it is false, there is no recovery path once the authorization is submitted. message is written to be shown to an end user as-is.

expectedSeconds is measured from the fill deadline and is a best-effort estimate, not an SLA.

The refund lifecycle

The transfer fails to fill

No relayer fills before the deadline. status.state becomes failed with status.reason of expired. Funds are still escrowed.

Bundle settlement

The expired transfer is included in the next settlement bundle. Bundles are proposed roughly every 1.5 hours and must clear a challenge period via UMA's Optimistic Oracle.

Refund execution

After the challenge period the refund root reaches the target chain and the refund executes on-chain. status.state becomes refunded with status.reason of refund_confirmed.

Refunds are not instant. Bundle intervals, the challenge period and canonical bridge delays together mean a refund can take several hours. Do not tell users to expect their money back immediately.

Tracking a refund

Refunds surface on the same transfer you were already tracking — there is no separate endpoint. Once a refund leaf has executed, transfer.refund is populated with chain, amount, refundAddress and returned, which names the side that was given back (input when the depositor got the origin token). It is absent until then.

refund.message is declared but is not currently emitted — it was absent from every refunded transfer in a live sample. Do not depend on it.

Reading a transfer needs your API key, exactly as in Tracking Transfers.

wait-for-refund.ts
const BASE_URL = "https://api.staging.across.to";
const API_KEY = process.env.ACROSS_API_KEY;

async function waitForRefund(orderId: string) {
  const intervalMs = 60_000;  // refunds take hours — poll once a minute
  const maxAttempts = 360;    // give up after ~6 hours

  for (let i = 0; i < maxAttempts; i++) {
    const res = await fetch(`${BASE_URL}/v1/transfers/${orderId}`, {
      headers: { Authorization: `Bearer ${API_KEY}` },
    });
    const transfer = await res.json();
    const { state, reason, description } = transfer.status;

    switch (state) {
      case "completed":
        return { outcome: "filled" };            // filled after all
      case "refunded":
        return { outcome: "refunded", refund: transfer.refund };
      case "failed":
        console.log(`Awaiting refund (${reason}): ${description}`);
        break;
      default:
        console.log(`Still ${state} / ${reason}`);
    }

    await new Promise((r) => setTimeout(r, intervalMs));
  }

  throw new Error("Refund polling timed out");
}

Poll refunds on a 60-second interval, not the 5 seconds used for fill tracking. Refunds move on the order of hours, so faster polling only costs you requests.

The refund transaction also appears in transfer.transactions as the entry with type: "refund".

Next

On this page