> 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/3.-batch-transactions.md).

# 3. Batch Transactions

## What you'll learn

* Create a batch transaction containing multiple ETH transfers
* Understand how the Protocol Kit uses the MultiSend contract under the hood
* Sign, propose, and execute a batch as a single on-chain transaction

## Prerequisites

* **Node.js 18+**
* **A Safe on Sepolia** where you control an owner key
* **A private key** for a Safe owner: testnet only
* **Sepolia ETH** in the Safe to cover the batch transfers and gas
* **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 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

{% 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();
```

{% 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

# Optional: 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 %}
{% endstep %}

{% step %}

### Define multiple operations

Create an array of `MetaTransactionData` objects. Each one represents a distinct operation. Here we batch two ETH transfers:

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

```typescript
const transactions: MetaTransactionData[] = [
  {
    to: "0xRecipient1",
    value: "100000000000000", // 0.0001 ETH
    data: "0x",
    operation: OperationType.Call,
  },
  {
    to: "0xRecipient2",
    value: "200000000000000", // 0.0002 ETH
    data: "0x",
    operation: OperationType.Call,
  },
];
```

{% endtab %}

{% tab title="Python" %}

```python
transactions = [
    {
        "to": "0xRecipient1",
        "value": 100000000000000,  # 0.0001 ETH (wei)
        "data": "0x",
        "operation": 0,  # CALL
    },
    {
        "to": "0xRecipient2",
        "value": 200000000000000,  # 0.0002 ETH (wei)
        "data": "0x",
        "operation": 0,  # CALL
    },
]
```

{% endtab %}

{% tab title="curl" %}

```bash
# Batch composition happens client-side.
# With curl, you can only submit the final proposed tx payload
# once you have the MultiSend call data, safeTxHash, and a signature.
```

{% endtab %}
{% endtabs %}

You can mix operation types in a batch: ETH transfers, contract calls, ERC-20 approvals, and more. Each operation has its own `to`, `value`, `data`, and `operation` fields.
{% endstep %}

{% step %}

### Create the batch transaction

Pass the array to `createTransaction`. When the array contains more than one item, the Protocol Kit automatically wraps them in a call to the **MultiSend** contract.

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

```typescript
const safeTransaction = await protocolKit.createTransaction({ transactions });
```

{% endtab %}

{% tab title="Python" %}

```python
# You still need to build a *single* Safe tx that calls MultiSend.
# There is no official Python Protocol Kit equivalent.
#
# Practical option:
# - Build the batch with TypeScript `@safe-global/protocol-kit`
# - Then propose it from Python via REST (see the next step)
```

{% endtab %}

{% tab title="curl" %}

```bash
# Same constraint as Python:
# you must compute the MultiSend calldata + safeTxHash client-side.
```

{% endtab %}
{% endtabs %}

Under the hood, the Protocol Kit:

{% stepper %}
{% step %}
ABI-encodes each operation into the MultiSend format.
{% endstep %}

{% step %}
Sets `to` to the MultiSend contract address (chain-specific, deployed by Safe).
{% endstep %}

{% step %}
Sets `operation` to `DelegateCall` (required for MultiSend).
{% endstep %}

{% step %}
Packs all operations into the `data` field.
{% endstep %}
{% endstepper %}

You don't need to interact with the MultiSend contract directly. The SDK handles it.
{% endstep %}

{% step %}

### Sign the batch

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

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

{% endtab %}

{% tab title="Python" %}

```python
# Signing is always client-side.
# Once you have the safeTxHash, sign it with any local signer.
# Then submit the signature to the service (next step).
```

{% 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 %}
{% endstep %}

{% step %}

### Propose to the Transaction Service

{% 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
# The service expects a regular Safe multisig tx proposal.
# For MultiSend batches:
# - "to" is the MultiSend contract address
# - "operation" is 1 (DELEGATE_CALL)
# - "data" is the MultiSend-encoded payload
payload = {
    "safe": SAFE_ADDRESS,
    "to": "0xMultiSendAddress",
    "value": "0",
    "data": "0xMultiSendCalldata",
    "operation": 1,
    "gasToken": "0x0000000000000000000000000000000000000000",
    "safeTxGas": "0",
    "baseGas": "0",
    "gasPrice": "0",
    "refundReceiver": "0x0000000000000000000000000000000000000000",
    "nonce": "0",
    "contractTransactionHash": "0xYourSafeTxHash",
    "sender": "0xOwnerAddress",
    "signature": "0xOwnerSignature",
}

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": "0xMultiSendAddress",
    "value": "0",
    "data": "0xMultiSendCalldata",
    "operation": 1,
    "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 batch transaction appears as a single entry in the Ledger Multisig UI.
{% endstep %}

{% step %}

### Execute

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

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

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

if (confirmations >= pendingTx.confirmationsRequired) {
  const result = await protocolKit.executeTransaction(pendingTx);
  const receipt = await publicClient.waitForTransactionReceipt({
    hash: result.hash as `0x${string}`,
    timeout: 120_000,
  });
  if (receipt.status === "reverted") {
    throw new Error("Batch reverted on-chain");
  }
  console.log("Batch executed. Tx hash:", result.hash);
}
```

{% endtab %}

{% tab title="Python" %}

```python
# Execution is an on-chain call to the Safe contract.
# If you don't have a Python SDK that can:
# - rebuild the Safe tx (to=MultiSend, operation=1, data=calldata)
# - collect/pack signatures
# prefer executing via the TypeScript Protocol Kit.
```

{% endtab %}

{% tab title="curl" %}

```bash
# Execution cannot be done via the Transaction Service API.
# You must submit an on-chain tx to the Safe contract (eth_sendRawTransaction).
```

{% endtab %}
{% endtabs %}

Both transfers happen atomically in a single on-chain transaction. Either all succeed or all revert.
{% endstep %}
{% endstepper %}

## Key concepts

What is MultiSend?

MultiSend is a contract deployed by the Safe team on every supported chain. It lets you batch multiple operations into a single transaction by:

* Encoding each operation (target, value, data, operation type) into a packed byte array
* Executing all of them via `delegatecall` from the Safe

Because it uses `delegatecall`, the individual operations execute in the context of the Safe. `msg.sender` in each sub-call is the Safe itself.

Why batch?

* **Gas efficiency:** One on-chain transaction instead of N
* **Atomicity:** All operations succeed or all revert
* **Single approval flow:** Owners sign once for the entire batch
* **Lower nonce usage:** The batch consumes only one Safe nonce

## Tips and pitfalls

* **Indexing lag.** After the batch executes on-chain, the Transaction Service may take 10–60 seconds to index the result. If you query immediately, `isExecuted` might still be `false`.
* **Always verify the receipt.** `executeTransaction` can return a hash even when the transaction eventually reverts. Wait for the receipt and check `receipt.status`.
* **DelegateCall is set automatically.** When you pass multiple transactions to `createTransaction`, the SDK sets the operation to `DelegateCall` for the outer MultiSend call. Don't override this manually.
* **Mixing ETH and contract calls.** You can freely mix native ETH transfers (data `"0x"`) and contract interactions (encoded calldata) in the same batch. See [ERC-20 Token Transfers](broken://pages/3ff5a6496eb4a944e65156123199c49fa67a95bf) for encoding contract calls.
* **Order matters.** Operations execute sequentially in the order you define them. If operation B depends on the result of operation A (e.g., approve then transferFrom), put A first.
* **Error in one reverts all.** If any operation in the batch fails, the entire MultiSend transaction reverts. Test each operation individually before batching.

## Next steps

{% content-ref url="/pages/494a02d578d9836eadeb5a1f7574c3176756e8bd" %}
[4. Delegate Management](/guides/api-guides/4.-delegate-management.md)
{% endcontent-ref %}
