> ## Documentation Index
> Fetch the complete documentation index at: https://docs.portalhq.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Move USDC with CCTP

> Learn how to move USDC between chains with CCTP using the Enclave MPC API.

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](./create-a-client))
* A wallet holding USDC on the source chain (see [Create a wallet](./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](../../../integrations/Trading/cctp)), 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](#step-4b-sign-and-broadcast-account-abstraction-clients) shows an Account Abstraction transfer and [Step 4c](#step-4c-sign-and-broadcast-solana-source) 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](/api-reference/cctp/list-cctp-routes).

```bash theme={null}
curl --request GET \
  --url 'https://api.portalhq.io/api/v3/clients/me/integrations/cctp/routes' \
  --header 'Authorization: Bearer [clientApiKey|clientSessionToken]'
```

The response, trimmed to three chains:

```json theme={null}
{
  "data": {
    "chains": [
      {
        "chainId": "eip155:84532",
        "name": "Base Sepolia",
        "domain": 6,
        "network": "testnet",
        "namespace": "eip155",
        "usdcAddress": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
        "canSend": true,
        "canReceive": true,
        "fastTransfer": true,
        "estimatedSeconds": { "fast": 8, "standard": 1080 }
      },
      {
        "chainId": "eip155:43113",
        "name": "Avalanche Fuji",
        "domain": 1,
        "network": "testnet",
        "namespace": "eip155",
        "usdcAddress": "0x5425890298aed601595a70AB815c96711a31Bc65",
        "canSend": true,
        "canReceive": true,
        "fastTransfer": false,
        "estimatedSeconds": { "fast": null, "standard": 8 }
      },
      {
        "chainId": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
        "name": "Solana Devnet",
        "domain": 5,
        "network": "testnet",
        "namespace": "solana",
        "usdcAddress": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
        "canSend": true,
        "canReceive": true,
        "fastTransfer": false,
        "estimatedSeconds": { "fast": null, "standard": 25 }
      }
    ]
  }
}
```

`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](/api-reference/cctp/quote-a-cctp-transfer).

```bash theme={null}
curl --request POST \
  --url 'https://api.portalhq.io/api/v3/clients/me/integrations/cctp/quote' \
  --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \
  --header 'Content-Type: application/json' \
  --data '{
  "sourceChain": "eip155:84532",
  "destinationChain": "eip155:43113",
  "amount": "0.09"
}'
```

| Field | Required | Description |
| - | - | - |
| `sourceChain` | Yes | CAIP-2 id of the chain to send from, for example `eip155:11155111` or `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`. |
| `destinationChain` | Yes | CAIP-2 id of the chain to send to. |
| `amount` | One of `amount` or `amountOut` | USDC to burn on the source chain, as a decimal string with up to 6 decimals (`"0.1"`, not base units). |
| `amountOut` | One of `amount` or `amountOut` | USDC the recipient must receive. Portal works out the `amount` to burn so the recipient gets at least this after fees (exactly this in practice, since Circle charges the full `maxFee`). |
| `speed` | No | `auto` (default), `fast` or `standard`. `auto` and `fast` use Fast when the source chain offers it, otherwise Standard. |
| `recipient` | No | Address to receive the USDC: an EVM address for an EVM destination, or a Solana wallet address (not a token account) for a Solana destination. Defaults to the client's own address on the destination chain. Any other address must be on your destination allowlist. |

The body is strict: unknown fields are rejected with a `400`.

The response:

```json theme={null}
{
  "data": {
    "sourceChain": "eip155:84532",
    "destinationChain": "eip155:43113",
    "amount": "0.09",
    "amountRaw": "90000",
    "speed": "fast",
    "minFinalityThreshold": 1000,
    "fees": {
      "protocol": "0.000012",
      "forwarding": "0.054181",
      "maxFee": "0.054193",
      "custodian": "0"
    },
    "totalDebit": "0.09",
    "amountOut": "0.035807",
    "minAmount": "0.05419",
    "sourceGas": { "payer": "user", "estimatedWei": "1237440000000" },
    "estimatedSeconds": 8,
    "sender": "0xa7f0c09f250b7f9cc73068c02ceb296a013c0c0c",
    "recipient": "0xa7f0c09f250b7f9cc73068c02ceb296a013c0c0c",
    "warnings": [],
    "ready": true,
    "checks": [
      { "id": "ROUTE_SUPPORTED", "ok": true },
      { "id": "AMOUNT_ABOVE_MINIMUM", "ok": true },
      { "id": "BALANCE", "ok": true },
      { "id": "RECIPIENT_ALLOWED", "ok": true },
      { "id": "SOURCE_GAS", "ok": true }
    ]
  }
}
```

* `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](../../../integrations/Trading/cctp#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](../../../integrations/Trading/cctp#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:

```json theme={null}
{ "id": "BALANCE", "ok": false, "message": "Balance is 0.045999 USDC on Base Sepolia; the transfer needs 0.06", "fix": "Fund the wallet with USDC on eip155:84532" }
```

> [!NOTE]
>
> 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](/api-reference/cctp/create-a-cctp-transfer).

