Quickstart
Move one transfer end to end, from discovery to a settled outcome.
This quickstart guide walks you through a single transfer, end to end.
You need an API key. Get one from the integrator portal
and set it as ACROSS_API_KEY.
Discover route capabilities
GET /v1/capabilities answers what can move between two chains right now.
Anything unavailable comes back with a reason rather than being silently
dropped.
const BASE_URL = "https://api.across.to";
const API_KEY = process.env.ACROSS_API_KEY;
const res = await fetch(
`${BASE_URL}/v1/capabilities` +
"?originChainId=42161&destinationChainId=8453&include=tokens",
{ headers: { Authorization: `Bearer ${API_KEY}` } },
);
const capabilities = await res.json();Each token in the response carries its decimals and an availability
status for the route. Take decimals from here rather than hardcoding it —
the same asset can have different decimals on different chains.
Availability here means selectable, not guaranteed to price. The quote is the binding check for one specific transfer.
Price the transfer
POST /v1/quotes prices one exact transfer and returns the ordered actions
required to execute it.
const BASE_URL = "https://api.across.to";
const API_KEY = process.env.ACROSS_API_KEY;
const res = await fetch(`${BASE_URL}/v1/quotes`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
origin: {
token: { chainId: 42161, address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831" },
amount: "1000000", // 1 USDC, 6 decimals
depositor: "0xYourWalletAddress",
},
destination: {
token: { chainId: 8453, address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" },
recipient: "0xYourWalletAddress",
},
amountType: "exact_input",
}),
});
const quote = await res.json();amountType decides which side carries amount: exact_input spends a
known input, while exact_output and min_output name a delivery on the
destination instead. It defaults to exact_input.
Bodies and query strings are both strict. An unrecognised field will return an error.
Execute the quote
The quote comes back with nextActions: an ordered list of things that must
happen before the transfer can proceed. Work through them in order. If the
last one is submit_execution, POST what they produced to the executions
endpoint. If there is no submit_execution, there is nothing to POST.
Only POST when the quote asks for it. submit_execution is in
nextActions exactly when the executions endpoint must be called.
Signature-funded quotes have it. Quotes funded on chain by a
prefunded-store transaction do not.
Signature-funded quotes
Below, a USDC quote funded by an ERC-3009 signature: one sign_typed_data
action, then submit_execution. The signatures map is keyed by each
action's own id, and must carry an entry for every sign_typed_data
action the quote returned.
const BASE_URL = "https://api.across.to";
const API_KEY = process.env.ACROSS_API_KEY;
const signAction = quote.nextActions.find((a) => a.kind === "sign_typed_data");
const res = await fetch(
`${BASE_URL}/v1/quotes/${quote.orderId}/executions`,
{
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
authorization: {
signatures: {
[signAction.id]: await walletClient.signTypedData(signAction.typedData),
},
},
}),
},
);
const { orderId } = await res.json();Sign typedData verbatim — that exact payload is what the server verifies.
A 2xx means the transfer was accepted, not that it has arrived. It still has to be filled, which is the next step.
Approval-funded quotes
A client that can only approve and send transactions declares that in the
quote request, with capabilities.operations set to approve_token:evm and
send_transaction:evm, and authorizationPolicy.allowedTypes set to
["approval"]. The quote then returns an approve_token action (omitted
when the allowance already covers the amount) and a send_transaction with
id: "prefunded-store", which is the deposit. There is no
submit_execution: once the deposit lands, Across picks it up on chain and
the transfer starts.
for (const action of quote.nextActions) {
if (action.kind === "approve_token") {
const hash = await walletClient.writeContract({
address: action.tokenAddress,
abi: erc20Abi,
functionName: "approve",
args: [action.spender, BigInt(action.amount)],
});
await publicClient.waitForTransactionReceipt({ hash });
} else if (action.kind === "send_transaction") {
const hash = await walletClient.sendTransaction({
to: action.to,
data: action.data,
value: BigInt(action.value),
});
await publicClient.waitForTransactionReceipt({ hash });
} else {
throw new Error(`unexpected action: ${action.kind}`);
}
}
// No POST to the executions endpoint. Track quote.orderId.Check the whole list before the first wallet prompt, so you never start a plan your client can't finish. A sent deposit is not a delivered transfer; track it in the next step.
Save the quote's orderId and use it to track the transfer.
Which actions a quote returns, and what each one requires, is on the Quote operation. What to submit back is on the Execution operation.
Track it to a settled outcome
Use the orderId you saved in the previous step to poll the transfer until
the outcome is final.
const BASE_URL = "https://api.across.to";
const API_KEY = process.env.ACROSS_API_KEY;
async function track(orderId: string) {
while (true) {
const res = await fetch(`${BASE_URL}/v1/transfers/${orderId}`, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
const transfer = await res.json();
const { isTerminal, finalized } = transfer.status;
if (isTerminal && finalized) return transfer;
await new Promise((r) => setTimeout(r, 5_000));
}
}Read status.state for progress. status.isTerminal turns true as soon as
the outcome is known, usually seconds after the fill, but it can still
change until status.finalized is true, which follows once both chains are
final, typically minutes later. Show progress from isTerminal; wait for
finalized before acting on the outcome in a way you cannot undo.
status.description is a support-safe sentence you can show a user as-is.
Branch your own logic on status.state, never on description.
Rather be told than poll? Webhooks push each status change to your endpoint.
The exact state and reason values, and the full semantics of
isTerminal, are on the
Transfer operation.
Handling errors
Errors come back in two envelopes, and which one you get depends on how far the request travelled.
Requests rejected at the edge, before reaching a service, are flat:
{ "code": "unauthenticated", "message": "AUTH_INVALID", "details": null }Everything else nests under error, with a hierarchical type.specific code
and a requestId worth quoting in support requests:
{
"error": {
"type": "request",
"code": "request.unsupported_param",
"message": "unsupported field: amountTyp",
"param": "amountTyp",
"requestId": "bhmecsejc58sfsfe2u56mn4cfc"
}
}Read the code from error.code, and fall back to the top-level code. The
two envelopes are not interchangeable, and a handler that reads only one of
them will silently miss the other.
| Situation | What you get |
|---|---|
| Missing or invalid API key | 401 — flat unauthenticated (message is AUTH_INVALID for a key that is present but wrong) |
| Unrecognised body or query field | 400 request.unsupported_param, naming the field |
| Amount the route cannot clear | 400 policy.amount_out_of_range |
| Unknown or malformed order id | 404 not_found.transfer |
| Quote past its expiry | Re-quote and do not retry the execution |
| Too many requests | 429 — flat gateway shape, like the 401 |