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

# Idempotency keys

> Retry a transaction send safely: while Portal remembers an idempotency key, it broadcasts that key at most once.

When a send times out or the connection drops, you can't tell whether the transaction reached the chain. Retrying might send it twice, and not retrying might lose it. Starting in `4.2.0`, you can attach an **idempotency key** to a send. Portal broadcasts a key at most once while it [remembers the key](#how-long-portal-remembers-a-key) (at least 24 hours), so a retry with the same key within that time cannot send the same transaction twice.

Idempotency keys are optional. Requests without one behave exactly as before. The SDK never generates a key or retries a request on its own.

## Supported requests

| Entry point | Where to pass the key | Supported on |
| - | - | - |
| `portal.request(...)` and `portal.provider.request(...)` | `idempotencyKey` in the request object | `eth_sendTransaction`, `sol_signAndSendTransaction`, `sol_signAndConfirmTransaction` |
| `portal.sendAsset(chain, params)` | `params.idempotencyKey` | EVM (`eip155`) and Solana chains |

The SDK sends the key to Portal in the `Idempotency-Key` header, which is exported as `PORTAL_IDEMPOTENCY_KEY_HEADER`.

> [!NOTE]
>
> EIP-1193 libraries that wrap `portal.provider`, such as ethers.js, call `request({ method, params })` and can't pass a key. Send a transaction that needs a key with `portal.request(...)` or `portal.provider.request(...)` directly.

