## Sandbox testing

The sandbox is a separate Talli environment with its own credentials and base URL, both provided by your Talli representative. It runs the same `/v3/distribution-manager` API as production, with test money.

The sandbox adds three endpoints that stand in for the actors your test cannot control: your bank, the bank partner, and the beneficiary's mailbox. In the routes below, `…` stands for `/v3/distribution-manager`.

| Endpoint | Stands in for |
|  --- | --- |
| [`POST …/simulate/bank-accounts/{bankAccountId}/:receive-ach`](#simulate-an-incoming-ach-deposit) | Your bank sending an inbound ACH credit to a Talli bank account. |
| [`POST …/simulate/payout-instructions/{payoutInstructionId}/:settle-ach`](#simulate-the-settlement-of-an-ach-payout) | The bank partner settling an ACH payout. |
| [`GET …/simulate/emails`](#read-the-emails-the-platform-sent) | The beneficiary's mailbox. |


In production these routes return `404`, and the API reference does not list them. The [endpoint reference](#endpoint-reference) below documents them in full.

## Test a payout end to end

These four steps are the same for every payout method. You need a distribution whose bank account is `Active` and which offers at least one payout method. The [Distributions quickstart](/docs/get-started/quickstart-distributions) shows how to find your distributions.

### 1. Find the bank account to fund

Every distribution draws its payouts from one bank account. Read it from the distribution:

```bash
curl --request GET \
  --url 'https://api.sandbox.talli.ai/v3/distribution-manager/distributions/YOUR_DISTRIBUTION_ID/account-details' \
  --header 'Authorization: Bearer YOUR_ACCESS_TOKEN'
```

Take `id` from the response — that is the `bankAccountId` you fund in step 2. `GET …/bank-accounts` lists every account you have. In production you would push a real ACH deposit to the account's `routingNumber` and `accountNumber`; in the sandbox you call `:receive-ach` instead.

### 2. Fund the bank account

```bash
curl --request POST \
  --url 'https://api.sandbox.talli.ai/v3/distribution-manager/simulate/bank-accounts/YOUR_BANK_ACCOUNT_ID/:receive-ach' \
  --header 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
  --header 'Content-Type: application/json' \
  --data '{ "amount": 25000.00, "idempotencyKey": "funding-1" }'
```

Send an `idempotencyKey`. A call that fails halfway can then be repeated safely: it resumes the same deposit instead of adding a second one.

The call returns `202` as soon as the bank partner accepts the deposit. Poll `GET …/bank-accounts/{id}` until `unallocatedBalance` covers the payouts you plan to send. It rises within seconds.

Fund the account whatever payout method you test. Every payout draws on it.

### 3. Create and initiate a payout instruction

`POST …/payout-instructions` creates it, `POST …/payout-instructions/{id}/:initiate` sends the beneficiary a claim email. See the API reference for both request bodies. The payout instruction is now `Initiated`.

### 4. Claim the payout as the beneficiary

```bash
curl --request GET \
  --url 'https://api.sandbox.talli.ai/v3/distribution-manager/simulate/emails?limit=50' \
  --header 'Authorization: Bearer YOUR_ACCESS_TOKEN'
```

Match the rows on `recipient` to find your beneficiary's mail, and take the newest `InitiateEmail` among them. Open the claim link in its `htmlBody`. Claim the payout as the beneficiary would. If the claim page asks for a one-time code, read it from the same endpoint — it arrives as an `OtpEmail`. Select a payout method and complete the claim.

The payout instruction moves to `Requested`. What happens next depends on the payout method the beneficiary selected:

- **ACH** waits for the bank partner to settle it. Finish it with the next section.
- **Every other payout method** has no simulate call. Poll `GET …/payout-instructions/{id}` and watch the status.


## Finish an ACH payout

An ACH payout is `Requested` once the platform has originated it at the bank partner. Settle it:

```bash
curl --request POST \
  --url 'https://api.sandbox.talli.ai/v3/distribution-manager/simulate/payout-instructions/YOUR_PAYOUT_INSTRUCTION_ID/:settle-ach' \
  --header 'Authorization: Bearer YOUR_ACCESS_TOKEN'
```

Poll `GET …/payout-instructions/{id}` until the status is `Fulfilled`. It follows within seconds.

A `409` with `The ACH payout has not been originated at the bank partner yet` means the payout has not reached the bank partner. Wait a few seconds and call again.

To test ACH payouts without the claim journey, use `POST …/distributions/{id}/payout-instructions/:direct-ach`. It creates the payout instruction from beneficiary and bank details you supply and sends no email, so it replaces steps 3 and 4. Settle it the same way.

## How the sandbox differs from production

- A simulate call returns `202` when the bank partner accepts it, not when Talli has applied the result. The result follows seconds later, so poll the resource you expect to change.
- Simulated settlement is instant. A real ACH deposit or payout takes a few business days.
- The sandbox has no ACH return window, so a settled payout reaches `Fulfilled` within seconds instead of several days later.


## Endpoint reference

### Simulate an incoming ACH deposit

```
POST /v3/distribution-manager/simulate/bank-accounts/{bankAccountId}/:receive-ach
```

Funds the bank account as an inbound ACH credit from your bank would: the bank partner receives, settles and releases the credit into the account.

| Field | Type | Description |
|  --- | --- | --- |
| `amount` | decimal | Required. The amount to deposit, in dollars. Positive, at most 2 decimal places. |
| `idempotencyKey` | string | Optional, at most 100 characters. Repeating a call with the same key, bank account and amount resumes an interrupted deposit instead of creating a second one. Without a key, every call creates a new deposit. |


| Code | Meaning |
|  --- | --- |
| `202` | The bank partner accepted the deposit. Empty body. |
| `400` | The amount is not positive, or has more than 2 decimal places. |
| `404` | No bank account with this id in your organization. |
| `409` | The bank account is not provisioned yet, or the idempotency key was already used for a different deposit. |
| `500` | The bank partner rejected a step of the simulation. `detail` names the step. Repeat the call with the same `idempotencyKey` to resume the deposit. A call sent without a key cannot be resumed, and repeating it deposits the amount a second time. |


### Simulate the settlement of an ACH payout

```
POST /v3/distribution-manager/simulate/payout-instructions/{payoutInstructionId}/:settle-ach
```

Settles the payout instruction's ACH transfer as the bank partner would. The payout instruction then completes through the normal pipeline. The request has no body.

| Code | Meaning |
|  --- | --- |
| `202` | The bank partner accepted the settlement. Empty body. |
| `404` | No payout instruction with this id in your organization. |
| `409` | The payout has no ACH transfer that can be settled. `detail` names the current state. A payout claimed with another payout method always answers `409`. |
| `500` | The bank partner rejected a step of the simulation. `detail` names the step. The call is safe to repeat. |


### Read the emails the platform sent

```
GET /v3/distribution-manager/simulate/emails?limit=10
```

Returns your organization's most recent emails, newest first, with claim links and one-time codes, so you can test the beneficiary journey without a real mailbox. `limit` is between 1 and 50 and defaults to 10; a value outside that range is rejected with `400`. There is no paging.

**Successful response example:**

```json
[
  {
    "id": "7c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
    "recipient": "beneficiary@example.com",
    "templateType": "InitiateEmail",
    "deliveryStatus": "Delivered",
    "createdOn": "2026-09-09T10:15:03.412Z",
    "sentOn": "2026-09-09T10:15:04.128Z",
    "payoutInstructionId": "1a2b3c4d-e5f6-7890-1234-567890abcdef",
    "subject": "Your payment is ready",
    "htmlBody": "<html>…<a href=\"https://claim.example.talli.ai/…\">Claim your payment</a>…</html>"
  }
]
```

| Field | Description |
|  --- | --- |
| `id` | Identifier of the email record. |
| `recipient` | The address the email was sent to. |
| `templateType` | `InitiateEmail`, `ReminderEmail`, `FailureReinitiateEmail`, `OtpEmail`, `PushEmail`, `InternalRequest` or `PayoutAvailableEmail`. |
| `deliveryStatus` | `Queued`, `Sending`, `Sent`, `Delivered`, `Opened`, `Bounced`, `SpamComplaint`, `SendingFailed` or `Cancelled`. |
| `createdOn` | When the platform created the email. The sort field. |
| `sentOn` | When the email was handed to the provider. For a `Queued`, `Sending`, `Cancelled` or `SendingFailed` email this is the planned send time instead. |
| `payoutInstructionId` | The payout instruction the email belongs to, where there is one. |
| `subject` | Subject line. |
| `htmlBody` | HTML body, including the claim link or the one-time code. |


`subject` and `htmlBody` are `null` while the email content is not available. For an email sent seconds ago, call again.

## Rate limits

The simulate endpoints carry their own limits, per organization. A simulate call spends the limit below instead of the general 60 / minute default described in [API Basics](/docs/get-started/api-basics).

| Endpoint(s) | Limit |
|  --- | --- |
| `POST …/simulate/bank-accounts/{id}/:receive-ach` | **10 / minute** |
| `POST …/simulate/payout-instructions/{id}/:settle-ach` | **10 / minute** |
| `GET …/simulate/emails` | **120 / minute** |