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


---

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