Across Docs

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.

1-discover.ts
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.

2-price.ts
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.

3-execute.ts
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.

3-execute-approval.ts
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.

4-track.ts
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.

SituationWhat you get
Missing or invalid API key401 — flat unauthenticated (message is AUTH_INVALID for a key that is present but wrong)
Unrecognised body or query field400 request.unsupported_param, naming the field
Amount the route cannot clear400 policy.amount_out_of_range
Unknown or malformed order id404 not_found.transfer
Quote past its expiryRe-quote and do not retry the execution
Too many requests429 — flat gateway shape, like the 401

On this page