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


---

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