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 message, chain, amount and refundAddress. It is absent
until then.
const BASE = "https://api.staging.across.to";
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 transfer = await (await fetch(`${BASE}/v1/transfers/${orderId}`)).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 10 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