> 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/6.-multi-signature-flow.md).

# 6. Multi Signature Flow

## What you'll learn

* Add and remove owners from a Safe programmatically
* Change the signing threshold
* Propose a transaction signed by one owner, then confirm with a second
* Execute a transaction that requires multiple signatures
* Handle Transaction Service indexing lag in multi-step flows

## Prerequisites

* **Node.js 18+**
* **A Safe on Sepolia** where you control an owner key (starting as 1-of-1)
* **A private key** for the existing Safe owner: testnet only
* **Sepolia ETH** in the Safe for gas (multiple on-chain transactions)
* **viem**: used for address derivation and transaction receipts

Install dependencies:

```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 { createPublicClient, http } from "viem";
import { sepolia } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
import { randomBytes } from "node:crypto";

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;
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_A_PRIVATE_KEY = "owner-a-private-key";
```

{% endtab %}

{% tab title="Python" %}

```python
import time
import requests
from web3 import Web3

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_A_PRIVATE_KEY = "owner-a-private-key"  # testnet only

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

w3 = Web3(Web3.HTTPProvider(RPC_URL))
```

{% endtab %}

{% tab title="curl" %}

```ini
# Use these values in your REST calls.
TX_SERVICE_URL=https://app.multisig.ledger.com/api/safe-transaction-service/11155111
SAFE_ADDRESS=0xYourSafeAddress
```

{% endtab %}
{% endtabs %}

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

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

## Helper: wait for receipt

Throughout this tutorial, we verify every on-chain transaction. This helper waits for the receipt and throws if the transaction reverted:

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

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

async function waitForReceipt(txHash: string) {
  const receipt = await publicClient.waitForTransactionReceipt({
    hash: txHash as `0x${string}`,
    timeout: 120_000,
  });
  if (receipt.status === "reverted") {
    throw new Error(`Transaction ${txHash} reverted on-chain`);
  }
  return receipt;
}
```

{% endtab %}

{% tab title="Python" %}

```python
def wait_for_receipt(tx_hash: str, timeout_s: int = 120) -> dict:
    receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=timeout_s)
    # web3.py uses status: 1 (success) / 0 (revert)
    if receipt.get("status") == 0:
        raise RuntimeError(f"Transaction {tx_hash} reverted on-chain")
    return dict(receipt)
```

{% endtab %}

{% tab title="curl" %}

```http
POST {RPC_URL}
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "eth_getTransactionReceipt",
  "params": ["0xOnChainTxHash"]
}
```

{% endtab %}
{% endtabs %}

## Helper: reinitialize Protocol Kit

After any on-chain state change (new owner, new threshold), reinitialize the Protocol Kit to pick up the latest Safe state:

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

```typescript
async function freshKit(signerKey: string) {
  return Safe.init({
    provider: RPC_URL,
    signer: signerKey,
    safeAddress: SAFE_ADDRESS,
  });
}
```

{% endtab %}

{% tab title="Python" %}

```python
# Python approach: don't cache Safe state.
# Re-fetch owners/threshold from the Transaction Service when needed.
def get_safe_info() -> dict:
    resp = session.get(f"{TX_SERVICE_URL}/v1/safes/{SAFE_ADDRESS}/")
    resp.raise_for_status()
    return resp.json()
```

{% endtab %}

{% tab title="curl" %}

```http
GET /v1/safes/{SAFE_ADDRESS}/
Accept: application/json
```

{% endtab %}
{% endtabs %}

## Helper: normalize private keys

Use one key format consistently. This helper accepts either `"abc..."` or `"0xabc..."`:

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

```typescript
function normalizePrivateKey(key: string): `0x${string}` {
  return (key.startsWith("0x") ? key : `0x${key}`) as `0x${string}`;
}
```

{% endtab %}

{% tab title="Python" %}

```python
def normalize_private_key(key: str) -> str:
    return key if key.startswith("0x") else f"0x{key}"
