Skip to main content
Portal’s CCTP integration moves USDC between EVM chains and Solana: the wallet burns USDC on the source chain and Circle mints it on the destination chain. The user signs only on the source chain and needs no gas on the destination. This guide covers quoting, creating, signing, submitting and tracking a transfer with curl.

Overview

The CCTP endpoints allow you to:
  • List routes to see which chains you can send between
  • Get a quote with the fees, the exact amount received and preflight checks
  • Create a transfer and get what to sign: EVM transactions, a user operation or a Solana transaction
  • Submit the hash (or Solana signature) of the signed burn so Portal can track it
  • Track the transfer until the USDC arrives, and list past transfers
All endpoints live under https://api.portalhq.io/api/v3/clients/me/integrations/cctp. Successful responses wrap the result in data.

Prerequisites

Before you start, ensure you have:
  • A properly initialized Portal client (see Create a client)
  • A wallet holding USDC on the source chain (see Create a wallet). EOA clients also need native gas on the source chain. Account Abstraction clients need gas sponsorship for the source chain, or native gas in the smart account. For a Solana source, the wallet needs SOL for fees and rent unless your environment has a Solana gas sponsor.
  • For a Solana source, or to send to the client’s own Solana address, a client with a Solana wallet
  • The CCTP integration enabled in your Portal Dashboard (see CCTP integration), with your own fee set there if you charge one
The examples below are real responses from a testnet transfer of 0.09 USDC from Base Sepolia (eip155:84532) to Avalanche Fuji (eip155:43113), with no custodian fee set on the CCTP card. Step 4b shows an Account Abstraction transfer and Step 4c a transfer from Solana Devnet.

Step 1: List routes

Use GET /routes to list the chains you can send between. For complete API documentation, see the Client API reference.
The response, trimmed to three chains:
domain is Circle’s domain id, not the EVM chain id. Pick a source and destination on the same network. namespace is eip155 for EVM chains and solana for Solana, where usdcAddress is the USDC mint. fastTransfer tells you whether Fast is available from that chain, and estimatedSeconds.fast is null when it isn’t.

Step 2: Get a quote

Use POST /quote to see the fees, the amount the recipient receives and whether the transfer can go ahead. A quote stores nothing, so you can call it as often as you like, for example while the user types an amount. For complete API documentation, see the Client API reference.
The body is strict: unknown fields are rejected with a 400. The response:
  • fees.maxFee is fees.protocol plus fees.forwarding, Circle’s fees, and amountOut is amount - fees.maxFee. Circle can charge less than maxFee, so the recipient never gets less than amountOut; in practice it charges the full maxFee.
  • fees.custodian is your fee, paid in USDC on top of amount, so totalDebit (amount + fees.custodian) is what leaves the wallet. See Fees.
  • minAmount is the smallest amount that delivers anything on this route.
  • speed is the speed Portal picked, and estimatedSeconds the estimated time for it. A Solana source always uses standard.
  • sourceGas.estimatedWei is the estimated source chain gas for all the transactions, and sourceGas.payer is sponsor when gas is sponsored. For a Solana source, sourceGas.estimatedLamports is the estimated SOL cost of the transaction fees and rent, in lamports.
  • warnings can include LONG_FINALITY, EXTERNAL_RECIPIENT and RECIPIENT_ACCOUNT_SETUP (Circle creates the Solana recipient’s USDC token account and charges a higher forwarding fee). They never block the transfer.
  • ready is true when no check has ok: false. See Preflight checks for what each check means.
A quote that can’t go ahead still returns 200, with ready: false and a message and fix on each failed check:
For Account Abstraction clients on an EVM source, SOURCE_GAS and SPONSORSHIP are null on a quote, because Portal only builds the user operation when you create the transfer. For a Solana source, both are known on the quote.

Step 3: Create the transfer

