> For the complete documentation index, see [llms.txt](https://help.multisig.ledger.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://help.multisig.ledger.com/guides/api-guides/2.-transaction-lifecycle-including-off-chain-signatures.md).

# 2. Transaction Lifecycle (including off-chain signatures)

## What you'll learn

* Initialize both the Protocol Kit (transaction creation/signing) and the API Kit (proposal/indexing)
* Build and sign an ETH transfer as a Safe transaction
* Propose the signed transaction to the Ledger Multisig Transaction Service
* Execute the transaction on-chain once the signature threshold is met
* Verify execution status through the API

## Prerequisites

* **Node.js 18+**
* **A Safe on Sepolia** (or another supported chain) where you control at least one owner key
* **A private key** for a Safe owner: use a **testnet-only** key
* **Sepolia ETH** in the Safe for gas and the transfer amount
* **viem**: used for receipt verification

Install the SDK:

```bash
npm install @safe-global/api-kit @safe-global/protocol-kit @safe-global/types-kit viem
```

## Configuration

{% tabs %}
{% tab title="TypeScript" %}

```typescript
import SafeApiKitModule from "@safe-global/api-kit";
import SafeModule from "@safe-global/protocol-kit";
import { OperationType } from "@safe-global/types-kit";
import type { MetaTransactionData } from "@safe-global/types-kit";
import { createPublicClient, http } from "viem";
import { sepolia } from "viem/chains";

// ESM interop-safe constructor resolution (required in some runtimes)
const SafeApiKit =
  typeof SafeApiKitModule === "function"
    ? SafeApiKitModule
    : (SafeApiKitModule as unknown as { default: typeof SafeApiKitModule }).default;
const Safe =
  typeof SafeModule === "function"
    ? SafeModule
    : (SafeModule as unknown as { default: typeof SafeModule }).default;

const CHAIN_ID = 11155111n; // Sepolia
const TX_SERVICE_URL = `https://app.multisig.ledger.com/api/safe-transaction-service/${CHAIN_ID}`;
const RPC_URL = "https://ethereum-sepolia-rpc.publicnode.com";
const SAFE_ADDRESS = "0xYourSafeAddress";
const OWNER_PRIVATE_KEY = "your-private-key"; // Testnet only!
```

{% endtab %}

{% tab title="Python" %}

```python
import requests

CHAIN_ID = 11155111  # Sepolia
TX_SERVICE_URL = f"https://app.multisig.ledger.com/api/safe-transaction-service/{CHAIN_ID}"
RPC_URL = "https://ethereum-sepolia-rpc.publicnode.com"
SAFE_ADDRESS = "0xYourSafeAddress"
OWNER_PRIVATE_KEY = "your-private-key"  # Testnet only!

session = requests.Session()
session.headers.update({"accept": "application/json"})
```

{% endtab %}

{% tab title="curl" %}

```bash
export CHAIN_ID=11155111  # Sepolia
export TX_SERVICE_URL="https://app.multisig.ledger.com/api/safe-transaction-service/${CHAIN_ID}"
export RPC_URL="https://ethereum-sepolia-rpc.publicnode.com"
export SAFE_ADDRESS="0xYourSafeAddress"
export OWNER_PRIVATE_KEY="your-private-key"  # Testnet only!
```

{% endtab %}
{% endtabs %}

The `TX_SERVICE_URL` points at the **Ledger-hosted** Transaction Service. Transactions proposed here appear in the [Ledger Enterprise Multisig UI](https://app.multisig.ledger.com/).

**Supported chains:** Ethereum (1), Optimism (10), BSC (56), Polygon (137), Base (8453), Arbitrum (42161), Sepolia (11155111).

## Step-by-step

{% stepper %}
{% step %}

### Initialize the Protocol Kit and API Kit

The Protocol Kit connects to the blockchain and handles transaction creation and signing. The API Kit talks to the Transaction Service for proposal and querying.

{% tabs %}
{% tab title="TypeScript" %}

```typescript
const protocolKit = await Safe.init({
  provider: RPC_URL,
  signer: OWNER_PRIVATE_KEY,
  safeAddress: SAFE_ADDRESS,
});

const apiKit = new SafeApiKit({
  chainId: CHAIN_ID,
  txServiceUrl: TX_SERVICE_URL,
});

const signerAddress = await protocolKit.getSafeProvider().getSignerAddress();

// Verify the signer is an owner
const safeInfo = await apiKit.getSafeInfo(SAFE_ADDRESS);
const isOwner = safeInfo.owners
  .map((o) => o.toLowerCase())
  .includes(signerAddress!.toLowerCase());

if (!isOwner) {
  throw new Error(`${signerAddress} is not an owner of this Safe`);
}
```

{% endtab %}

{% tab title="Python" %}

```python
from eth_account import Account

# Derive the public address from the private key
signer_address = Account.from_key(OWNER_PRIVATE_KEY).address

# Verify the signer is an owner (via Transaction Service)
resp = session.get(f"{TX_SERVICE_URL}/v1/safes/{SAFE_ADDRESS}/")
resp.raise_for_status()
safe_info = resp.json()

owners = [o.lower() for o in safe_info["owners"]]
if signer_address.lower() not in owners:
    raise ValueError(f"{signer_address} is not an owner of this Safe")
```

{% endtab %}

{% tab title="curl" %}

```bash
# Fetch Safe config (owners, threshold, nonce, ...)
curl -sS -X GET \
  "${TX_SERVICE_URL}/v1/safes/${SAFE_ADDRESS}/" \
  -H "accept: application/json"
```

{% endtab %}
{% endtabs %}

> REST API: `GET /v1/safes/{address}/` - used internally by `getSafeInfo` to verify ownership.
> {% endstep %}

{% step %}

### Create the transaction

Define the transaction parameters and create a Safe transaction object. For a simple ETH transfer, set `data` to `"0x"` and `operation` to `Call`.

{% tabs %}
{% tab title="TypeScript" %}

```typescript
const txData: MetaTransactionData = {
  to: "0xRecipientAddress",
  value: "100000000000000", // 0.0001 ETH in wei
  data: "0x",
  operation: OperationType.Call,
};

const safeTransaction = await protocolKit.createTransaction({
  transactions: [txData],
});
```

{% endtab %}

{% tab title="Python" %}

```python
# For Python transaction building/execution, use safe-eth-py.
from safe_eth.eth import EthereumClient
from safe_eth.safe import Safe
from hexbytes import HexBytes

ethereum_client = EthereumClient(RPC_URL)
safe = Safe(SAFE_ADDRESS, ethereum_client)

safe_tx = safe.build_multisig_tx(
    to="0xRecipientAddress",
    value=100000000000000,  # 0.0001 ETH in wei
    data=HexBytes("0x"),
    operation=0,  # CALL
)
```

{% endtab %}

{% tab title="curl" %}

```bash
# Transaction creation happens client-side.
# With curl you can only submit the *final* payload to the service
# once you have a computed safeTxHash and an owner signature.
```

{% endtab %}
{% endtabs %}

The Protocol Kit automatically sets the nonce, `safeTxGas`, `baseGas`, `gasPrice`, `gasToken`, and `refundReceiver` fields based on the current Safe state.
{% endstep %}

{% step %}

### Sign the transaction

Compute the Safe transaction hash and sign it with the owner's private key. This produces an off-chain (EIP-712) signature with no gas is spent.

{% tabs %}
{% tab title="TypeScript" %}

```typescript
const safeTxHash = await protocolKit.getTransactionHash(safeTransaction);
const signature = await protocolKit.signHash(safeTxHash);
```

{% endtab %}

{% tab title="Python" %}

```python
# safe-eth-py can sign the tx after building it.
signature = safe_tx.sign(OWNER_PRIVATE_KEY)
safe_tx_hash = safe_tx.safe_tx_hash.hex()
```

{% endtab %}

{% tab title="curl" %}

```bash
# Signing is cryptographic and must happen client-side (not via the service).
# Use a local signer, HSM, or wallet SDK to sign the safeTxHash.
```

{% endtab %}
{% endtabs %}

The `safeTxHash` is the unique identifier for this transaction within the Safe. It is **not** an on-chain transaction hash.
{% endstep %}

{% step %}

### Propose the transaction

Submit the signed transaction to the Transaction Service. This stores it off-chain and makes it visible to other owners in the Ledger Multisig UI.

{% tabs %}
{% tab title="TypeScript" %}

```typescript
await apiKit.proposeTransaction({
  safeAddress: SAFE_ADDRESS,
  safeTransactionData: safeTransaction.data,
  safeTxHash,
  senderAddress: signerAddress!,
  senderSignature: signature.data,
});
```

{% endtab %}

{% tab title="Python" %}

```python
# Propose by calling the REST endpoint directly.
# The service expects the full tx fields plus:
# - contractTransactionHash (safeTxHash)
# - sender
# - signature
payload = {
    "safe": SAFE_ADDRESS,
    "to": safe_tx.to,
    "value": safe_tx.value,
    "data": safe_tx.data.hex(),
    "operation": safe_tx.operation,
    "gasToken": safe_tx.gas_token,
    "safeTxGas": safe_tx.safe_tx_gas,
    "baseGas": safe_tx.base_gas,
    "gasPrice": safe_tx.gas_price,
    "refundReceiver": safe_tx.refund_receiver,
    "nonce": safe_tx.nonce,
    "contractTransactionHash": safe_tx.safe_tx_hash.hex(),
    "sender": signer_address,
    "signature": signature.hex() if hasattr(signature, "hex") else str(signature),
}

resp = session.post(
    f"{TX_SERVICE_URL}/v1/safes/{SAFE_ADDRESS}/multisig-transactions/",
    json=payload,
)
resp.raise_for_status()
```

{% endtab %}

{% tab title="curl" %}

```bash
curl -sS -X POST \
  "${TX_SERVICE_URL}/v1/safes/${SAFE_ADDRESS}/multisig-transactions/" \
  -H "accept: application/json" \
  -H "content-type: application/json" \
  -d '{
    "safe": "'"${SAFE_ADDRESS}"'",
    "to": "0xRecipientAddress",
    "value": "100000000000000",
    "data": "0x",
    "operation": 0,
    "gasToken": "0x0000000000000000000000000000000000000000",
    "safeTxGas": "0",
    "baseGas": "0",
    "gasPrice": "0",
    "refundReceiver": "0x0000000000000000000000000000000000000000",
    "nonce": "0",
    "contractTransactionHash": "0xYourSafeTxHash",
    "sender": "0xOwnerAddress",
    "signature": "0xOwnerSignature"
  }'
