# Batch payments: integrate and test (https://docs.tryacme.com/guides/payment-lifecycle-and-statuses/batch-payments)

> For AI agents: the documentation index is at [llms.txt](https://docs.tryacme.com/llms.txt), with every page in one file at [llms-full.txt](https://docs.tryacme.com/llms-full.txt). Each page is also available as Markdown by appending `.md` to its URL.

Create a batch payment, handle the answer and its errors, and test it in test mode.

A batch payment sends many payments to the bank in one file (host-to-host). Use it when your bank
integration is host-to-host. A single payment, created with [Create a Payment](https://docs.tryacme.com/reference/post-payments),
goes to the bank over its API instead.

The payments in a batch share a payment type, a payment date and a sender account. Each payment
has its own `id` and status. For the statuses and webhooks, see
[Batch payment flow](https://docs.tryacme.com/guides/payment-lifecycle-and-statuses#batch-payment-flow).

## Create a batch payment [#create-a-batch-payment]

Send [Create a Batch Payment](https://docs.tryacme.com/reference/post-batch-payments) (`POST /v1/payment-batches`) with an
`Idempotency-Key` header, so you can retry it safely (see [Idempotency](https://docs.tryacme.com/guides/idempotency)).

This example is a DBS Singapore `MEPS` batch of two payments. The fields each payment needs depend on
the bank and the payment type: look them up in your bank's guide under
[Bank payment rules](https://docs.tryacme.com/guides/bank-payment-rules).

```json title="Request"
{
  "type": "MEPS",
  "paymentDate": "2026-11-10",
  "senderAccountId": "intacct_0KSEVXZ2ZF0AV",
  "currency": "SGD",
  "payments": [
    {
      "amount": 20000,
      "customerReference": "INV-1001",
      "paymentDetails": "10 boxes of 12 each",
      "bankChargeBearer": "SHARED",
      "receiver": {
        "name": "Donut Shop",
        "bank": "OCBCSGSGXXX",
        "bankAccountNumber": "123456789",
        "address": { "line1": "20 Side Street", "line2": "Unit 02-3A" }
      }
    },
    {
      "amount": 3000,
      "customerReference": "INV-1002",
      "bankChargeBearer": "SHARED",
      "receiver": {
        "name": "Coffee Shop",
        "bank": "UOVBSGSGXXX",
        "bankAccountNumber": "345678901",
        "address": { "line1": "127 Jln Merdeka" }
      }
    }
  ]
}
```

- `amount` is in minor units: `20000` is 200.00 SGD. See [Minor units format](https://docs.tryacme.com/guides/minor-units-format).
- `senderAccountCurrency` is required only when the sender account holds more than one currency in
  Acme.
- `currency` on a payment is optional and defaults to the batch `currency`. Only `TT`, `US_WIRE_INTL`,
  `ACT` and `BKTR` payments can use another currency.
- `customerReference` is optional. Acme generates one when you leave it out.
- For a `PAYNOW` or other proxy payment, send `receiver.proxyType` and `receiver.proxyValue` instead of
  a bank account. Your bank's guide lists which fields each type takes.

### Response [#response]

A `200` means Acme created the batch. It does not mean the bank has made the payments yet.

```json title="200 OK"
{
  "id": "bpmt_0KSEVXZ2ZF0AV",
  "type": "MEPS",
  "paymentDate": "2026-11-10",
  "senderAccountId": "intacct_0KSEVXZ2ZF0AV",
  "senderAccountCurrency": "SGD",
  "currency": "SGD",
  "status": "PROCESSING",
  "payments": [
    {
      "id": "pymt_0KSEVXZ2ZF0B0",
      "amount": 20000,
      "currency": "SGD",
      "customerReference": "INV-1001",
      "paymentDetails": "10 boxes of 12 each",
      "bankChargeBearer": "SHARED",
      "receiver": {
        "name": "Donut Shop",
        "bank": "OCBCSGSGXXX",
        "bankAccountNumber": "123456789",
        "address": { "line1": "20 Side Street", "line2": "Unit 02-3A" }
      },
      "status": "PROCESSING",
      "createdAt": "2026-11-06T02:15:04.000Z",
      "updatedAt": "2026-11-06T02:15:04.000Z"
    },
    {
      "id": "pymt_0KSEVXZ2ZF0B1",
      "amount": 3000,
      "currency": "SGD",
      "customerReference": "INV-1002",
      "bankChargeBearer": "SHARED",
      "receiver": {
        "name": "Coffee Shop",
        "bank": "UOVBSGSGXXX",
        "bankAccountNumber": "345678901",
        "address": { "line1": "127 Jln Merdeka" }
      },
      "status": "PROCESSING",
      "createdAt": "2026-11-06T02:15:04.000Z",
      "updatedAt": "2026-11-06T02:15:04.000Z"
    }
  ],
  "createdAt": "2026-11-06T02:15:04.000Z",
  "updatedAt": "2026-11-06T02:15:04.000Z"
}
```

> **Store each payment id**
>
> This response is the only one that lists the payments in the batch. [Get a Batch
> Payment](https://docs.tryacme.com/reference/get-batch-payments-id) and [List Batch
> Payments](https://docs.tryacme.com/reference/list-batch-payments) return the batch without its payments, and [List
> Payments](https://docs.tryacme.com/reference/get-payments) cannot filter by batch. Store each payment `id`, or match each
> payment webhook to its batch with `batchId`.

Each payment in the batch is also a Payment. [Get a Payment](https://docs.tryacme.com/reference/get-payments-id) returns it
with these extra fields:

| Field | What it holds |
| --- | --- |
| `batchId` | The batch the payment belongs to. |
| `paymentDate` | The payment date you sent. |
| `actualPaymentDate` | The date the bank used, when the bank changed it (for example after its cutoff). |
| `bankChargeAmount` and `bankChargeCurrency` | The bank's charge, when the bank reports one. Not every bank does. |

## Validation [#validation]

Acme checks the whole batch before it creates anything, and rejects the whole batch if any payment
fails. Every batch must:

- Have `type`, `paymentDate`, `senderAccountId` and `currency`, and at least one payment.
- Use a sender account of your organization, in the mode of your API key, that supports the payment
  type.
- Give `senderAccountCurrency`, one of the account's currencies, when the account holds more than one.
- Give each payment a positive whole `amount` and a `receiver`. The maximum amount depends on the
  bank and the payment type.
- Give `bankChargeBearer`, when sent, as `SENDER`, `RECEIVER` or `SHARED`.

A batch holds at most 1,000 payments for most banks, and fewer for some types (ANZ, for example).
Each bank adds its own rules: which currencies each type takes, which receiver fields are required or not allowed, and field lengths and characters. Your
bank's guide lists them under [Bank payment rules](https://docs.tryacme.com/guides/bank-payment-rules).

## Possible errors [#possible-errors]

For the `4xx` and `503` answers below, Acme created nothing, so nothing reached the bank. Only `400`
and `429` carry an `errorCode`.

| Status | What it means | What to do |
| --- | --- | --- |
| `400` | The request failed validation, or the sender account, its currency or the payment type is not valid. `errorCode` is `INVALID_REQUEST_PARAMETER`. See the example below. | Fix every listed field and send the whole batch again, with a new `Idempotency-Key`. |
| `401` | Empty body. The API key is missing, wrong or disabled, or the request came from an IP address your organization does not allow. | Check the API key (a test key for test mode, a live key for live mode). If it is right, contact Acme. |
| `403` | The API key does not have the Batch Payments permission. | Contact Acme. |
| `409` | Empty body. The `Idempotency-Key` was used in the last hour with a different request, or the first request with this key is still running. | Send the first request again, unchanged (the same body, byte for byte) and with the same key. If it is still running you get a `409` again: wait a few seconds and repeat. Otherwise you get its saved answer: a `200` means the batch exists; a `400` means nothing was created; a `500`: follow the `500` row. |
| `429` | `errorCode` `RATE_LIMIT_EXCEEDED`. Your API key sent too many requests ([Rate limits](https://docs.tryacme.com/guides/rate-limits)). Creating a batch does not call the bank, so this is never the bank's limit. | Wait for the `Retry-After` interval, then retry with the same `Idempotency-Key`. |
| `503` | `{"message": "Bank is under maintenance."}`. Live mode only: the bank, or this payment type, has a maintenance window. See [Scheduled maintenance](https://docs.tryacme.com/guides/scheduled-maintenance). | Send the batch again after the window ends. |
| `500` | An error at Acme. In rare cases (a database connection lost while the batch was being saved) the batch may still have been created. | Wait a minute, then check [List Batch Payments](https://docs.tryacme.com/reference/list-batch-payments) for a batch created at that time. If there is none, retry with a new `Idempotency-Key`: the same key can replay this answer for an hour. If it fails again, contact Acme with the time of the request. |

### Example `400` response [#example-400-response]

`paymentErrors` lists every payment field that failed, with the payment's position in `payments`
(from 0). `errorMessage` repeats one of the failures as `<field>: <rule>`.

```json title="400 Bad Request"
{
  "errorCode": "INVALID_REQUEST_PARAMETER",
  "errorMessage": "payments[0].currency: not supported, only SGD is supported",
  "paymentErrors": [
    { "index": 0, "field": "currency", "message": "not supported, only SGD is supported" },
    {
      "index": 1,
      "field": "receiver.address",
      "message": "must be provided for this type of payment"
    }
  ]
}
```

- `paymentErrors` is empty when only batch fields failed, for example a missing `paymentDate`.
- `paymentErrors` is left out when the body could not be read, or the sender account, its currency or
  the payment type is not valid. `errorMessage` then says what to fix, for example
  `Sender account not found. Please recheck or contact Acme.`

If you get no response at all (a timeout), send the same request again (the same body, byte for
byte) with the same `Idempotency-Key` within an hour. You get the original answer, and no second
batch is created. If that retry gets a `409`, the first request is still running: wait a few seconds
and retry again with the same key.

## Test mode [#test-mode]

Use your test API key to try your integration before going live. In test mode, Acme checks the batch
with the same rules as in live mode, then simulates the bank instead of sending a file. This works the
same for every bank.

### Steps [#steps]


#### Create a batch with your test key [#create-a-batch-with-your-test-key]

Send the request above with your test API key and a test sender account. Every payment succeeds,
except one sent to a receiver account number from the table below.


#### Wait for the simulated bank [#wait-for-the-simulated-bank]

Acme sends test batches on the same schedule as live ones. Expect `payment-batches.submitted`, then
the payment results, usually 10 to 25 minutes after you create the batch.


#### Check the webhooks and statuses [#check-the-webhooks-and-statuses]

Compare what you receive with the outcomes below, then check your system updated each payment.


### Test account numbers [#test-account-numbers]

Put one of these in `receiver.bankAccountNumber`. Any receiver bank works.

| `receiver.bankAccountNumber` | What happens |
| --- | --- |
| `000000000` or `000000001` | This payment is `FAILED`. The other payments in the batch still succeed. |
| `000000002` | The bank rejects the whole batch. The batch is `FAILED`, and so is every payment in it. |
| Any other number | The payment is `COMPLETED`. |

### What you receive [#what-you-receive]

| Batch | Webhooks, in order | Final status |
| --- | --- | --- |
| No `000000002` | `payment-batches.submitted` ([sample](https://docs.tryacme.com/reference/webhooks-payments#batch-payments)), then `payments.succeeded` and `payments.failed` for the payments ([samples](https://docs.tryacme.com/reference/webhooks-payments#payments-in-a-batch)) | Batch `COMPLETED`. Each payment `COMPLETED` or `FAILED`. |
| A payment to `000000002` | `payment-batches.submitted`, then `payment-batches.rejected`, then `payments.failed` for every payment | Batch `FAILED`. Every payment `FAILED`. |

- A failed test payment's `resultCode` depends on the sender account's bank. For DBS Singapore it is
  `OTHERS`.
- A batch that completes sends no webhook of its own. Check the payments.
- These payment types have no receiver account number, so a test payment of these types always
  succeeds: `AU_BPAY`, `MY_JOMPAY` and `GB_SEPA`.
- A proxy payment (`PAYNOW`, `HK_FPS_PROXY`, `MY_DUITNOW` by proxy) fails only if it also carries a test
  `bankAccountNumber`, which some banks' rules do not allow.
- Test mode has no maintenance windows, so it never answers `503`.

For other test values, such as for single payments and direct debit, see
[Test mode values](https://docs.tryacme.com/guides/special-test-mode-values).