```

{% endtab %}

{% tab title="curl" %}

```http
# Keys must be normalized in your client before signing.
```

{% endtab %}
{% endtabs %}

## Step-by-step

{% stepper %}
{% step %}

### Add a second owner

Starting from a 1-of-1 Safe, generate an ephemeral Owner B key, then add Owner B while keeping the threshold at 1 (so Owner A can still execute alone for now).

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

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

const ownerAAddress = privateKeyToAccount(normalizePrivateKey(OWNER_A_PRIVATE_KEY)).address;

// Generate a temporary second owner for this tutorial run
const ownerBKeyHex = Buffer.from(randomBytes(32)).toString("hex");
const ownerBAddress = privateKeyToAccount(normalizePrivateKey(ownerBKeyHex)).address;

let kitA = await freshKit(normalizePrivateKey(OWNER_A_PRIVATE_KEY));

const addOwnerTx = await kitA.createAddOwnerTx({
  ownerAddress: ownerBAddress,
  threshold: 1, // Keep threshold at 1 for now
});

// Sign, propose, and execute (threshold is still 1)
const safeTxHash = await kitA.getTransactionHash(addOwnerTx);
const signature = await kitA.signHash(safeTxHash);

await apiKit.proposeTransaction({
  safeAddress: SAFE_ADDRESS,
  safeTransactionData: addOwnerTx.data,
  safeTxHash,
  senderAddress: ownerAAddress,
  senderSignature: signature.data,
});

const pendingTx = await apiKit.getTransaction(safeTxHash);
const result = await kitA.executeTransaction(pendingTx);
await waitForReceipt(result.hash);
```

{% endtab %}

{% tab title="Python" %}

```python
# Building Safe owner-management txs (add/remove owners, change threshold)
# requires correct Safe calldata + safeTxHash computation.
# Practical path:
# - build/sign/execute on-chain with the TypeScript Protocol Kit
# - use Python only for REST querying (shown in later steps)
```

{% endtab %}

{% tab title="curl" %}

```http
# Same constraint as Python:
# owner-management calldata + safeTxHash must be computed client-side.
```

{% endtab %}
{% endtabs %}

What happens on-chain: The Safe calls its own `addOwnerWithThreshold(owner, threshold)` method, adding Owner B to the owner list.

Verify the new owner was added:

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

```typescript
kitA = await freshKit(normalizePrivateKey(OWNER_A_PRIVATE_KEY));
const owners = await kitA.getOwners();
// owners now includes both Owner A and Owner B
```

{% endtab %}

{% tab title="Python" %}

```python
info = get_safe_info()
owners = info["owners"]
```

{% endtab %}

{% tab title="curl" %}

```http
GET /v1/safes/{SAFE_ADDRESS}/
Accept: application/json
```

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

{% step %}

### Change threshold to 2

Now require both owners to sign. This is an on-chain Safe transaction:

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

```typescript
kitA = await freshKit(normalizePrivateKey(OWNER_A_PRIVATE_KEY));

const changeThresholdTx = await kitA.createChangeThresholdTx(2);

const thresholdTxHash = await kitA.getTransactionHash(changeThresholdTx);
const thresholdSig = await kitA.signHash(thresholdTxHash);

await apiKit.proposeTransaction({
  safeAddress: SAFE_ADDRESS,
  safeTransactionData: changeThresholdTx.data,
  safeTxHash: thresholdTxHash,
  senderAddress: ownerAAddress,
  senderSignature: thresholdSig.data,
});

const thresholdPendingTx = await apiKit.getTransaction(thresholdTxHash);
const thresholdResult = await kitA.executeTransaction(thresholdPendingTx);
await waitForReceipt(thresholdResult.hash);
```

{% endtab %}

{% tab title="Python" %}

```python
# Threshold change is an on-chain Safe tx.
# Use the TypeScript Protocol Kit to build/sign/execute it.
# Then poll the Transaction Service until it indexes the new threshold.
```

{% endtab %}

{% tab title="curl" %}

```http
# Threshold change is executed on-chain.
# Use the Transaction Service only for proposal + confirmations.
```

{% endtab %}
{% endtabs %}

Critical: After changing the threshold on-chain, you must wait for the Transaction Service to index the new state before proposing new transactions. Otherwise, `confirmationsRequired` in the API response will still show the old threshold.

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

```typescript
// Poll until the Transaction Service reports threshold = 2
async function waitForTxServiceThreshold(expected: number) {
  for (let attempt = 0; attempt < 24; attempt++) {
    await new Promise((r) => setTimeout(r, 5000));
    const info = await apiKit.getSafeInfo(SAFE_ADDRESS);
    if (info.threshold === expected) return;
  }
  throw new Error(`TX Service did not index threshold ${expected}`);
}

await waitForTxServiceThreshold(2);
```