```bash theme={null}
curl --request POST \
  --url 'https://api.portalhq.io/api/v3/clients/me/integrations/cctp/transfers' \
  --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: order-8412-cctp' \
  --data '{
  "sourceChain": "eip155:84532",
  "destinationChain": "eip155:43113",
  "amount": "0.09"
}'
```

The response has every quote field, plus `transferId` and `transactions`:

```json theme={null}
{
  "data": {
    "transferId": "cmuzgyc2105hw41rg2h19pomm",
    "sourceChain": "eip155:84532",
    "destinationChain": "eip155:43113",
    "amount": "0.09",
    "amountRaw": "90000",
    "speed": "fast",
    "minFinalityThreshold": 1000,
    "fees": {
      "protocol": "0.000012",
      "forwarding": "0.054181",
      "maxFee": "0.054193",
      "custodian": "0"
    },
    "totalDebit": "0.09",
    "amountOut": "0.035807",
    "minAmount": "0.05419",
    "sourceGas": { "payer": "user", "estimatedWei": "1237440000000" },
    "estimatedSeconds": 8,
    "sender": "0xa7f0c09f250b7f9cc73068c02ceb296a013c0c0c",
    "recipient": "0xa7f0c09f250b7f9cc73068c02ceb296a013c0c0c",
    "warnings": [],
    "ready": true,
    "checks": [
      { "id": "ROUTE_SUPPORTED", "ok": true },
      { "id": "AMOUNT_ABOVE_MINIMUM", "ok": true },
      { "id": "BALANCE", "ok": true },
      { "id": "RECIPIENT_ALLOWED", "ok": true },
      { "id": "SOURCE_GAS", "ok": true }
    ],
    "transactions": [
      {
        "to": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
        "data": "0x095ea7b30000000000000000000000008fe6b999dc680ccfdd5bf7eb0974218be2542daa0000000000000000000000000000000000000000000000000000000000015f90",
        "value": "0",
        "kind": "approve"
      },
      {
        "to": "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA",
        "data": "0x779b432d0000000000000000000000000000000000000000000000000000000000015f900000000000000000000000000000000000000000000000000000000000000001000000000000000000000000a7f0c09f250b7f9cc73068c02ceb296a013c0c0c000000000000000000000000036cbd53842c5426634e7929541ec2318f3dcf7e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d3b100000000000000000000000000000000000000000000000000000000000003e800000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000020636374702d666f72776172640000000000000000000000000000000000000000",
        "value": "0",
        "kind": "burn"
      }
    ]
  }
}
```

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](#step-4b-sign-and-broadcast-account-abstraction-clients)). For a Solana source, `transactions` holds a single Solana transaction instead (see [Step 4c](#step-4c-sign-and-broadcast-solana-source)).

With a custodian fee set, the last entry is the fee transfer to your EVM fee address:

```json theme={null}
{
  "to": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
  "data": "0xa9059cbb0000000000000000000000002ca7777daae64108dbf1502cac3ca3ce0aba298d00000000000000000000000000000000000000000000000000000000000001f4",
  "value": "0",
  "kind": "fee"
}
```

### 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:

```json theme={null}
{
  "error": "CCTP transfer can't be built: SOURCE_GAS",
  "details": {
    "failed": ["SOURCE_GAS"],
    "checks": [
      { "id": "ROUTE_SUPPORTED", "ok": true },
      { "id": "AMOUNT_ABOVE_MINIMUM", "ok": true },
      { "id": "BALANCE", "ok": true },
      { "id": "RECIPIENT_ALLOWED", "ok": true },
      {
        "id": "SOURCE_GAS",
        "ok": false,
        "message": "Native balance 0 wei is below the ~1237440000000 wei needed for gas",
        "fix": "Fund the wallet with native gas on eip155:84532"
      }
    ]
  },
  "id": "CCTP_PREFLIGHT_FAILED"
}
```

### 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](./sign-ethereum-transactions). `from` is the `sender` from the response, and `chainId` is the `sourceChain`.

Sign the approve:

```bash theme={null}
curl --request POST \
  --url https://mpc-client.portalhq.io/v1/sign \
  --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \
  --header 'Content-Type: application/json' \
  --data '{
  "share": "[share]",
  "method": "eth_sendTransaction",
  "params": {
    "from": "0xa7f0c09f250b7f9cc73068c02ceb296a013c0c0c",
    "to": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
    "value": "0x0",
    "data": "0x095ea7b30000000000000000000000008fe6b999dc680ccfdd5bf7eb0974218be2542daa0000000000000000000000000000000000000000000000000000000000015f90"
  },
  "rpcUrl": "https://api.portalhq.io/rpc/v1/eip155/84532",
  "chainId": "eip155:84532"
}'
```

**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](/api-reference/wallet/get-transaction-details-by-signature) (`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.

> [!WARNING]
>
> 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](../../../resources/idempotency-keys).

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

For [Account Abstraction](../../../resources/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.

```json theme={null}
{
  "data": {
    "transferId": "cmuzh140l05i541rgg512354k",
    "sourceGas": { "payer": "sponsor", "estimatedWei": "5270758500000" },
    "checks": [
      { "id": "ROUTE_SUPPORTED", "ok": true },
      { "id": "AMOUNT_ABOVE_MINIMUM", "ok": true },
      { "id": "BALANCE", "ok": true },
      { "id": "RECIPIENT_ALLOWED", "ok": true },
      { "id": "SOURCE_GAS", "ok": true },
      { "id": "SPONSORSHIP", "ok": true }
    ],
    "transactions": [
      { "to": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "data": "0x095ea7b3...", "value": "0", "kind": "approve" },
      { "to": "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA", "data": "0x779b432d...", "value": "0", "kind": "burn" }
    ],
    "userOperation": {
      "userOperation": "{\"sender\":\"0xD5100FA185bd10b08a2DF69a8B7405B1585961ce\",\"nonce\":\"0x0\",\"initCode\":\"0x5de4839a76cf55d0c90e2061ef4386d962E15ae3296601cd000000000000000000000000d3f582f6b4814e989ee8e96bc3175320b5a540ab000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000084d1f57894000000000000000000000000d9ab5096a832b9ce79914329daee236f8eea03900000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000001441da01d1a68b621f1a6fbfd458ac746c046e9fa600000000000000000000000000000000000000000000000000000000000000000000000000000000\",\"callData\":\"0x34fcd5be...\",\"paymasterAndData\":\"0x66666666...\",\"signature\":\"0x00000000...\",\"maxFeePerGas\":\"0x7026f0\",\"maxPriorityFeePerGas\":\"0x100590\",\"callGasLimit\":\"0x392b0\",\"verificationGasLimit\":\"0x67189\",\"preVerificationGas\":\"0xecfd\"}",
      "userOpHash": "0x09a2689b48a289ca6e3748f590ee401599b9e1d8e52507f2106c3d39bb3e20b6",
      "sponsored": true,
      "estimatedGasCostWei": "5270758500000"
    }
  }
}
```

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](./sign-ethereum-transactions#build-sign-and-broadcast-a-user-operation), starting from the signing step:

1. Sign `userOpHash` with the raw signing endpoint, without the `0x` prefix:

```bash theme={null}
curl --request POST \
  --url https://mpc-client.portalhq.io/v1/raw/sign/SECP256K1 \
  --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \
  --header 'Content-Type: application/json' \
  --data '{
  "share": "[share]",
  "params": "09a2689b48a289ca6e3748f590ee401599b9e1d8e52507f2106c3d39bb3e20b6"
}'
```

The response `data` is the signature.

2. Broadcast it with [broadcast user operation](/api-reference/transactions/broadcast-a-user-operation). Pass `userOperation.userOperation` exactly as the create response returned it, and the signature exactly as the raw signing endpoint returned it:

```bash theme={null}
curl --request POST \
  --url 'https://api.portalhq.io/api/v3/clients/me/chains/eip155:84532/assets/send/broadcast-user-operation' \
  --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \
  --header 'Content-Type: application/json' \
  --data '{
  "userOperation": "USER_OPERATION_FROM_THE_CREATE_RESPONSE",
  "signature": "SIGNATURE_FROM_THE_RAW_SIGN_RESPONSE"
}'
```

The response `data.userOpHash` is the hash to submit in the next step.

> [!WARNING]
>
> 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:

```json theme={null}
{
  "data": {
    "transferId": "cmuzgzaj405hy41rgx2gai5e2",
    "sourceChain": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
    "destinationChain": "eip155:84532",
    "amount": "0.09",
    "speed": "standard",
    "fees": {
      "protocol": "0",
      "forwarding": "0.054001",
      "maxFee": "0.054001",
      "custodian": "0"
    },
    "totalDebit": "0.09",
    "amountOut": "0.035999",
    "sourceGas": { "payer": "sponsor", "estimatedLamports": "3002040" },
    "estimatedSeconds": 25,
    "sender": "CFDVxCGxPsdYFfHCgwgjq9Nv8rQ8X14ZrsLaQmJ6c4AD",
    "recipient": "0xa7f0c09f250b7f9cc73068c02ceb296a013c0c0c",
    "checks": [
      { "id": "ROUTE_SUPPORTED", "ok": true },
      { "id": "AMOUNT_ABOVE_MINIMUM", "ok": true },
      { "id": "BALANCE", "ok": true },
      { "id": "RECIPIENT_ALLOWED", "ok": true },
      { "id": "SOURCE_GAS", "ok": true },
      {
        "id": "SPONSORSHIP",
        "ok": true,
        "message": "The environment's gas sponsor pays the fees and rent"
      }
    ],
    "transactions": [
      {
        "kind": "burn",
        "namespace": "solana",
        "serializedTransaction": "AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYsZk5N/Z9giVAiEFxWmbXo5GXC1B1YXo7ACcHRvIhjSqmykJrNHAjnFXnEUIhDcEdh3qLzWbfBU1FqNIacRYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMACxL1B4ViaC0+8+Orgnvxi5G+Yg12vwcf8J62bySsUPQV3jWmZCQYU9KocWrA11kLkFrsF1UwKZnmlo204DQg8naRpxTSA7Nqxxk6Cm8Wswm8OpQNHyTuu/5uoEBmTTjLkWozqSA7+ycCsBaj/ewJeZX6GJDDLSOGv59Nu6j1t1KAbTtELLORIVfxOpM9ATQoLQMrX/7NAaLb8bd5BgjfAC6nXoOp1THIOPl15AR6Mo0tOX1cirITMUCr7RyRTqmjTNUHbq7us12Dfla4X7yoM1XeoxQbnP1bkLPoxu58CjSULwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFKaiGfuw3xJfThgGzO10dOrBI17FLYOsqlzxXrR5VRQtx71r2jruWGPiFgQi7ixv89aruYChuVkAhwQ4h3atSVEAM/zHgAPw39W+Z1B0m4S59II7nMg7bLVPGtsM7iBujmtqMVIduprQivBrbYzpqqz6gh6ncSy7S5JpSm8DNvuinDqDdYX+UHhjHQQ4hkY+XjXzhkrbn9qjCfID1GW9SKZfyBzhntzS0sNAsC+mG+HVut3hWSgz3fkgCdjPaFRVpl/IHQ/vqIYMs7g/CJsCJL6KZoe3rkn1lMC5tNfpOJPBTtP+v/MbiUnJ1BEgKbxdPv9Q2rMvQWSL1hOTczPhv873EbHc1dbneQ8vj1ZRMy4+fZWZriOO1hKhu462jjTsBt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKmqPIJ0AVPI7M9VKLu/2p/MUafbk2Qk8H8KN6F2H9f6VgEOFAIACQMQBgsMDwUEAQ0OEQcKDggNhAFv9T6DzGzfm5BfAQAAAAAABgAAAAAAAAAAAAAAAAAAAKfwwJ8lC3+cxzBowCzrKWoBPAwMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADx0gAAAAAAANAHAAAgAAAAY2N0cC1mb3J3YXJkAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
        "lastValidBlockHeight": 496041811
      }
    ]
  }
}
```

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`:

