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
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
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
UseGET /routes to list the chains you can send between.
For complete API documentation, see the Client API reference.
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
UsePOST /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.maxFeeisfees.protocolplusfees.forwarding, Circle’s fees, andamountOutisamount - fees.maxFee. Circle can charge less thanmaxFee, so the recipient never gets less thanamountOut; in practice it charges the fullmaxFee.fees.custodianis your fee, paid in USDC on top ofamount, sototalDebit(amount + fees.custodian) is what leaves the wallet. See Fees.minAmountis the smallestamountthat delivers anything on this route.speedis the speed Portal picked, andestimatedSecondsthe estimated time for it. A Solana source always usesstandard.sourceGas.estimatedWeiis the estimated source chain gas for all the transactions, andsourceGas.payerissponsorwhen gas is sponsored. For a Solana source,sourceGas.estimatedLamportsis the estimated SOL cost of the transaction fees and rent, in lamports.warningscan includeLONG_FINALITY,EXTERNAL_RECIPIENTandRECIPIENT_ACCOUNT_SETUP(Circle creates the Solana recipient’s USDC token account and charges a higher forwarding fee). They never block the transfer.readyistruewhen no check hasok: false. See Preflight checks for what each check means.
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
UsePOST /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.
transferId and transactions:
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 returns400 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
TheIdempotency-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(orABANDONED), 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 notransactions, so the wallet can’t burn twice. Otherwise the response has the sametransferId, amount and fees with freshly builttransactions, and the checks run again. Once it’s submitted, the response has the transfer’s current state (the fieldsGET /transfers/{id}returns) plustransferId, and notransactions. - 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
409with the idCCTP_REBUILD_UNAVAILABLEinstead of newtransactions. 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:
409with the idCCTP_REBUILD_TOO_SOON, since the transfer’s burn may still be pending. Retry afterdetails.retryAfterSeconds, or submit the hash if your app broadcast it. - Same key, different body:
409with the idIDEMPOTENCY_KEY_REUSED.
speed is the same as sending "auto".
Step 4a: Sign and broadcast (EOA clients)
On an EVM source, sign each entry intransactions 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:
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.
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 auserOperation 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.
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:
- Sign
userOpHashwith the raw signing endpoint, without the0xprefix:
data is the signature.
- Broadcast it with broadcast user operation. Pass
userOperation.userOperationexactly as the create response returned it, and the signature exactly as the raw signing endpoint returned it:
data.userOpHash is the hash to submit in the next step.
- When
sponsoredisfalse, the smart account pays for gas and needs enough native token on the source chain to coverestimatedGasCostWei. TheSOURCE_GASandSPONSORSHIPchecks fail when it can’t. - Your environment’s transaction policies run when Portal builds the user operation. When one blocks it,
POST /transfersreturns403with the idPOLICY_VIOLATIONand the policy indetails.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:
serializedTransactionis 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.payerissponsor, 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 passsponsorGas: false), or the sponsor’s signature is missing and the transaction fails. Otherwise the wallet pays, aboutsourceGas.estimatedLamportslamports, and must keep Solana’s minimum balance for the account afterwards.
sol_signAndSendTransaction and the client’s ED25519 share. params is the serializedTransaction string, and chainId is the sourceChain:
data is the transaction signature. Keep it for the next step.
Step 5: Submit the hash
Tell Portal which transaction to track withPOST /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.
txHash is the signature from sol_signAndSendTransaction:
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
PollGET /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.
List transfers and recover a transfer id
UseGET /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 anerror message, and CCTP errors add a stable id (and details where relevant) that you can key off:
Example flow
A complete EOA transfer, usingjq to read the responses:
Next steps
- Read about fees, recipients, preflight checks and statuses on the CCTP integration page
- Learn about signing Ethereum transactions and user operations, and signing Solana transactions
- Check out the Client API CCTP endpoints