> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lead.bank/llms.txt
> Use this file to discover all available pages before exploring further.

# Sandbox testing

> Test ACH entries in sandbox: entries land in their first status and you move them one step at a time by naming the next status.

In production, an ACH entry moves through its lifecycle on its own, driven by Fed processing windows and the receiving bank. In sandbox, nothing is submitted to the Fed, so nothing moves on its own. Every entry, outgoing or incoming, starts in `scheduled` and stays there until you advance it. Each advance names the status you want next and moves the entry exactly one step. It returns the full updated entry and fires the webhook production fires for that status.

Before testing, review [ACH Statuses](https://docs.lead.bank/v2/products/ach/statuses.md) so you know which statuses to expect at each step.

## Simulation endpoints

These endpoints exist only in sandbox. In production they return `404`.

| Action | Endpoint |
| - | - |
| Advance an entry to the next status you name | `POST /v2/simulate/ach/{ach_id}/advance` |
| Create an incoming entry | `POST /v2/simulate/ach/incoming_ach` |
| Have the counterparty return one of your outgoing entries | `POST /v2/simulate/ach/{ach_id}/incoming_return` |

To return an incoming entry, dishonor a return, contest a dishonor, or cancel an entry, use the standard endpoints, as in production. See [Returns, reversals, and exceptions](https://docs.lead.bank/v2/products/ach/exceptions.md).

## Advancing an entry

`POST /v2/simulate/ach/{ach_id}/advance` takes a `simulation` object with one required field, `next_status`. One entry and one transition per call. You can't skip a status.

```json theme={null}
{
  "simulation": {
    "next_status": "processing"
  }
}
```

Lead checks only that the transition is allowed from the entry's current status for its direction. It doesn't re-run production validations. A successful call returns `200` with the full entry.

### Allowed transitions

The table lists every transition you can request.

| Direction | From | To | Webhook |
| - | - | - | - |
| Incoming | `scheduled` | `posted` | `ach.posted` |
| Outgoing | `scheduled` | `processing` | None |
| Outgoing | `processing` | `submitted` | `ach.submitted` |
| Outgoing | `submitted` | `posted` | `ach.posted` |
| Either | `pending_return` | `returned` | `ach.returned` |
| Either | `pending_dishonored_return` | `return_dishonored` | `ach.return_dishonored` |
| Either | `pending_contested_return` | `return_contested` | `ach.return_contested` |

The three `pending_` statuses are entered through the return, dishonor, and contest actions, not through `/advance`. You then advance the entry out of them.

### Errors

Every error uses the format described on [Errors](https://docs.lead.bank/api-reference/using-the-api/errors-v2.md). A rejected advance leaves the entry unchanged and sends no webhook.

| Scenario | Response |
| - | - |
| `next_status` missing, blank, or not a status value | `422 parameters_invalid`, `errors[].code` `schema_violation`, parameter `simulation.next_status` |
| A real status that isn't allowed from the current one | `422 object_state_mismatched`, `errors[].code` `status_transition_not_permitted`. The message names the current status, the status you asked for, and the statuses allowed next. |
| The entry is already in `next_status` | `422 object_state_mismatched`, `errors[].code` `object_status_unchanged` |
| The entry is in a terminal status | `422 operation_invalid`, `errors[].code` `object_status_terminal` |
| Two advances race on the same entry | The first commits. The second returns `409 object_modified_concurrently` or the transition error above. |
| The entry isn't visible to your program | `404 object_not_found` |

```json theme={null}
{
  "code": "object_state_mismatched",
  "message": "The object isn't in the state this request expected. Fetch its current state before you retry.",
  "errors": [
    {
      "code": "status_transition_not_permitted",
      "message": "ACH ach_3KrLe9Wc4OlYa7nZsU5xF2bR8t is processing, so it can't move to posted. Use one of submitted.",
      "parameters": ["next_status"]
    }
  ]
}
```

`/advance` takes no `Idempotency-Key`. Repeating a call that names the current status gets `object_status_unchanged`. Repeating one that names a now-allowed status is a new transition.

## Test an outgoing entry

Follow these steps:

1. Create the entry with `POST /v2/ach`. The response is the entry in `scheduled`. Synchronous create checks run exactly as in production: a request against an inactive account number fails with `422` and creates nothing.
2. Advance to `processing`. No webhook.
3. Advance to `submitted`. `ach.submitted` fires.
4. Advance to `posted`. `ach.posted` fires.

Advancing from `processing` straight to `posted` returns the `status_transition_not_permitted` error from the Errors table on this page.

## Test an incoming entry

`POST /v2/simulate/ach/incoming_ach` creates an entry as if another bank originated it. The request takes `account_number_id`, `sec_code`, `transaction_type`, `amount`, `currency_code`, `statement_descriptor`, `sender_name`, `sender_company_id`, and `sender_routing_number`. The response is the full entry in `scheduled`, and `ach.scheduled` fires. Advance it to `posted` to complete it.

## Test returns

You can test returns three ways:

* **You return an incoming entry.** Call `POST /v2/ach/{ach_id}/return` with a `return_code`. The entry moves to `pending_return`. Advance it to `returned`.
* **The counterparty returns your outgoing entry.** Call `POST /v2/simulate/ach/{ach_id}/incoming_return` with `return_code` and, optionally, `details`. The entry moves to `pending_return`, and the text you sent comes back as `returns[].details`. Advance it to `returned`.
* **Dishonor and contest.** Use `POST /v2/ach/{ach_id}/return` with a dishonor or contest code on the original entry, then advance out of the pending status it enters.

## What is and isn't real

Keep these differences from production in mind:

* No funds move, and Fed processing windows don't apply. You control timing entirely through `/advance`.
* An entry can't be steered into `under_review`, and a return, dishonor, or contest can't be rejected, in sandbox.
* Statuses, the `returns[]` block, and webhooks match production. A webhook fires only where production fires one, and never twice for one transition.
* v1 sandbox behavior is unchanged. Entries created on `/v1/ach` keep advancing the v1 way.

## Related

* [ACH Statuses](https://docs.lead.bank/v2/products/ach/statuses.md)
* [Sending ACH](https://docs.lead.bank/v2/products/ach/sending-ach.md)
* [Receiving ACH](https://docs.lead.bank/v2/products/ach/receiving-ach.md)
* [Returns, reversals, and exceptions](https://docs.lead.bank/v2/products/ach/exceptions.md)
* [Errors](https://docs.lead.bank/api-reference/using-the-api/errors-v2.md)