{% endtab %}

{% tab title="Python" %}

```python
def wait_for_threshold(expected: int, attempts: int = 24, sleep_s: int = 5) -> None:
    for _ in range(attempts):
        time.sleep(sleep_s)
        info = get_safe_info()
        if int(info["threshold"]) == expected:
            return
    raise TimeoutError(f"TX Service did not index threshold {expected}")

wait_for_threshold(2)
```

{% endtab %}

{% tab title="curl" %}

```http
# Poll this endpoint until it returns the expected threshold:
GET /v1/safes/{SAFE_ADDRESS}/
Accept: application/json
```

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

{% step %}

### Propose a transaction (Owner A signs)

With threshold = 2, a single signature is no longer enough to execute. Owner A proposes and signs:

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

```typescript
kitA = await freshKit(normalizePrivateKey(OWNER_A_PRIVATE_KEY));

const transferTx = await kitA.createTransaction({
  transactions: [
    {
      to: ownerAAddress,
      value: "10000000000000", // 0.00001 ETH
      data: "0x",
      operation: OperationType.Call,
    },
  ],
});

const transferTxHash = await kitA.getTransactionHash(transferTx);
const signatureA = await kitA.signHash(transferTxHash);

await apiKit.proposeTransaction({
  safeAddress: SAFE_ADDRESS,
  safeTransactionData: transferTx.data,
  safeTxHash: transferTxHash,
  senderAddress: ownerAAddress,
  senderSignature: signatureA.data,
});

// Check: 1 of 2 confirmations
let tx = await apiKit.getTransaction(transferTxHash);
// tx.confirmations.length === 1, tx.confirmationsRequired === 2
```

{% endtab %}

{% tab title="Python" %}

```python
# Query the proposed tx by safeTxHash:
safe_tx_hash = "0xYourSafeTxHash"
resp = session.get(f"{TX_SERVICE_URL}/v1/multisig-transactions/{safe_tx_hash}/")
resp.raise_for_status()
tx = resp.json()
```

{% endtab %}

{% tab title="curl" %}

```http
GET /v1/multisig-transactions/{safeTxHash}/
Accept: application/json
```

{% endtab %}
{% endtabs %}

REST API: `POST /v1/safes/{address}/multisig-transactions/`
{% endstep %}

{% step %}

### Confirm with Owner B

Owner B retrieves the pending transaction and adds their confirmation (off-chain signature):

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

```typescript
const kitB = await freshKit(ownerBKeyHex);
const signatureB = await kitB.signHash(transferTxHash);

await apiKit.confirmTransaction(transferTxHash, signatureB.data);

// Check: 2 of 2 confirmations
tx = await apiKit.getTransaction(transferTxHash);
// tx.confirmations.length === 2, tx.confirmationsRequired === 2
```

{% endtab %}

{% tab title="Python" %}

```python
safe_tx_hash = "0xYourSafeTxHash"
owner_b_signature = "0xOwnerBSignature"

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

{% endtab %}

{% tab title="curl" %}

```http
POST /v1/multisig-transactions/{safeTxHash}/confirmations/
Accept: application/json
Content-Type: application/json

{ "signature": "0xOwnerSignature" }
```

{% endtab %}
{% endtabs %}

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

{% step %}

### Execute (threshold met)

Either owner (or any account) can now execute the transaction, since both signatures are collected in the Transaction Service:

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

```typescript
tx = await apiKit.getTransaction(transferTxHash);

if ((tx.confirmations?.length ?? 0) >= tx.confirmationsRequired) {
  const execResult = await kitA.executeTransaction(tx);
  await waitForReceipt(execResult.hash);
}
```

{% endtab %}

{% tab title="Python" %}

```python
# Execution is an on-chain call to the Safe contract.
# The Transaction Service cannot execute for you.
#
# You can check if the threshold is met:
resp = session.get(f"{TX_SERVICE_URL}/v1/multisig-transactions/0xYourSafeTxHash/")
resp.raise_for_status()
tx = resp.json()
threshold_met = len(tx.get("confirmations") or []) >= tx["confirmationsRequired"]
```

{% endtab %}

{% tab title="curl" %}

```http
# Execution requires an on-chain tx (eth_sendRawTransaction).
# It cannot be done via the Transaction Service REST API.
```

{% endtab %}
{% endtabs %}

The Protocol Kit pulls the collected signatures from the transaction object and submits them together in the on-chain execution call.
{% endstep %}

{% step %}

### Reset (optional): remove owner and lower threshold

To return to a 1-of-1 configuration (e.g., after testing), remove Owner B and lower the threshold in a single transaction. This still requires both signatures since threshold is currently 2:

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

```typescript
kitA = await freshKit(normalizePrivateKey(OWNER_A_PRIVATE_KEY));