> [!WARNING]
>
> Keys only reach Portal through version `4.2.0` or later of the Portal iframe. The SDK loads the iframe for its own version from `host`, so with the default host, or a [custom subdomain](https://docs.portalhq.io/sdks/web/guide/configure-a-custom-subdomain.md) that points to Portal, the iframe matches the SDK. If you serve or proxy the iframe yourself, don't serve an older version: an older iframe drops the key without an error, and the transaction is sent without idempotency protection.

## Generate a key

```typescript theme={null}
function generateIdempotencyKey(): string
```

`generateIdempotencyKey()` returns a lowercase UUID v4. Generate one key per operation, such as one transfer or one payment, and store it with the pending operation before you send. A retry, even one after the page reloads, can then reuse it.

You can also use your own key, for example an ID from your backend. The SDK trims surrounding whitespace, and the key must then follow these rules:

| Rule | Detail |
| - | - |
| Length | 1–255 characters |
| Allowed characters | `A-Z`, `a-z`, `0-9`, `-`, `.`, `_`, `~` |

A key that breaks these rules throws a `PortalIdempotencyError` with `reason: 'invalidKey'` before the user is asked to approve the request, so nothing is signed or sent. The SDK checks the key even on a method that would otherwise ignore it, so a malformed key still throws there. The error message names the rule that failed and never contains the key. `eth_sendRawTransaction` and `sol_sendTransaction` don't check the key: they reject any key with `reason: 'unsupportedTarget'`.

## Send a transaction with a key

Build the transaction once, then send it with the key:

```typescript theme={null}
import { generateIdempotencyKey } from '@portal-hq/web'

const chainId = 'eip155:11155111' // Ethereum Sepolia

// Build the transaction once. A retry sends this same transaction object.
const { transaction } = await portal.buildTransaction(
  chainId,
  '0xDestinationAddress',
  'NATIVE',
  '0.0001',
)

// One key per transfer. Store it with the pending transfer.
const idempotencyKey = generateIdempotencyKey()

const txHash = await portal.request({
  chainId,
  method: 'eth_sendTransaction',
  params: [transaction],
  signatureApprovalMemo: 'Send ETH',
  idempotencyKey,
})

console.log(`✅ Transaction hash: ${txHash}`)
```

On Solana, build the transaction with `portal.buildTransaction` for your Solana chain ID and send it with `sol_signAndSendTransaction` or `sol_signAndConfirmTransaction` the same way.

If the user doesn't approve the request, `portal.request(...)` resolves with `undefined` and nothing reaches Portal, so the key is not recorded and you can use it again for the same transfer.

## Retry with the same key

If a send fails and you don't know whether the transaction went out, for example after a timeout, call `portal.request(...)` again with the **same transaction object and the same key**. One of two things happens:

* The first attempt never reached Portal, so Portal has no record of the key, and the retry sends the transaction.
* Portal already recorded the key, whether that attempt is still in progress, was broadcast or failed, and it rejects the retry with a `PortalMpcError` instead of broadcasting again.

Portal recognizes a retry only when its `chainId`, `method` and `params` match the first request. The SDK sends `params` as a JSON string, so the same fields in a different order count as a different request, and Portal rejects the retry with `IDEMPOTENCY_KEY_REUSED`. Keep the transaction object you built and send it again, rather than rebuilding it.

Reuse a key only for the identical request. If anything changes, such as the amount, the recipient, the chain or the method, generate a new key.

### Handle a rejection

Two helpers identify idempotency rejections. Both take the caught value as `unknown` and match on its `id`, never on the message or `code`:

| Helper | Returns `true` when |
| - | - |
| `isIdempotencyRejection(error)` | `error.id` is any of the idempotency error IDs below. |
| `isIdempotencyKeyReused(error)` | `error.id` is `IDEMPOTENCY_KEY_REUSED`. |

Branch on `id` to decide what to do next. Each ID is also available as a constant on `PortalIdempotencyErrorId`:

| Error ID | `PortalIdempotencyErrorId` | What it means | What to do |
| - | - | - | - |
| `IDEMPOTENT_REQUEST_IN_PROGRESS` | `RequestInProgress` | A request with this key is still being processed. | Wait, then retry with the same key. |
| `IDEMPOTENT_REQUEST_ALREADY_COMPLETED` | `RequestAlreadyCompleted` | The transaction was broadcast. | Don't send it again. Look the transaction up on-chain. |
| `IDEMPOTENT_REQUEST_PREVIOUSLY_FAILED` | `RequestPreviouslyFailed` | The earlier request failed, or its outcome is unknown. Portal returns this ID in both cases, so it doesn't confirm that the transaction was not broadcast. | Check the chain before sending again with a new key. |
| `IDEMPOTENT_REQUEST_UNEXPECTED_STATE` | `RequestUnexpectedState` | Portal can't confirm the outcome of the earlier request. | Check the chain before sending again with a new key. |
| `IDEMPOTENT_TX_MISSING` | `TxMissing` | Portal can't find the request recorded for this key. | Check the chain before sending again with a new key. |
| `IDEMPOTENCY_KEY_REUSED` | `KeyReused` | The key was already used for a request with a different payload. This doesn't tell you the state of that earlier request. | Check the chain before sending again with a new key. |

```typescript theme={null}
import {
  PortalIdempotencyErrorId,
  PortalMpcError,
  isIdempotencyRejection,
} from '@portal-hq/web'

try {
  const txHash = await portal.request({
    chainId,
    method: 'eth_sendTransaction',
    params: [transaction], // The transaction built for this transfer
    idempotencyKey, // The key stored with this transfer
  })

  console.log(`✅ Transaction hash: ${txHash}`)
} catch (error) {
  if (isIdempotencyRejection(error)) {
    const { id } = error as PortalMpcError

    switch (id) {
      case PortalIdempotencyErrorId.RequestInProgress:
        console.log('⏳ Still processing. Retry later with the same key.')
        break
      case PortalIdempotencyErrorId.RequestAlreadyCompleted:
        console.log('✅ Already broadcast. Look the transaction up instead of sending it again.')
        break
      default:
        console.log(`⚠️ Outcome unconfirmed (${id}). Check the chain before sending again with a new key.`)
    }
  } else {
    // Any other failure. Retrying with the same key can't broadcast the transaction twice,
    // but it only helps when the failure may be transient, such as a timeout.
    console.error('❌ Send failed:', error)
  }
}
```

> [!WARNING]
>
> No rejection returns the original transaction hash. Before you send an operation again under a **new** key, check the chain or your transaction history to confirm the first attempt didn't land. Portal treats a new key as a new transaction.

> [!NOTE]
>
> The rejection's HTTP status (`409` or `422`) is not set on the `PortalMpcError`. Match rejections on `id`.

## Retry `sendAsset`

Pass the key in the `sendAsset` parameters:

```typescript theme={null}
import { generateIdempotencyKey } from '@portal-hq/web'

// One key per transfer. Store it with the pending transfer.
const idempotencyKey = generateIdempotencyKey()

const txHash = await portal.sendAsset('eip155:11155111', {
  to: '0xDestinationAddress',
  amount: '0.0001',
  token: 'NATIVE',
  signatureApprovalMemo: 'Send ETH',
  idempotencyKey,
})

console.log(`✅ Transaction hash: ${txHash}`)
```

`sendAsset` builds the transaction again on every call, and Portal recognizes a retry only when the rebuilt transaction matches the first one:

* **EVM:** the built transaction carries no nonce or gas, so with the same inputs it normally matches. A retry gets the status of the first request, as described in [Handle a rejection](#handle-a-rejection).
* **Solana:** each build carries a new recent blockhash, so a retry with the same key is normally rejected with `IDEMPOTENCY_KEY_REUSED`, whatever happened to the first attempt.

Either way, while Portal remembers the key, the transfer is never broadcast twice under it. Before you retry with a new key, wait until the first transfer can no longer land, then check the chain:

* **Solana:** wait until the first transfer is confirmed or its recent blockhash has expired.
* **EVM:** wait until the first transfer is confirmed or the account nonce has moved past it.

> [!TIP]
>
> For the most predictable retries, especially on Solana, build the transaction once and send it with `portal.request(...)` as shown in [Send a transaction with a key](#send-a-transaction-with-a-key). Every retry then gets the status of the first request, including `IDEMPOTENT_REQUEST_IN_PROGRESS`.

## Requests that don't take a key

| Request | With a key |
| - | - |
| `eth_sendRawTransaction`, and `sol_sendTransaction` (method `'sendTransaction'`) | Throws a `PortalIdempotencyError` with `reason: 'unsupportedTarget'` before anything is sent. These methods broadcast a transaction that is already signed through a plain RPC call, which Portal can't deduplicate. |
| `sendAsset` on a TRON chain | Throws a `PortalIdempotencyError` with `reason: 'unsupportedTarget'` before the transaction is built. Send TRON transfers without a key. |
| `tron_sendTransaction` | The SDK drops the key and logs a warning, so the transfer is sent without idempotency protection. |
| Any other method, such as `eth_signTransaction` or `personal_sign` | The SDK drops the key and logs a warning. |

Warnings are only printed when `logLevel` is `'warn'` or more verbose. See [Logging](https://docs.portalhq.io/sdks/web/guide/logging.md).

## How long Portal remembers a key

Portal remembers a key for at least 24 hours after the request was last updated, then removes it. Once the key is removed, the same key is treated as a new request. Because the exact removal time isn't fixed, check the chain before you retry an operation that is older than 24 hours, whatever key you use.

## Presignatures

If you enable presignatures ([`usePresignatures`](./feature-flags#usepresignatures)), the SDK also sends the key when it signs an EVM transaction with a presignature, with the same `params` as a sign without one. A retry therefore matches the first request whichever way the SDK signs it. When no presignature is available, the SDK signs normally and still sends the key. When a presignature is available but the sign with it fails, the Web SDK doesn't fall back to a normal sign, so the failed attempt is never sent again automatically. Retry it yourself with the same key.

## Next Steps

* Review the [idempotency API reference](https://docs.portalhq.io/sdks/web/reference.md#idempotency)
* Learn about [signing transactions](https://docs.portalhq.io/sdks/web/guide/sign-a-transaction.md)
* Read [Send tokens](https://docs.portalhq.io/sdks/web/guide/send-tokens.md) for the full `sendAsset` flow
* See [Idempotency keys](https://docs.portalhq.io/resources/idempotency-keys.md) for the Enclave MPC API


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