Skip to main content

Overview

Turnkey provides two primary ways to sign and broadcast transactions:
  1. Using the React handler (handleSendTransaction) from @turnkey/sdk-react-wallet-kit
    This gives you:
    • modals
    • spinner + chain logo
    • success screen
    • explorer link
    • built-in polling
  2. Using low-level functions in @turnkey/sdk-core
    You manually call:
    • signAndSendTransaction → submit
    • pollTransactionStatus → wait for inclusion
This page shows both flows with full code included.

1. Using handleSendTransaction (React)

This handler wraps everything: intent creation, signing, Turnkey submission, polling, modal UX, and final success UI.

Step 1 — Configure the Provider

import { TurnkeyProvider } from "@turnkey/sdk-react-wallet-kit";

const turnkeyConfig = {
  apiBaseUrl: "https://api.turnkey.com",
  defaultOrganizationId: process.env.NEXT_PUBLIC_TURNKEY_ORG_ID,
  rpId: window.location.hostname,
  iframeUrl: "https://auth.turnkey.com",
};

export default function App({ children }) {
  return (
    <TurnkeyProvider config={turnkeyConfig}>
      {children}
    </TurnkeyProvider>
  );
}

Step 2 — Use handleSendTransaction inside your UI

const { handleSendTransaction, wallets } = useTurnkey();

const walletAccount = wallets[0].accounts[0];

await handleSendTransaction({
  from: walletAccount.address,
  to: "0xRecipient",
  value: "1000000000000000",
  data: "0x",
  caip2: "eip155:8453",
  sponsor: true,
});
This automatically:
  • opens Turnkey modal
  • shows chain logo
  • polls until INCLUDED
  • displays success page + explorer link

2. Using @turnkey/sdk-core directly (non-React)

For custom frameworks, Node.js servers, or full manual control. You will call:

signAndSendTransaction(params)

→ returns { sendTransactionStatusId }

pollTransactionStatus(params)

→ returns { txHash, status }

Step 1 — Create a client

import { Turnkey } from "@turnkey/sdk-core";

const client = new Turnkey({
  apiBaseUrl: "https://api.turnkey.com",
  defaultOrganizationId: process.env.TURNKEY_ORG_ID,
});

Step 2 — Submit the transaction

const sendTransactionStatusId  = await client.signAndSendTransaction({
  walletAccount,
  from: walletAccount.address,
  to: "0xRecipient",
  caip2: "eip155:8453",
  sponsor: true,
  value: "0",
  data: "0x",
  nonce: "0",
});

Step 3 — Wait for inclusion

const pollResult = await client?.pollTransactionStatus({
  sendTransactionStatusId,
});

if (!pollResult) {
  throw new TurnkeyError(
    "Polling returned no result",
    TurnkeyErrorCodes.SIGN_AND_SEND_TRANSACTION_ERROR,
  );
}

const { eth } = pollResult;
const txHash = eth?.txHash;
if (!txHash) {
  throw new TurnkeyError(
    "Missing txHash in transaction result",
    TurnkeyErrorCodes.SIGN_AND_SEND_TRANSACTION_ERROR,
  );
}
console.log(txHash)

Step 4 (Optional) - Check Gas usage for an end user

There is a customer configurable gas limit for both sub-orgs and all orgs. We reccomend checking sub-org gas usage against the limit client side so your application can handle edge cases of getting close to or exceeding the gas limit. You may also want to consider monitoring the all org gas usage regularly to see if you are getting close to your gas limit.
  const resp = await httpClient?.getGasUsage({})
  if (resp?.usageUsd! > resp?.windowLimitUsd!) { // you can also configure this to be a threshold
    console.error("Gas usage limit exceeded for sponsored transactions");
    return
  }

3. Core Code (full implementations)

signAndSendTransaction — from @turnkey/sdk-core

/**
 * Signs and submits an Ethereum transaction using Turnkey.
 *
 * Behavior:
 * - Constructs transaction intent (sponsored or EIP-1559)
 * - Submits via Turnkey
 * - Returns { sendTransactionStatusId }
 *
 * Does NOT poll — caller must poll via pollTransactionStatus.
 */