const removeOwnerTx = await kitA.createRemoveOwnerTx({
  ownerAddress: ownerBAddress,
  threshold: 1, // Lower threshold along with removing owner
});

const removeTxHash = await kitA.getTransactionHash(removeOwnerTx);
const removeSigA = await kitA.signHash(removeTxHash);

// Owner A proposes
await apiKit.proposeTransaction({
  safeAddress: SAFE_ADDRESS,
  safeTransactionData: removeOwnerTx.data,
  safeTxHash: removeTxHash,
  senderAddress: ownerAAddress,
  senderSignature: removeSigA.data,
});

// Owner B confirms
const kitB2 = await freshKit(ownerBKeyHex);
const removeSigB = await kitB2.signHash(removeTxHash);
await apiKit.confirmTransaction(removeTxHash, removeSigB.data);

// Execute
const removePendingTx = await apiKit.getTransaction(removeTxHash);
const removeResult = await kitA.executeTransaction(removePendingTx);
await waitForReceipt(removeResult.hash);
```

{% endtab %}

{% tab title="Python" %}

```python
# removeOwner(...) is an on-chain Safe tx.
# It requires correct Safe calldata, including the Safe owners linked-list prevOwner.
# Recommended: execute the reset with the TypeScript Protocol Kit.
```

{% endtab %}

{% tab title="curl" %}

```http
# Same constraints as Python: reset requires an on-chain Safe tx.
```

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

## Key concepts

How threshold signing works:

A Safe with `N` owners and threshold `T` requires at least `T` unique owner signatures before a transaction can execute.

| Phase   | Who                       | What happens                                               | Gas? |
| ------- | ------------------------- | ---------------------------------------------------------- | ---- |
| Propose | Any owner or delegate     | Transaction data + first signature submitted to TX Service | No   |
| Confirm | Other owners              | Additional signatures submitted to TX Service              | No   |
| Execute | Anyone (usually an owner) | All signatures bundled and submitted on-chain              | Yes  |

Signatures are collected off-chain (free) and only the final execution costs gas. This is the core efficiency of the Safe signature model.

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

* **Transaction Service indexing lag is critical here.** After any on-chain state change (adding an owner, changing threshold), the Transaction Service needs time (10–60 seconds) to index the new state. If you immediately propose a new transaction, the API may report the old `confirmationsRequired` value. Always poll `getSafeInfo` until it reflects the expected threshold before proceeding.
* **Always verify receipt status.** `executeTransaction` returns a hash even if the on-chain transaction reverts. Use `waitForTransactionReceipt` and check `receipt.status`:

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

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

{% endtab %}

{% tab title="Python" %}

```python
if receipt.get("status") == 0:
    raise RuntimeError("Transaction reverted")
```

{% endtab %}

{% tab title="curl" %}

```http
# Check `status` in the JSON-RPC receipt:
# - "0x1" = success
# - "0x0" = revert
```

{% endtab %}
{% endtabs %}

* **Reinitialize the Protocol Kit after state changes.** The Protocol Kit caches the Safe's owner list and threshold at initialization. After adding/removing owners or changing the threshold, call `Safe.init(...)` again to pick up the new state.
* **Private key format must be consistent.** Avoid mixing prefixed/non-prefixed keys manually (for example `0x` + `0x...`). Use a key normalizer helper and keep the format uniform.
* **Signature ordering.** The Safe contract expects signatures sorted by signer address (ascending, lowercase). The Protocol Kit and Transaction Service handle this automatically when you use `executeTransaction` with a transaction object from `getTransaction`. Don't manually reorder signatures.
* **`createRemoveOwnerTx` with threshold.** When removing an owner, you can simultaneously lower the threshold. If you set the threshold higher than the remaining owner count, the transaction will revert.
* **Don't lose keys mid-flow.** If you change the threshold to 2-of-2 and then lose access to one key, you'll be locked out of the Safe permanently. In production, always test threshold changes carefully and keep backup access plans.