```bash theme={null}
curl --request POST \
  --url https://mpc-client.portalhq.io/v1/sign \
  --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \
  --header 'Content-Type: application/json' \
  --data '{
  "share": "[ED25519 share]",
  "method": "sol_signAndSendTransaction",
  "params": "AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA...",
  "rpcUrl": "https://api.portalhq.io/rpc/v1/solana/EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
  "chainId": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"
}'
```

The response `data` is the transaction signature. Keep it for the next step.

> [!WARNING]
>
> 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](/api-reference/cctp/submit-a-cctp-transfer).

```bash theme={null}
curl --request POST \
  --url 'https://api.portalhq.io/api/v3/clients/me/integrations/cctp/transfers/cmuzgyc2105hw41rg2h19pomm/submit' \
  --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \
  --header 'Content-Type: application/json' \
  --data '{
  "txHash": "0xc7c227eee90af289fceb0d33c66b472457c65b74811f2ba02a8ab21727496dfb"
}'
```

For a Solana source, `txHash` is the signature from `sol_signAndSendTransaction`:

```json theme={null}
{ "txHash": "3GxLobzBKtFmQUrYEvgCbrcXHbJ6SsgZwiwyXP8TWJeWBeZUCeAU7D1tzqdZqFYr7VvLA41WXsvQuFMqgGjXBMhr" }
```