signAndSendTransaction = async (
  params: SignAndSendTransactionParams
): Promise<SignAndSendResult> => {
  const {
    organizationId,
    from,
    to,
    caip2,
    sponsor,
    value,
    data,
    nonce,
    gasLimit,
    maxFeePerGas,
    maxPriorityFeePerGas,
    walletAccount,
  } = params;

  return withTurnkeyErrorHandling(
    async () => {
      const intent = {
        from,
        to,
        caip2,
        ...(value ? { value } : {}),
        ...(data ? { data } : {}),
      };

      if (sponsor) {
        intent["sponsor"] = true;
      } else {
        if (nonce) intent["nonce"] = nonce;
        if (gasLimit) intent["gasLimit"] = gasLimit;
        if (maxFeePerGas) intent["maxFeePerGas"] = maxFeePerGas;
        if (maxPriorityFeePerGas)
          intent["maxPriorityFeePerGas"] = maxPriorityFeePerGas;
      }

      const resp = await this.httpClient.ethSendTransaction({
        ...intent,
        ...(organizationId && { organizationId }),
      });

      return { sendTransactionStatusId: resp.sendTransactionStatusId };
    },
    {
      errorMessage: "Failed to submit transaction",
      errorCode: TurnkeyErrorCodes.SIGN_AND_SEND_TRANSACTION_ERROR,
    }
  );
};

pollTransactionStatus — from @turnkey/sdk-core

/**
 * Polls Turnkey until a transaction reaches a terminal state.
 *
 * Terminal states:
 * - COMPLETED / INCLUDED → resolves { txHash, status }
 * - FAILED / CANCELLED → rejects
 */
pollTransactionStatus({
  httpClient,
  organizationId,
  sendTransactionStatusId,
}: PollTransactionStatusParams): Promise<{
  txHash: string;
  status: string;
}> {
  return withTurnkeyErrorHandling(
    async () => {
      return new Promise((resolve, reject) => {
        const ref = setInterval(async () => {
          try {
            const resp = await httpClient.getSendTransactionStatus({
              organizationId,
              sendTransactionStatusId,
            });

            const status = resp?.txStatus;
            const txHash = resp?.eth?.txHash;
            const txError = resp?.txError;

            if (!status) return;

            if (txError || status === "FAILED" || status === "CANCELLED") {
              clearInterval(ref);
              reject(txError || `Transaction ${status}`);
              return;
            }

            if (status === "COMPLETED" || status === "INCLUDED") {
              clearInterval(ref);
              resolve({ txHash: txHash!, status });
            }
          } catch (e) {
            console.warn("polling error:", e);
          }
        }, 500);
      });
    },
    {
      errorMessage: "Failed to poll transaction status",
      errorCode: TurnkeyErrorCodes.SIGN_AND_SEND_TRANSACTION_ERROR,
    }
  );
}

4. Full React Handler (uses the above two functions)

const handleSendTransaction = useCallback(
  async (params: HandleSendTransactionParams): Promise<void> => {
    const s = await getSession();
    const organizationId = params.organizationId || s?.organizationId;

    const {
      from,
      to,
      value,
      data,
      caip2,
      sponsor,
      gasLimit,
      maxFeePerGas,
      maxPriorityFeePerGas,
      nonce: providedNonce,
      successPageDuration = 2000,
    } = params;

    const { nonce } = await generateNonces({
      from: from,
      rpcUrl: DEFAULT_RPC_BY_CHAIN[caip2],
      providedNonce,
    });

    return new Promise((resolve, reject) => {
      const SendTxContainer = () => {
        const cleanedData =
          data && data !== "0x" && data !== "" ? data : undefined;

        const action = async () => {
          const { sendTransactionStatusId } =
            await client.signAndSendTransaction({
              walletAccount,
              organizationId,
              from,
              to,
              caip2,
              sponsor: !!sponsor,
              value,
              data: cleanedData,
              nonce,
              gasLimit,
              maxFeePerGas,
              maxPriorityFeePerGas,
            });

          const { txHash } = await client.pollTransactionStatus({
            httpClient: client.httpClient,
            organizationId,
            sendTransactionStatusId,
          });

          return { txHash };
        };

        return (
          <SendTransactionPage
            icon={<img src={getChainLogo(caip2)} className="h-10 w-10" />}
            action={action}
            caip2={caip2}
            successPageDuration={successPageDuration}
            onSuccess={() => resolve()}
            onError={(err) => reject(err)}
          />
        );
      };

      pushPage({
        key: "Send Transaction",
        content: <SendTxContainer />,
        preventBack: true,
        showTitle: false,
      });
    });
  },
  [pushPage, client]
);