```

{% endtab %}
{% endtabs %}

> REST API: `POST /v1/safes/{address}/multisig-transactions/` - the request body includes the full transaction data, the `safeTxHash`, the sender address, and the signature.

After proposing, the transaction is visible in the Ledger Enterprise Multisig UI and other owners can sign the transaction.
{% endstep %}

{% step %}

### Check confirmations and execute

Retrieve the pending transaction, check if enough signatures have been collected to meet the threshold, and execute on-chain if ready.

{% tabs %}
{% tab title="TypeScript" %}

```typescript
const pendingTx = await apiKit.getTransaction(safeTxHash);
const confirmationCount = pendingTx.confirmations?.length ?? 0;

if (confirmationCount >= pendingTx.confirmationsRequired) {
  const executionResult = await protocolKit.executeTransaction(pendingTx);
  console.log("Transaction hash:", executionResult.hash);
} else {
  console.log(
    `Need ${pendingTx.confirmationsRequired - confirmationCount} more signature(s)`
  );
}
```

{% endtab %}

{% tab title="Python" %}

```python
# Fetch the pending tx and inspect confirmations.
resp = session.get(f"{TX_SERVICE_URL}/v1/multisig-transactions/{safe_tx_hash}/")
resp.raise_for_status()
pending = resp.json()