The response is the transfer:

```json theme={null}
{
  "data": {
    "id": "cmuzgyc2105hw41rg2h19pomm",
    "status": "SUBMITTED",
    "sourceChain": "eip155:84532",
    "destinationChain": "eip155:43113",
    "amount": "0.09",
    "amountOut": "0.035807",
    "sender": "0xa7f0c09f250b7f9cc73068c02ceb296a013c0c0c",
    "recipient": "0xa7f0c09f250b7f9cc73068c02ceb296a013c0c0c",
    "speed": "fast",
    "fees": { "maxFee": "0.054193", "custodian": "0" },
    "totalDebit": "0.09",
    "sourceTxHash": "0xc7c227eee90af289fceb0d33c66b472457c65b74811f2ba02a8ab21727496dfb",
    "sourceExplorerUrl": "https://sepolia.basescan.org/tx/0xc7c227eee90af289fceb0d33c66b472457c65b74811f2ba02a8ab21727496dfb",
    "estimatedCompletionAt": "2026-10-08T11:44:54.347Z",
    "pollAfterMs": 2000,
    "createdAt": "2026-10-08T11:44:32.137Z",
    "updatedAt": "2026-10-08T11:44:46.349Z"
  }
}
```

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](/api-reference/cctp/get-a-cctp-transfer).

