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:

GuideWhy
Getting StartedPlatform overview and setup
EnvironmentsGET /api/v1/config returns the ContractRegistry, bundler and paymaster for Step 2
AuthenticationHow to obtain the corporation token
Wallet DeploymentThe batch is executed by the corporation wallet — its validator and policy are what Step 2 signs against
PermissionsWho may approve and who may sign the execution
Wallets and BalancesThe 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
:

  1. 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.
  2. Execute — the corporation wallet calls FundsManagement.executeOutgoingBatchTransaction in 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 returned 200, 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
StepActionProducesConsumed by
1POST /api/v1/bulk/transfertransaction_id, operation_id, crypto_transfers[]Step 2
2executeOutgoingBatchTransaction from the corporation walletTransaction hashStep 3
3GET /api/v1/activity/feedOne Completed item per recipient, carrying the Step 2 hashYour 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

RequirementDetail
Corporation walletDeployed with CorporateValidator as the root validator's signer module and ExecutionDelayCorporatePolicy as its policy — see Wallet Deployment
Approving employeeThe corporation token belongs to an employee with TransactionSu or TransactionCreate (the Owner role has both through Su)
Signing keyThe 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
BalanceThe corporation wallet holds at least the batch total of the chosen token
Chain configurationchains.<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 createCorporation path, 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"
      }
    }
  ]
}
FieldTypeRequiredDescription
recipientsarrayYes1–50 entries. All must share the same currency
recipients[].typestringYesMust be Crypto. Any other value is rejected
recipients[].currencystringYesToken symbol, e.g. WEUR, WUSD, EURC, USDC. Use WUSD / WEUR for the unified tokens
recipients[].amountnumber or stringYesAmount 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.addressstringYesDestination EVM address
recipients[].crypto.networkstringYesMust 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"
    }
  ]
}
FieldDescription
transaction_idThe corporation id. The same value on every bulk transfer this corporation makes. Passed to Step 2 as-is
operation_idIdentifies 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[].addressDestination address as sent
crypto_transfers[].precise_amountApproved amount in the token's smallest unit, as a string. This is what Step 2 sends
crypto_transfers[].token_addressResolved 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.

MomentGET /api/v1/wallets balanceOn-chain balanceOfActivity feed
Before Step 120.0020.00—
After Step 1 (batch of 3.75)16.25 — reserved20.00Nothing
After Step 2—16.25One Completed item per recipient
Step 2 never sent16.25 for 24 hours, then 20.0020.00Nothing

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 with ErrorInsufficientFunds even
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 sentprecise_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_amount with what you intended before Step 2. A recipient below 0.01 is
approved for zero, and the request still returns 200.

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

ArgumentValue from Step 1
transactionIdtransaction_id with the dashes removed, as bytes16 — 0x3fa85f6457174562b3fc2c963f66afa6
operationIdoperation_id with the dashes removed, as bytes16
tokenAddresscrypto_transfers[0].token_address
recipientscrypto_transfers[].address, in response order
amountscrypto_transfers[].precise_amount as uint256, in response order

Two things must be right for the UserOperation to validate:

RequirementDetail
ValidatorThe 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)
AmountsThe 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 with Batch 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 reasonCauseResolution
Batch transaction not found — first seconds after Step 1The approval is not on-chain yetRetry after a few seconds
Batch transaction not found — after a successful executionThe batch was already executed. A batch executes onceNothing to do. Find the existing items in the activity feed
Batch transaction not found — persistentlytransaction_id / operation_id were not converted to bytes16 as described, or the operation was never approvedCheck the conversion; approve again if needed
Total amount mismatchamounts do not sum to the approved totalSend 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"
}
FieldDescription
destination.wallet.addressThe recipient
destination_amount.amount_preciseEquals that recipient's precise_amount from Step 1
operations[].hashThe Step 2 transaction hash — shared by every recipient of the batch
statusCompleted 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 Failed item 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
Completed ones 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, Card and every other recipient type is rejected.
  • 1–50 recipients per batch, one currency per batch. To pay in two tokens, send two batches.
  • Two decimal places of precision, truncated.
  • crypto.network is 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 ReasonError DetailsDescriptionResolution
ErrorMissingFieldfield: recipients, issue: missingrecipients is requiredSend at least one recipient
ErrorInvalidFieldissue: max_exceeded, max: 50recipients exceeds maximum allowedSplit into batches of 50
ErrorInvalidFieldfield: recipients[i].type, expected: CryptoOnly crypto recipients are supportedSet type to Crypto
ErrorInvalidFieldfield: recipients[i].currency, issue: currency_mismatchAll recipients must use the same currencySend one currency per batch
ErrorInvalidFieldfield: recipients[i], issue: invalid_recipientRecipient details are invalidThe crypto object, crypto.address or crypto.network is missing or empty
ErrorInvalidFieldissue: invalid_formatInvalid value for amountSend amount as a JSON number or a decimal string
ErrorNotSupportedfield: currency, issue: unsupported_for_chainInvalid currencyNo active token with that symbol on the corporation's chain. Read GET /api/v1/config/tokens
ErrorInsufficientFundsavailableBalance: <base units>, failed_method: TryHoldBalanceV2Insufficient fundsThe batch total exceeds the available balance, which excludes amounts reserved by earlier approvals. Top up or reduce the batch
ErrorGeneralamount: Amount must be greater than zeroTransaction amount must be greater than zeroThe batch total is zero — every amount is below 0.01 or zero

Permission Errors (403)

Error ReasonDescriptionResolution
ErrorPermissionDeniedUser does not have required permissionsThe corporation token's employee needs TransactionCreate or TransactionSu

Server Errors (500)

Error ReasonDescriptionResolution
ErrorGeneralFailed to get service tokensThe token catalogue was unavailable. Retry
ErrorGeneralFailed to request bulk transfer approvalThe 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/config read 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_amount is what you intended and none is "0"
  • operation_id stored 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[].hash and destination.wallet.address
  • Approvals you abandon are accounted for: their total stays unavailable for 24 hours

Did this page help you?