> ## 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 from SDK version 5.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, and apart from the [presignature fallback](#presignatures) it never retries a request on its own.

## Supported requests

| Entry point | Where to pass the key | Supported on |
| - | - | - |
| `portal.request(method, params, chainId, options)` | `options.idempotencyKey` ([`RequestOptions`](../reference/core#request)) | `PortalRequestMethod.EthSendTransaction`, `PortalRequestMethod.SolSignAndSendTransaction`, `PortalRequestMethod.SolSignAndConfirmTransaction` |
| `portal.sendAsset(params, chain)` | `params.idempotencyKey` ([`SendAssetParams`](../reference/core#sendassetparams)) | EVM (`eip155`) and Solana chains |

Portal enforces the key whether the transaction is signed on the device or through the Enclave MPC API ([`useEnclaveMPCApi`](./feature-flags#useenclavempcapi)).

See [Requests that don't take a key](#requests-that-don’t-take-a-key) for everything else.

## Import the helpers

The idempotency helpers are exported by `@portal-hq/utils`, which `@portal-hq/core` depends on. `@portal-hq/core` doesn't re-export them.

```typescript theme={null}
import {
  generateIdempotencyKey,
  isIdempotencyKeyReused,
  isIdempotencyRejection,
  PortalIdempotencyError,
  PortalIdempotencyErrorId,
} from '@portal-hq/utils'
```

If your package manager only lets you import packages listed in your app's `package.json`, add `@portal-hq/utils` at the same version as `@portal-hq/core`.

## Generate a key

```typescript theme={null}
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 app restarts, can then reuse it.

> [!WARNING]
>
> `generateIdempotencyKey()` uses `crypto.randomUUID` or `crypto.getRandomValues` when the JavaScript runtime provides one. Hermes provides neither, so without a polyfill the key is built from `Math.random`, which keeps keys unique in practice but is not a cryptographic source. Install a polyfill such as [`react-native-get-random-values`](https://github.com/LinusU/react-native-get-random-values) and import it in your app's entry file before you generate keys.

You can also use your own key, for example an ID from your backend. The key must be a string. 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` set to `'invalidKey'` before the approval step, so nothing is signed or sent. The error message names the rule that failed and never contains the key.

Check for this error with `PortalIdempotencyError.is(error)`, not `instanceof`. The error is identified by a cross-copy brand, so `instanceof` returns `false` when your dependency tree resolves two copies of `@portal-hq/utils`.

## Send a transaction with a key

Build the transaction once, then send it with the key. Keep the transaction object together with the key, because a retry must send the same transaction:

```typescript theme={null}
import { Portal, PortalRequestMethod } from '@portal-hq/core'
import { generateIdempotencyKey } from '@portal-hq/utils'

const sendWithIdempotencyKey = async (portal: Portal, toAddress: string) => {
  const chainId = 'eip155:11155111' // Ethereum Sepolia

  // Build the transaction once
  const { transaction } = await portal.api.buildTransaction(
    toAddress, // recipient address
    'NATIVE', // token
    '0.0001', // amount
    chainId,
  )

  // One key per transfer. Store it with the transaction before you send.
  const idempotencyKey = generateIdempotencyKey()

  const txHash = await portal.request(
    PortalRequestMethod.EthSendTransaction,
    [transaction],
    chainId,
    { idempotencyKey },
  )

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

The same applies on Solana with `PortalRequestMethod.SolSignAndSendTransaction` or `PortalRequestMethod.SolSignAndConfirmTransaction` and a `solana:` chain ID. You can combine `idempotencyKey` with the other [`RequestOptions`](../reference/core#request), such as `signatureApprovalMemo`.

## 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 method, chain ID, transaction object and 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 instead of broadcasting again.

Portal recognizes a retry only when its `chainId`, `method` and `params` match the first request's, and it compares `params` as the JSON string the SDK sends. Retry with the same transaction object: the same fields in a different order serialize differently, and the retry can be rejected with `IDEMPOTENCY_KEY_REUSED`.

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

Portal's rejections are thrown as a `PortalMpcError` whose `id` is one of the IDs below. Use these helpers from `@portal-hq/utils` to recognize them:

| Helper | Returns `true` when |
| - | - |
| `isIdempotencyRejection(error)` | The error's `id` is any of the IDs below. |
| `isIdempotencyKeyReused(error)` | The error's `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 { Portal, PortalRequestMethod } from '@portal-hq/core'
import {
  isIdempotencyRejection,
  PortalIdempotencyErrorId,
  type PortalMpcError,
} from '@portal-hq/utils'

const sendOrRetry = async (
  portal: Portal,
  chainId: string,
  transaction: unknown, // The transaction object stored with the key
  idempotencyKey: string, // The key stored with this transfer
) => {
  try {
    const txHash = await portal.request(
      PortalRequestMethod.EthSendTransaction,
      [transaction],
      chainId,
      { idempotencyKey },
    )

    // A rejected signing request resolves without a hash instead of throwing.
    if (!txHash) {
      console.log('🚫 Signing request rejected. Nothing was sent.')
      return
    }

    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.`)
      }
      return
    }

    // A PortalIdempotencyError is thrown before anything is signed or sent:
    // fix the key or the method instead of retrying. After a transient failure,
    // such as a timeout or a dropped connection, the broadcast outcome is
    // unknown: retry with the same transaction and key, not a new key.
    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]
>
> Match rejections with `isIdempotencyRejection()` or on `id`, not on the error message or `code`.

## Retry `sendAsset`

Pass the key in `SendAssetParams`:

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

const sendTokensWithIdempotencyKey = async (portal: Portal) => {
  // One key per transfer. Store it with the pending transfer.
  const idempotencyKey = generateIdempotencyKey()

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

  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 build carries no nonce or gas, so with unchanged inputs the rebuilt transaction 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 usually rejected with `IDEMPOTENCY_KEY_REUSED`, which doesn't tell you whether the first transfer is in progress, completed or failed.

Either way, while Portal remembers the key, the transfer is never broadcast twice under it. Before you send the transfer again under a new key, wait until the first one can no longer land, then check the chain to confirm it didn't:

* **Solana:** wait until the first transfer is confirmed or its recent blockhash has expired, usually within about 60–90 seconds after the transaction was built.
* **EVM:** wait until the first transfer is confirmed or the account's nonce has moved past it.

> [!TIP]
>
> For the most predictable retries, especially on Solana, build the transaction once with `portal.api.buildTransaction` 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 |
| - | - |
| `PortalRequestMethod.EthSendRawTransaction` (`eth_sendRawTransaction`) and `PortalRequestMethod.SolSendTransaction` (`sendTransaction`) | Throws a `PortalIdempotencyError` with `reason` set to `'unsupportedTarget'` before anything is sent, whatever the key's format. These methods broadcast a transaction that is already signed through a plain RPC call, which Portal can't deduplicate. |
| `sendAsset` on a Bitcoin chain (`bip122`) | Throws a `PortalIdempotencyError` with `reason` set to `'unsupportedTarget'` before the transaction is built. The SDK raw-signs and broadcasts Bitcoin transactions itself. |
| `portal.rawSign` | `RawSignOptions` has no `idempotencyKey`. Raw signatures never carry a key. |
| Any other method, such as `eth_signTransaction`, `personal_sign` or `PortalRequestMethod.RawSign` | The SDK checks the key's format, then ignores the key and logs a warning. |

The [Enclave MPC API](https://docs.portalhq.io/resources/idempotency-keys.md#broadcast-rpc-methods) also accepts keys for Tron and Stellar broadcasts. The React Native SDK doesn't take a key for them: `sendAsset` doesn't support Tron or Stellar chains, and a key passed to `portal.request` with any method not listed under [Supported requests](#supported-requests) is ignored with a warning.

## 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) in `featureFlags`), the presignature attempt carries the same key as the request. Presignatures apply only to SECP256K1 signing, so this affects EVM sends.

* If Portal rejects the presignature attempt because of its key, the SDK throws that rejection and doesn't fall back to a normal sign.
* If the presignature attempt fails for another reason, the SDK falls back to one normal sign with the same key and request. If the failed attempt already reached Portal, Portal can reject the fallback, for example with `IDEMPOTENT_REQUEST_IN_PROGRESS`, where a request without a key would have been signed. The SDK then logs a warning that the presignature attempt may already have used the key.

Handle either case like any other [rejection](#handle-a-rejection).

## Next Steps

* Learn about [signing transactions](https://docs.portalhq.io/sdks/react-native/guide/sign-a-transaction.md)
* Read [Send tokens](https://docs.portalhq.io/sdks/react-native/guide/send-tokens.md) for the full `sendAsset` flow
* Review [`RequestOptions`](../reference/core#request) and [`SendAssetParams`](../reference/core#sendassetparams) in the `@portal-hq/core` reference
* 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.