```bash theme={null}
curl --request GET \
  --url 'https://api.portalhq.io/api/v3/clients/me/integrations/cctp/transfers/cmuzgyc2105hw41rg2h19pomm' \
  --header 'Authorization: Bearer [clientApiKey|clientSessionToken]'
```

A completed transfer:

```json theme={null}
{
  "data": {
    "id": "cmuzgyc2105hw41rg2h19pomm",
    "status": "COMPLETED",
    "sourceChain": "eip155:84532",
    "destinationChain": "eip155:43113",
    "amount": "0.09",
    "amountOut": "0.035807",
    "sender": "0xa7f0c09f250b7f9cc73068c02ceb296a013c0c0c",
    "recipient": "0xa7f0c09f250b7f9cc73068c02ceb296a013c0c0c",
    "speed": "fast",
    "fees": { "maxFee": "0.054193", "custodian": "0", "feeExecuted": "0.054193" },
    "totalDebit": "0.09",
    "sourceTxHash": "0xc7c227eee90af289fceb0d33c66b472457c65b74811f2ba02a8ab21727496dfb",
    "destinationTxHash": "0x945c92ce99555fb8bb0db7ea9ef2527a5f1688e872b07a27dedc349cddd43989",
    "sourceExplorerUrl": "https://sepolia.basescan.org/tx/0xc7c227eee90af289fceb0d33c66b472457c65b74811f2ba02a8ab21727496dfb",
    "destinationExplorerUrl": "https://testnet.snowtrace.io/tx/0x945c92ce99555fb8bb0db7ea9ef2527a5f1688e872b07a27dedc349cddd43989",
    "createdAt": "2026-10-08T11:44:32.137Z",
    "updatedAt": "2026-10-08T11:45:01.202Z"
  }
}
```