confirmation_count = len(pending.get("confirmations") or [])
required = pending["confirmationsRequired"]

if confirmation_count < required:
    print(f"Need {required - confirmation_count} more signature(s)")
else:
    print("Threshold met. Execute on-chain using a Safe SDK (recommended).")
```

{% endtab %}

{% tab title="curl" %}

```bash
# Retrieve a single transaction by safeTxHash
curl -sS -X GET \
  "${TX_SERVICE_URL}/v1/multisig-transactions/0xYourSafeTxHash/" \
  -H "accept: application/json"
```

{% endtab %}
{% endtabs %}

> REST API: `GET /v1/multisig-transactions/{safeTxHash}/` - retrieves a single transaction by its Safe transaction hash, including all collected confirmations.

If the threshold is not yet met, other owners can confirm using:

{% tabs %}
{% tab title="TypeScript" %}

```typescript
await apiKit.confirmTransaction(safeTxHash, theirSignature.data);
```

{% endtab %}

{% tab title="Python" %}

```python
from eth_account import Account
from eth_account.messages import encode_defunct

# "theirSignature" is a signature over the safeTxHash bytes32.
# One common approach is an EIP-191 personal_sign over the hash.
msg = encode_defunct(hexstr=safe_tx_hash)
their_signature = Account.sign_message(msg, private_key="their-private-key").signature.hex()

resp = session.post(
    f"{TX_SERVICE_URL}/v1/multisig-transactions/{safe_tx_hash}/confirmations/",
    json={"signature": their_signature},
)
resp.raise_for_status()
```

{% endtab %}

{% tab title="curl" %}

```bash
curl -sS -X POST \
  "${TX_SERVICE_URL}/v1/multisig-transactions/0xYourSafeTxHash/confirmations/" \
  -H "accept: application/json" \
  -H "content-type: application/json" \
  -d '{ "signature": "0xOwnerSignature" }'
