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.
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
- Tracking transfers — the full status model
GET /v1/transfers/{orderId}— therefundandstatusschemas