| Field | Description |
| - | - |
| `status` | `BUILT`, `SUBMITTED`, `PENDING_SOURCE`, `ATTESTING`, `DELAYED`, `FORWARDING`, `COMPLETED`, `FAILED`, `ABANDONED` or `STALLED`. `COMPLETED`, `FAILED` and `STALLED` are final. See [Transfer statuses](../../../integrations/Trading/cctp#transfer-statuses). |
| `failureReason` | Set when `FAILED`: `SOURCE_REVERTED`, `TX_MISMATCH`, `USER_OP_NOT_FOUND` or `SOURCE_TX_NOT_FOUND` (the Solana signature didn't appear on chain within 30 minutes). |
| `delayReason` | Circle's reason while the transfer is `DELAYED`. |
| `amountOut` | What the recipient receives: `amount` minus `fees.feeExecuted` once known, otherwise minus `fees.maxFee`. |
| `fees.feeExecuted` | The fee Circle actually charged, once known. |
| `fees.custodian` | Your fee for the transfer, paid on top of `amount`. |
| `totalDebit` | `amount + fees.custodian`, the USDC that leaves the wallet. |
| `sourceTxHash`, `userOpHash`, `destinationTxHash` | Present once known. For Account Abstraction clients on an EVM source, `sourceTxHash` appears once the user operation is included on chain. On Solana, these are base58 signatures. |
| `sourceExplorerUrl`, `destinationExplorerUrl` | Block explorer links for the hashes. Solana links go to Solscan. |
| `estimatedCompletionAt` | When the transfer should complete. Only present until the status is final. |
| `pollAfterMs` | How long to wait before reading again. Only present until the status is final. |

## 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](/api-reference/cctp/list-cctp-transfers).

```bash theme={null}
curl --request GET \
  --url 'https://api.portalhq.io/api/v3/clients/me/integrations/cctp/transfers?limit=3' \
  --header 'Authorization: Bearer [clientApiKey|clientSessionToken]'
```

| Query parameter | Description |
| - | - |
| `status` | Only return transfers with this status. |
| `limit` | Page size, from 1 to 100. Defaults to 20. |
| `cursor` | The `nextCursor` from the previous page. |

The response, trimmed to the first transfer:

```json theme={null}
{
  "data": {
    "transfers": [
      {
        "id": "cmuzn19ul008wt5mjgok1hro4",
        "status": "COMPLETED",
        "sourceChain": "eip155:84532",
        "destinationChain": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
        "amount": "0.33",
        "amountOut": "0.025447",
        "sender": "0xa7f0c09f250b7f9cc73068c02ceb296a013c0c0c",
        "recipient": "6ZrzkiyuxiNN3LVH6donXfgJkoB7eREcbytMwd9obbnu",
        "speed": "fast",
        "fees": { "maxFee": "0.304553", "custodian": "0", "feeExecuted": "0.304553" },
        "totalDebit": "0.33",
        "sourceTxHash": "0x09d4579d4d9d7239250e54bb8737eed07d8e63c49886353b42c90b53917057e1",
        "destinationTxHash": "rv4Gx3ts8BHi3TSW6NSRoWRheKHGLEagSkMtET3zV4ubgXAEaE6x5igPgiF8ueG8rkwfp6ePwBr9QTsg5XBtbnu",
        "sourceExplorerUrl": "https://sepolia.basescan.org/tx/0x09d4579d4d9d7239250e54bb8737eed07d8e63c49886353b42c90b53917057e1",
        "destinationExplorerUrl": "https://solscan.io/tx/rv4Gx3ts8BHi3TSW6NSRoWRheKHGLEagSkMtET3zV4ubgXAEaE6x5igPgiF8ueG8rkwfp6ePwBr9QTsg5XBtbnu?cluster=devnet",
        "createdAt": "2026-10-08T14:34:46.941Z",
        "updatedAt": "2026-10-08T14:35:13.193Z"
      }
    ],
    "nextCursor": "cmuzgzaj405hy41rgx2gai5e2"
  }
}
```

`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:

```json theme={null}
{
  "error": "Enable the CCTP integration on the Portal Dashboard to use this endpoint",
  "id": "CCTP_NOT_ENABLED"
}
```

| Status | `id` | When |
| - | - | - |
| `400` | | The request is malformed: a missing or unknown field, an invalid chain id, amount, address or hash, an invalid `Idempotency-Key`, or the wrong hash field for the client type or source chain on submit (for example a `0x` hash for a Solana source). |
| `400` | `CCTP_NOT_ENABLED` | The integration isn't enabled for this environment. |
| `400` | `INVALID_AMOUNT` | The body has both or neither of `amount` and `amountOut`. |
| `400` | `CCTP_PREFLIGHT_FAILED` | A preflight check failed on `POST /transfers`. See `details.failed`, `details.checks` and `details.minAmount`. |
| `403` | `POLICY_VIOLATION` | A transaction policy blocked the user operation (Account Abstraction clients). See `details.policyViolation`. |
| `404` | | The transfer doesn't exist or belongs to another client, or the client has no wallet. A client that lacks the wallet a route needs (for example a Solana wallet) gets a failed `ROUTE_SUPPORTED` check instead. |
| `409` | `IDEMPOTENCY_KEY_REUSED` | The `Idempotency-Key` was used with a different body. |
| `409` | `CCTP_REBUILD_TOO_SOON` | The transfer was built less than 2 minutes ago and its burn may still be pending. Retry after `details.retryAfterSeconds`, or submit the hash if it was broadcast. |
| `409` | `CCTP_REBUILD_UNAVAILABLE` | Portal can't confirm the transfer's earlier burn didn't land, so it won't build another. Submit the burn's hash if it was broadcast, or create a new transfer. |
| `409` | `ALREADY_SUBMITTED` | The transfer was submitted with a different hash, or the hash belongs to another transfer. |
| `503` | `CCTP_UPSTREAM_UNAVAILABLE` | Circle's API is temporarily unavailable. Retry after `details.retryAfterSeconds`. Portal never quotes with stale fees. |

## Example flow

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

```bash theme={null}
API='https://api.portalhq.io/api/v3/clients/me/integrations/cctp'
AUTH='Authorization: Bearer [clientApiKey|clientSessionToken]'

# 1. Create the transfer
TRANSFER=$(curl --silent --request POST \
  --url "$API/transfers" \
  --header "$AUTH" \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: order-8412-cctp' \
  --data '{
  "sourceChain": "eip155:84532",
  "destinationChain": "eip155:43113",
  "amount": "0.09"
}')
TRANSFER_ID=$(echo "$TRANSFER" | jq -r '.data.transferId')

# 2. Sign each entry in .data.transactions in order with /v1/sign and
#    eth_sendTransaction (Step 4a), waiting for each to be mined with status 0x1
#    before the next. Then paste the burn's transaction hash.
read -r -p 'Burn transaction hash: ' BURN_TX_HASH

# 3. Submit the burn hash
curl --silent --request POST \
  --url "$API/transfers/$TRANSFER_ID/submit" \
  --header "$AUTH" \
  --header 'Content-Type: application/json' \
  --data "{\"txHash\": \"$BURN_TX_HASH\"}"

# 4. Poll until the status is final
while true; do
  STATUS_RESPONSE=$(curl --silent --url "$API/transfers/$TRANSFER_ID" --header "$AUTH")
  STATUS=$(echo "$STATUS_RESPONSE" | jq -r '.data.status')
  echo "$STATUS"
  case "$STATUS" in COMPLETED|FAILED|STALLED) break ;; esac
  sleep $(( $(echo "$STATUS_RESPONSE" | jq -r '.data.pollAfterMs // 2000') / 1000 ))
done
```

## Next steps

* Read about fees, recipients, preflight checks and statuses on the [CCTP integration](../../../integrations/Trading/cctp) page
* Learn about [signing Ethereum transactions](./sign-ethereum-transactions) and user operations, and [signing Solana transactions](./sign-solana-transactions)
* Check out the [Client API CCTP endpoints](/api-reference/cctp/create-a-cctp-transfer)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.