```

{% endtab %}
{% endtabs %}

> REST API: `POST /v1/multisig-transactions/{safeTxHash}/confirmations/`
> {% endstep %}

{% step %}

### Verify execution

After execution, query the Transaction Service to confirm the transaction was indexed as executed and successful.

{% tabs %}
{% tab title="TypeScript" %}

```typescript
// Wait for the Transaction Service to index the execution
await new Promise((resolve) => setTimeout(resolve, 10_000));

// Re-fetch the exact tx by safeTxHash (safer than "latest tx")
const updated = await apiKit.getTransaction(safeTxHash);
console.log("Executed:", updated.isExecuted);
console.log("Successful:", updated.isSuccessful);
console.log("On-chain hash:", updated.transactionHash);
```

{% endtab %}

{% tab title="Python" %}

```python
import time

time.sleep(10)

resp = session.get(f"{TX_SERVICE_URL}/v1/multisig-transactions/{safe_tx_hash}/")
resp.raise_for_status()
updated = resp.json()

print("Executed:", updated.get("isExecuted"))
print("Successful:", updated.get("isSuccessful"))
print("On-chain hash:", updated.get("transactionHash"))
```

{% endtab %}

{% tab title="curl" %}

```bash
sleep 10
curl -sS -X GET \
  "${TX_SERVICE_URL}/v1/multisig-transactions/0xYourSafeTxHash/" \
  -H "accept: application/json"
```

{% endtab %}
{% endtabs %}
{% endstep %}
{% endstepper %}

## Key concepts

> Off-chain signatures, on-chain execution. Safe transactions use a two-phase model:
>
> * Propose: The transaction data and an owner's signature are submitted to the Transaction Service (off-chain, no gas).
> * Execute: Once enough signatures meet the threshold, any account can submit the transaction on-chain (costs gas).
>
> This means a 3-of-5 Safe only pays gas once - when the final executor submits all collected signatures in a single on-chain call.
>
> For the full guide, see [Transactions with Off-chain Signatures](https://ledger-4.gitbook.io/ledger-multisig/guides/transactions-with-off-chain-signatures).

## Tips and pitfalls

* **Indexing lag.** After `executeTransaction` returns, the Transaction Service may take 10–60 seconds to index the receipt. If you query immediately, `isExecuted` may still be `false` and `isSuccessful` may be `null`. The on-chain transaction is confirmed. The indexer catches up within a minute or two.
* **`executeTransaction` can return a hash for a reverted tx.** The Protocol Kit returns a transaction hash even if the on-chain transaction reverts. Always check the receipt status independently.

{% tabs %}
{% tab title="TypeScript" %}

```typescript
const publicClient = createPublicClient({
  chain: sepolia,
  transport: http(RPC_URL),
});

const receipt = await publicClient.waitForTransactionReceipt({
  hash: executionResult.hash as `0x${string}`,
  timeout: 120_000,
});

if (receipt.status === "reverted") {
  throw new Error("Transaction reverted on-chain");
}
```

{% endtab %}

{% tab title="Python" %}

```python
from web3 import Web3

w3 = Web3(Web3.HTTPProvider(RPC_URL))
receipt = w3.eth.wait_for_transaction_receipt(
    execution_tx_hash,
    timeout=120,
)

# web3.py uses status: 1 (success) / 0 (revert)
if receipt["status"] == 0:
    raise RuntimeError("Transaction reverted on-chain")
```

{% endtab %}

{% tab title="curl" %}

```bash
# Most RPCs support eth_getTransactionReceipt.
curl -sS -X POST "${RPC_URL}" \
  -H "content-type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionReceipt","params":["0xOnChainTxHash"]}'
```

{% endtab %}
{% endtabs %}

* **Signer must be an owner.** The `proposeTransaction` call will fail if `senderAddress` is not a current owner (or delegate) of the Safe. Verify ownership before proposing.
* **Nonce management.** The Protocol Kit automatically picks the next nonce. If you need to queue transactions in a specific order, pass `options: { nonce }` to `createTransaction`.

## Next steps

{% content-ref url="/pages/2f500d7500a478df6d16aa68d444bf529ab9cfac" %}
[3. Batch Transactions](/guides/api-guides/3.-batch-transactions.md)
{% endcontent-ref %}


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://help.multisig.ledger.com/guides/api-guides/2.-transaction-lifecycle-including-off-chain-signatures.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