Use POST /transfers with the same body as the quote. Portal quotes again with fresh fees, runs every check and, when they all pass, stores the transfer as BUILT and returns what to sign. For complete API documentation, see the Client API reference.
The response has every quote field, plus transferId and transactions:
Store transferId. The fees, totalDebit and amountOut in this response are the ones the transfer uses, so show these to your user rather than the earlier quote. On an EVM source, transactions contains an approve first when the wallet’s USDC allowance for Circle’s contract is too low, then the burn, then a fee transfer for your fee. When the allowance is already enough, there’s no approve, and with no fee there’s no fee entry. Account Abstraction clients also get a userOperation (see Step 4b). For a Solana source, transactions holds a single Solana transaction instead (see Step 4c). With a custodian fee set, the last entry is the fee transfer to your EVM fee address:

When a check fails

If any check fails, Portal stores nothing and returns 400 with the id CCTP_PREFLIGHT_FAILED. details.failed lists every failed check and details.checks has all of them, with a fix for each failure. When AMOUNT_ABOVE_MINIMUM fails, details.minAmount is the smallest amount that works. For example, a wallet with no ETH for gas yet:

Idempotency

The Idempotency-Key header is optional but recommended, so a retried request can’t create a second transfer. Keys are scoped to the client, up to 255 characters, and use only A-Z, a-z, 0-9, -, ., _ and ~.
  • Same key, same body: returns the same transfer. While it’s still BUILT (or ABANDONED), Portal first checks whether its burn already landed, for example because your app broadcast it but stopped before submitting. If it did, Portal submits it for you and returns the transfer’s current state with no transactions, so the wallet can’t burn twice. Otherwise the response has the same transferId, amount and fees with freshly built transactions, and the checks run again. Once it’s submitted, the response has the transfer’s current state (the fields GET /transfers/{id} returns) plus transferId, and no transactions.
  • Same key, no way to rule out an earlier burn: if Portal can’t look back far enough to confirm the burn never landed (a retry long after the transfer was created, or a very busy wallet), it returns 409 with the id CCTP_REBUILD_UNAVAILABLE instead of new transactions. Submit the burn’s hash if your app broadcast it; otherwise create a new transfer with a new key.
  • Same key, within 2 minutes of the last build: 409 with the id CCTP_REBUILD_TOO_SOON, since the transfer’s burn may still be pending. Retry after details.retryAfterSeconds, or submit the hash if your app broadcast it.
  • Same key, different body: 409 with the id IDEMPOTENCY_KEY_REUSED.
The order of fields in the body doesn’t matter, and leaving out speed is the same as sending "auto".

Step 4a: Sign and broadcast (EOA clients)

On an EVM source, sign each entry in transactions in order with the eth_sendTransaction flow from Sign Ethereum transactions. from is the sender from the response, and chainId is the sourceChain. Sign the approve:
Wait for the approve to be mined before you send the burn. Otherwise the burn can fail because the allowance isn’t set yet. Poll get transaction (GET /api/v3/clients/me/chains/eip155:84532/transactions/{hash}) with the approve hash until the transaction has a blockNumber, then check its status is 0x1 (0x0 means it reverted: stop there). Then sign the burn the same way, with the burn’s to and data. The response data is the burn transaction hash. Keep it for the next step, and wait for it to be mined with status 0x1 the same way. Then sign each fee transaction the same way. Only send them once the burn succeeded: if it reverted, stop there and no fee is paid.
Submit the burn hash, not the approve or a fee transfer hash. A transfer submitted with another hash ends as FAILED with failureReason TX_MISMATCH.
To make the signing calls safe to retry, add an Idempotency-Key header to them too. See Idempotency keys.

Step 4b: Sign and broadcast (Account Abstraction clients)

For Account Abstraction clients on an EVM source, the create response also has a userOperation that batches the approve (when needed), the burn and any fee transfers into one ERC-4337 user operation. Sign that, not the transactions, which only show the calls inside it.
The other quote fields are left out above for brevity. userOperation.userOperation is the user operation as a JSON string, and userOperation.userOpHash is the hash to sign. The steps are the same as Build, sign and broadcast a user operation, starting from the signing step:
  1. Sign userOpHash with the raw signing endpoint, without the 0x prefix:
The response data is the signature.
  1. Broadcast it with broadcast user operation. Pass userOperation.userOperation exactly as the create response returned it, and the signature exactly as the raw signing endpoint returned it:
The response data.userOpHash is the hash to submit in the next step.
Sign and broadcast soon after creating the transfer. The user operation carries gas prices and a nonce from the moment it was built, and a sponsored one carries paymaster data that expires after a few minutes. If it expires, call POST /transfers again with the same Idempotency-Key and body to get a freshly built user operation for the same transfer, at least 2 minutes after the last one was built.
  • When sponsored is false, the smart account pays for gas and needs enough native token on the source chain to cover estimatedGasCostWei. The SOURCE_GAS and SPONSORSHIP checks fail when it can’t.
  • Your environment’s transaction policies run when Portal builds the user operation. When one blocks it, POST /transfers returns 403 with the id POLICY_VIOLATION and the policy in details.policyViolation.

Step 4c: Sign and broadcast (Solana source)

For a Solana source, transactions holds a single Solana transaction, for EOA and Account Abstraction clients alike. For example, sending 0.09 USDC from Solana Devnet to Base Sepolia, with the environment’s gas sponsor paying the SOL:
The other quote fields are left out above for brevity.
  • serializedTransaction is a base64 Solana transaction that holds the burn and any fee transfers. It’s already partially signed by a one-time event account that Circle’s program requires, so sign it exactly as returned. Changing it, for example its fee payer, invalidates that signature.
  • When sourceGas.payer is sponsor, your environment’s Solana gas sponsor is already set as the fee payer and pays the transaction fee and the rent for Circle’s event account, so the wallet needs no SOL. Sign it with sponsorship on (don’t pass sponsorGas: false), or the sponsor’s signature is missing and the transaction fails. Otherwise the wallet pays, about sourceGas.estimatedLamports lamports, and must keep Solana’s minimum balance for the account afterwards.
Sign and broadcast it with sol_signAndSendTransaction and the client’s ED25519 share. params is the serializedTransaction string, and chainId is the sourceChain:
The response data is the transaction signature. Keep it for the next step.
The transaction carries a recent blockhash that expires after roughly a minute (at lastValidBlockHeight), so sign and broadcast it right after creating the transfer. If signing is delayed and the transaction expires before you broadcast it, call POST /transfers again with the same Idempotency-Key and body to get a freshly built transaction for the same transfer, at least 2 minutes after the last one was built. If the earlier transaction did land, Portal finds it and starts tracking it instead of returning a new one.

Step 5: Submit the hash

Tell Portal which transaction to track with POST /transfers/{id}/submit. Send exactly one of:
  • txHash: the burn transaction hash, for EOA clients on an EVM source. For a Solana source, the base58 signature of the burn transaction, for every client (Account Abstraction clients included).
  • userOpHash: the user operation hash, for Account Abstraction clients on an EVM source.
For complete API documentation, see the Client API reference.
For a Solana source, txHash is the signature from sol_signAndSendTransaction:
The response is the transfer:
Submitting is safe to retry. The same hash again returns the transfer, while a different hash returns 409 with the id ALREADY_SUBMITTED. A hash that was already submitted for another transfer also returns 409 ALREADY_SUBMITTED. A transfer marked ABANDONED (built but not submitted within an hour, with no landed burn) still accepts a late submit.

Step 6: Track the transfer

Poll GET /transfers/{id} until the status is final. It’s a cheap read from Portal’s database, since Portal tracks the transfer in the background. Wait pollAfterMs milliseconds between reads. For complete API documentation, see the Client API reference.
A completed transfer:

List transfers and recover a transfer id

Use GET /transfers to list the client’s transfers, newest first. This is how to find a transferId you lost, for example if your app stopped after creating a transfer but before storing its id. For complete API documentation, see the Client API reference.
The response, trimmed to the first transfer:
nextCursor is null on the last page. If you sent an Idempotency-Key when creating the transfer, repeating the create request with the same key and body also returns the transfer.

Errors

Error responses have an error message, and CCTP errors add a stable id (and details where relevant) that you can key off:

Example flow

A complete EOA transfer, using jq to read the responses:

Next steps