Bulk Transfers
Pay up to 50 crypto addresses in one batch — approve it through the API, then execute it from the corporation wallet.
Before You Start
Read the following guides before proceeding:
| Guide | Why |
|---|---|
| Getting Started | Platform overview and setup |
| Environments | GET /api/v1/config returns the ContractRegistry, bundler and paymaster for Step 2 |
| Authentication | How to obtain the corporation token |
| Wallet Deployment | The batch is executed by the corporation wallet — its validator and policy are what Step 2 signs against |
| Permissions | Who may approve and who may sign the execution |
| Wallets and Balances | The batch is paid from the corporation wallet balance |
Overview
A bulk transfer pays one token to up to 50 on-chain addresses. It is two steps, and the API call alone
moves no money:
- Approve —
POST /api/v1/bulk/transfer. Wirex validates the batch, reserves its total on the
corporation balance and approves it on-chain. The response carries the exact call data for step 2. - Execute — the corporation wallet calls
FundsManagement.executeOutgoingBatchTransactionin a
UserOperation signed by an employee key. This is the transaction that moves the tokens.
Each recipient then appears in the activity feed as one Outbound Crypto item with status Completed.
An approved batch that is never executed stays reserved and is never paid. Wirex does not execute
the batch for you. If a batch "hangs" after the API call returned200, Step 2 was not sent.
Despite living under bank, this is not a fiat flow. Only crypto recipients are accepted — no IBAN,
no card, no corridor.
sequenceDiagram
participant App as Your Backend
participant API as Wirex Corporate API
participant Chain as Base
App->>API: 1. POST /api/v1/bulk/transfer
API->>Chain: approve batch (asynchronous)
API-->>App: transaction_id, operation_id, crypto_transfers
App->>Chain: 2. UserOperation: executeOutgoingBatchTransaction
Chain-->>App: transaction hash
App->>API: 3. GET /api/v1/activity/feed
API-->>App: one Outbound item per recipient, Completed
| Step | Action | Produces | Consumed by |
|---|---|---|---|
| 1 | POST /api/v1/bulk/transfer | transaction_id, operation_id, crypto_transfers[] | Step 2 |
| 2 | executeOutgoingBatchTransaction from the corporation wallet | Transaction hash | Step 3 |
| 3 | GET /api/v1/activity/feed | One Completed item per recipient, carrying the Step 2 hash | Your reconciliation |
Measured on sandbox: the on-chain approval lands within 12 seconds of Step 1 returning, and every
recipient's item is Completed within 5 seconds of the Step 2 transaction. A batch that is still not paid after a
minute was not executed.
Prerequisites
| Requirement | Detail |
|---|---|
| Corporation wallet | Deployed with CorporateValidator as the root validator's signer module and ExecutionDelayCorporatePolicy as its policy — see Wallet Deployment |
| Approving employee | The corporation token belongs to an employee with TransactionSu or TransactionCreate (the Owner role has both through Su) |
| Signing key | The private key of an employee whose on-chain role holds Su, TransactionSu or TransactionCreate. CorporateValidator recovers the signer and checks that role on every UserOperation |
| Balance | The corporation wallet holds at least the batch total of the chosen token |
| Chain configuration | chains.<id>.contractRegistry, bundlerUrl and paymasterUrl from GET /api/v1/config |
Gas is sponsored by the paymaster. The corporation wallet needs no ETH.
On a corporation registered through the obsolete
createCorporationpath, only the single key behind
the root validator can sign Step 2 — employee keys are refused. See
Obsolete: createCorporation.
Step 1: Approve the Batch
POST /api/v1/bulk/transfer
Requires TransactionSu or TransactionCreate.
Request body:
{
"recipients": [
{
"type": "Crypto",
"currency": "WEUR",
"amount": "1.50",
"crypto": {
"address": "0xA7E41d5680dE394EaA2ed417169DFf56840Fb3EE",
"network": "Base"
}
},
{
"type": "Crypto",
"currency": "WEUR",
"amount": "2.25",
"crypto": {
"address": "0xAAFF0821A09A1Aac28B72dD3Ff410A7ea5FEb874",
"network": "Base"
}
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
recipients | array | Yes | 1–50 entries. All must share the same currency |
recipients[].type | string | Yes | Must be Crypto. Any other value is rejected |
recipients[].currency | string | Yes | Token symbol, e.g. WEUR, WUSD, EURC, USDC. Use WUSD / WEUR for the unified tokens |
recipients[].amount | number or string | Yes | Amount in display units. A JSON number (1.5) or a decimal string ("1.50"); both forms may be mixed. Applied with two decimal places — see below |
recipients[].crypto.address | string | Yes | Destination EVM address |
recipients[].crypto.network | string | Yes | Must be non-empty. The batch always runs on the chain of the corporation token, whatever this field says |
Response:
{
"transaction_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"operation_id": "64120850-73a1-4df5-a074-d463258c9deb",
"crypto_transfers": [
{
"address": "0xA7E41d5680dE394EaA2ed417169DFf56840Fb3EE",
"precise_amount": "1500000000000000000",
"token_address": "0x5c55F314624718019A326F16a62A05D6C6d8C8A2"
},
{
"address": "0xAAFF0821A09A1Aac28B72dD3Ff410A7ea5FEb874",
"precise_amount": "2250000000000000000",
"token_address": "0x5c55F314624718019A326F16a62A05D6C6d8C8A2"
}
]
}| Field | Description |
|---|---|
transaction_id | The corporation id. The same value on every bulk transfer this corporation makes. Passed to Step 2 as-is |
operation_id | Identifies this batch. Unique per call. Key your records and support requests on it |
crypto_transfers[] | The approved transfers, one per recipient, in request order |
crypto_transfers[].address | Destination address as sent |
crypto_transfers[].precise_amount | Approved amount in the token's smallest unit, as a string. This is what Step 2 sends |
crypto_transfers[].token_address | Resolved token contract. Identical for every entry |
const response = await fetch(`${baseUrl}/api/v1/bulk/transfer`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${corporationToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
recipients: payouts.map((p) => ({
type: 'Crypto',
currency: 'WEUR',
amount: p.amount.toFixed(2), // anything past 2 decimals is discarded
crypto: { address: p.address, network: 'Base' }
}))
})
});
const approval = await response.json();response = requests.post(
f"{base_url}/api/v1/bulk/transfer",
headers={
"Authorization": f"Bearer {corporation_token}",
"Content-Type": "application/json",
},
json={
"recipients": [
{
"type": "Crypto",
"currency": "WEUR",
"amount": f"{p['amount']:.2f}", # anything past 2 decimals is discarded
"crypto": {"address": p["address"], "network": "Base"},
}
for p in payouts
]
},
)
approval = response.json()recipients := make([]map[string]interface{}, 0, len(payouts))
for _, p := range payouts {
recipients = append(recipients, map[string]interface{}{
"type": "Crypto",
"currency": "WEUR",
"amount": fmt.Sprintf("%.2f", p.Amount), // anything past 2 decimals is discarded
"crypto": map[string]string{"address": p.Address, "network": "Base"},
})
}
body, _ := json.Marshal(map[string]interface{}{"recipients": recipients})
req, _ := http.NewRequest("POST", baseURL+"/api/v1/bulk/transfer", bytes.NewBuffer(body))
req.Header.Set("Authorization", "Bearer "+corporationToken)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var approval BulkTransferResponse
json.NewDecoder(resp.Body).Decode(&approval)What the approval does to the balance
The approval reserves the batch total immediately. Nothing moves on-chain yet.
| Moment | GET /api/v1/wallets balance | On-chain balanceOf | Activity feed |
|---|---|---|---|
| Before Step 1 | 20.00 | 20.00 | — |
| After Step 1 (batch of 3.75) | 16.25 — reserved | 20.00 | Nothing |
| After Step 2 | — | 16.25 | One Completed item per recipient |
| Step 2 never sent | 16.25 for 24 hours, then 20.00 | 20.00 | Nothing |
The reservation lasts 24 hours. An approval you do not intend to execute keeps its total
unavailable for a day — a second approval in that window can fail withErrorInsufficientFundseven
though the tokens are still in the wallet. There is no cancel endpoint.
Amounts are applied with two decimal places
Each amount is truncated — not rounded — to two decimal places, then scaled to the token's decimals.
amount sent | precise_amount returned (18-decimal token) |
|---|---|
"1.50" | "1500000000000000000" |
"0.129" | "120000000000000000" — 0.12 |
"0.000001" | "0" — a zero transfer inside the batch |
Compare every
precise_amountwith what you intended before Step 2. A recipient below0.01is
approved for zero, and the request still returns200.
Validate addresses yourself
crypto.address is checked for presence only. A value that is not an EVM address is approved, reserved,
and can never be executed — the reservation then holds for 24 hours. Validate ^0x[0-9a-fA-F]{40}$
before sending.
Step 2: Execute the Batch from the Corporation Wallet
Send one UserOperation from the corporation wallet that calls:
function executeOutgoingBatchTransaction(
bytes16 transactionId,
bytes16 operationId,
address tokenAddress,
address[] recipients,
uint256[] amounts
)on the FundsManagement contract, resolved from the ContractRegistry with contractByName("FundsManagement").
| Argument | Value from Step 1 |
|---|---|
transactionId | transaction_id with the dashes removed, as bytes16 — 0x3fa85f6457174562b3fc2c963f66afa6 |
operationId | operation_id with the dashes removed, as bytes16 |
tokenAddress | crypto_transfers[0].token_address |
recipients | crypto_transfers[].address, in response order |
amounts | crypto_transfers[].precise_amount as uint256, in response order |
Two things must be right for the UserOperation to validate:
| Requirement | Detail |
|---|---|
| Validator | The wallet's root validator: a permission validator whose signer is the employee's ECDSA key and whose single policy is ExecutionDelayCorporatePolicy (resolved from the ContractRegistry) |
| Amounts | The sum of amounts must equal the approved total exactly, or the call reverts Total amount mismatch |
An approved batch is executed directly — it does not go through the request → delay → execute cycle
that ExecutionDelayCorporatePolicy applies to an ordinary transfer.
The approval reaches the chain a few seconds after Step 1 returns. A UserOperation sent before that
is rejected at simulation withBatch transaction not found. Retry every few seconds — on sandbox the
first or second attempt succeeds. A rejected attempt is never mined and costs nothing.
The example uses viem and the ZeroDev SDK, which build Kernel v3.1 UserOperations; any ERC-4337 client
that produces the same validator and call data works.
import { createPublicClient, encodeFunctionData, http, pad, type Address, type Hex } from "viem";
import { base } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
import { createKernelAccount, createKernelAccountClient, createZeroDevPaymasterClient } from "@zerodev/sdk";
import { getEntryPoint, KERNEL_V3_1 } from "@zerodev/sdk/constants";
import { toPermissionValidator } from "@zerodev/permissions";
import { toSudoPolicy } from "@zerodev/permissions/policies";
import { toECDSASigner } from "@zerodev/permissions/signers";
// From GET /api/v1/config → chains.<id>
const chainConfig = { rpcUrl, bundlerUrl, paymasterUrl, contractRegistry };
const publicClient = createPublicClient({ chain: base, transport: http(chainConfig.rpcUrl) });
const contractByName = (name: string) =>
publicClient.readContract({
address: chainConfig.contractRegistry,
abi: [{ type: "function", name: "contractByName", stateMutability: "view", inputs: [{ type: "string" }], outputs: [{ type: "address" }] }],
functionName: "contractByName",
args: [name],
});
const fundsManagement = await contractByName("FundsManagement");
const delayPolicy = await contractByName("ExecutionDelayCorporatePolicy");
// The corporation wallet, signed by an employee key
const entryPoint = getEntryPoint("0.7");
const validator = await toPermissionValidator(publicClient, {
entryPoint,
kernelVersion: KERNEL_V3_1,
signer: await toECDSASigner({ signer: privateKeyToAccount(employeePrivateKey) }),
policies: [toSudoPolicy({ policyAddress: delayPolicy })],
});
validator.address = delayPolicy;
const account = await createKernelAccount(publicClient, {
entryPoint,
kernelVersion: KERNEL_V3_1,
plugins: { sudo: validator },
address: corporationAddress,
});
const paymaster = createZeroDevPaymasterClient({ chain: base, transport: http(chainConfig.paymasterUrl) });
const wallet = createKernelAccountClient({
account,
chain: base,
bundlerTransport: http(chainConfig.bundlerUrl),
paymaster: { getPaymasterData: (userOperation) => paymaster.sponsorUserOperation({ userOperation }) },
});
// The call, built from the Step 1 response
const toBytes16 = (uuid: string) => pad(`0x${uuid.replace(/-/g, "")}` as Hex, { size: 16 });
const transfers = approval.crypto_transfers;
const callData = await account.encodeCalls([{
to: fundsManagement,
value: 0n,
data: encodeFunctionData({
abi: [{
type: "function",
name: "executeOutgoingBatchTransaction",
stateMutability: "nonpayable",
inputs: [
{ type: "bytes16", name: "transactionId" },
{ type: "bytes16", name: "operationId" },
{ type: "address", name: "tokenAddress" },
{ type: "address[]", name: "recipients" },
{ type: "uint256[]", name: "amounts" },
],
outputs: [],
}],
functionName: "executeOutgoingBatchTransaction",
args: [
toBytes16(approval.transaction_id),
toBytes16(approval.operation_id),
transfers[0].token_address as Address,
transfers.map((t) => t.address as Address),
transfers.map((t) => BigInt(t.precise_amount)),
],
}),
}]);
// Retry until the approval is on-chain ("Batch transaction not found" until then)
let txHash: Hex | undefined;
for (let attempt = 1; !txHash && attempt <= 20; attempt++) {
try {
const userOpHash = await wallet.sendUserOperation({ callData });
const receipt = await wallet.waitForUserOperationReceipt({ hash: userOpHash });
txHash = receipt.receipt.transactionHash;
} catch {
await new Promise((resolve) => setTimeout(resolve, 6000));
}
}Step 2 is on-chain and is shown in TypeScript only, as on Wallet Deployment:
the UserOperation tooling for Kernel accounts is a JavaScript/TypeScript library.
Execution outcomes
Every refusal below happens at simulation: nothing is mined, no token moves, no activity item is created.
| Revert reason | Cause | Resolution |
|---|---|---|
Batch transaction not found — first seconds after Step 1 | The approval is not on-chain yet | Retry after a few seconds |
Batch transaction not found — after a successful execution | The batch was already executed. A batch executes once | Nothing to do. Find the existing items in the activity feed |
Batch transaction not found — persistently | transaction_id / operation_id were not converted to bytes16 as described, or the operation was never approved | Check the conversion; approve again if needed |
Total amount mismatch | amounts do not sum to the approved total | Send precise_amount values unchanged |
Step 3: Confirm in the Activity Feed
GET /api/v1/activity/feed
Each recipient becomes one item with type: Crypto, direction: Outbound. Match the batch by the Step 2
transaction hash in operations[].hash; match each recipient by destination.wallet.address.
{
"id": "8b7a4f9b-537f-cba9-c18b-8dbe91c4247f",
"corporation_address": "0xAAFF0821A09A1Aac28B72dD3Ff410A7ea5FEb874",
"type": "Crypto",
"direction": "Outbound",
"status": "Completed",
"source": {
"type": "Wallet",
"wallet": {
"address": "0xAAFF0821A09A1Aac28B72dD3Ff410A7ea5FEb874"
}
},
"destination": {
"type": "ExternalWallet",
"wallet": {
"address": "0xA7E41d5680dE394EaA2ed417169DFf56840Fb3EE"
}
},
"source_amount": {
"amount": -1.5,
"amount_precise": "-1500000000000000000",
"decimals": 18,
"token_symbol": "WEUR",
"token_address": "0x5c55F314624718019A326F16a62A05D6C6d8C8A2"
},
"destination_amount": {
"amount": 1.5,
"amount_precise": "1500000000000000000",
"decimals": 18,
"token_symbol": "WEUR",
"token_address": "0x5c55F314624718019A326F16a62A05D6C6d8C8A2"
},
"operations": [
{
"hash": "0x6fb13e90d9696c9a8f7415527b396102ef2b455e8ddad3aa92af9d44b5ca9a1e",
"operation_amount": {
"amount": -1.5,
"token_symbol": "WEUR"
}
}
],
"activity_steps": [
{
"type": "Initiated",
"status": "Completed",
"created_at": "2024-01-01T10:00:00Z",
"completed_at": "2024-01-01T10:00:00Z"
},
{
"type": "CryptoOut",
"status": "Completed",
"created_at": "2024-01-01T10:00:00Z",
"completed_at": "2024-01-01T10:00:00Z"
},
{
"type": "Completed",
"status": "Completed",
"created_at": "2024-01-01T10:00:00Z",
"completed_at": "2024-01-01T10:00:00Z"
}
],
"created_at": "2024-01-01T10:00:00Z"
}| Field | Description |
|---|---|
destination.wallet.address | The recipient |
destination_amount.amount_precise | Equals that recipient's precise_amount from Step 1 |
operations[].hash | The Step 2 transaction hash — shared by every recipient of the batch |
status | Completed once the transaction is indexed |
The same items are delivered to POST {your_webhook_base_url}/v2/webhooks/activities, and the balance
change to /v2/webhooks/balances — see Webhooks.
const response = await fetch(`${baseUrl}/api/v1/activity/feed?page_number=0&page_size=50`, {
headers: { 'Authorization': `Bearer ${corporationToken}` }
});
const feed = await response.json();
const legs = feed.data.filter((item) =>
item.operations?.some((op) => op.hash.toLowerCase() === txHash.toLowerCase())
);response = requests.get(
f"{base_url}/api/v1/activity/feed",
params={"page_number": 0, "page_size": 50},
headers={"Authorization": f"Bearer {corporation_token}"},
)
legs = [
item for item in response.json()["data"]
if any(op["hash"].lower() == tx_hash.lower() for op in item.get("operations", []))
]req, _ := http.NewRequest("GET", baseURL+"/api/v1/activity/feed?page_number=0&page_size=50", nil)
req.Header.Set("Authorization", "Bearer "+corporationToken)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var feed ActivityFeedResponse
json.NewDecoder(resp.Body).Decode(&feed)
legs := []ActivityItem{}
for _, item := range feed.Data {
for _, op := range item.Operations {
if strings.EqualFold(op.Hash, txHash) {
legs = append(legs, item)
}
}
}There is no
Faileditem for a bulk transfer. An approval creates no item, and an execution that
is refused is never mined, so it creates none either. The only items a batch produces are the
Completedones above — and since a refused execution debits nothing, there is never anything to
refund.
There is no bulk-transfer status endpoint and no bulk-transfer approval webhook.
Limitations
- Crypto destinations only.
Sepa,Ach,Cardand every other recipient type is rejected. - 1–50 recipients per batch, one
currencyper batch. To pay in two tokens, send two batches. - Two decimal places of precision, truncated.
crypto.networkis not used to pick the chain; the batch runs on the corporation token's chain.- A batch executes once. Execution must be sent by the corporation; Wirex never executes it.
- No cancel endpoint. An approval that is not executed releases its reservation after 24 hours.
Error Handling
Errors from Step 1 use the standard envelope. Validation errors name the offending entry by index, so the
failing recipient is identifiable without bisecting the batch.
{
"error_reason": "ErrorInvalidField",
"error_description": "All recipients must use the same currency",
"error_category": {
"category": "CategoryValidationFailure",
"http_status_code": 400
},
"error_details": [
{ "key": "field", "details": "recipients[1].currency" },
{ "key": "issue", "details": "currency_mismatch" }
]
}A Step 1 error means nothing was reserved. Execution errors from Step 2 are listed under
Execution outcomes.
Validation Errors (400)
| Error Reason | Error Details | Description | Resolution |
|---|---|---|---|
ErrorMissingField | field: recipients, issue: missing | recipients is required | Send at least one recipient |
ErrorInvalidField | issue: max_exceeded, max: 50 | recipients exceeds maximum allowed | Split into batches of 50 |
ErrorInvalidField | field: recipients[i].type, expected: Crypto | Only crypto recipients are supported | Set type to Crypto |
ErrorInvalidField | field: recipients[i].currency, issue: currency_mismatch | All recipients must use the same currency | Send one currency per batch |
ErrorInvalidField | field: recipients[i], issue: invalid_recipient | Recipient details are invalid | The crypto object, crypto.address or crypto.network is missing or empty |
ErrorInvalidField | issue: invalid_format | Invalid value for amount | Send amount as a JSON number or a decimal string |
ErrorNotSupported | field: currency, issue: unsupported_for_chain | Invalid currency | No active token with that symbol on the corporation's chain. Read GET /api/v1/config/tokens |
ErrorInsufficientFunds | availableBalance: <base units>, failed_method: TryHoldBalanceV2 | Insufficient funds | The batch total exceeds the available balance, which excludes amounts reserved by earlier approvals. Top up or reduce the batch |
ErrorGeneral | amount: Amount must be greater than zero | Transaction amount must be greater than zero | The batch total is zero — every amount is below 0.01 or zero |
Permission Errors (403)
| Error Reason | Description | Resolution |
|---|---|---|
ErrorPermissionDenied | User does not have required permissions | The corporation token's employee needs TransactionCreate or TransactionSu |
Server Errors (500)
| Error Reason | Description | Resolution |
|---|---|---|
ErrorGeneral | Failed to get service tokens | The token catalogue was unavailable. Retry |
ErrorGeneral | Failed to request bulk transfer approval | The approval could not be completed. Read GET /api/v1/wallets before retrying — if the total shows as reserved, it is released after 24 hours |
Implementation Checklist
-
GET /api/v1/configread once per environment: ContractRegistry, bundler and paymaster for the chain - Amounts formatted to two decimals; addresses validated against
^0x[0-9a-fA-F]{40}$ - Step 1 response checked: every
precise_amountis what you intended and none is"0" -
operation_idstored against your payout records - Step 2 sent from the corporation wallet, signed by an employee key, retried on
Batch transaction not found - Step 2 transaction hash stored
- Reconciliation matches activity items by
operations[].hashanddestination.wallet.address - Approvals you abandon are accounted for: their total stays unavailable for 24 hours
Updated 15 days ago

