# Accept an ACH payment

Debit a bank account over the API, understand what an ACH approval promises and what it doesn't, and be ready for the return that can arrive days later.

4 steps, 2 API calls

**Products:** ACH, Payments, Transactions

Samples use {{API_KEY}} for your API key and {{BASE_URL}} for this instance's API address. Anything else in double braces is a value an earlier step gave you.

## Send the debit

### 1. Send the sale with check data

API call

`POST /api/transactions`

[Reference for this operation](https://devportal-simpay-sbx.winkpg.io/docs/api/transactionsCreate.md)

Send the same sale request you would send for a card, with a checkData block in place of the card block. The check data is what selects the ACH rail; there is no separate endpoint for it. The SEC code says under which NACHA authorization class you are debiting the account, and PPD is the ordinary choice for a personal account you hold a signed authorization for.

**Values this step gives you**

- `{{transactionId}}`: The id of the debit, from the response body's id property.
- `{{merchantId}}`: The merchant the debit belongs to, from the response body's merchantId property.

cURL:

```bash
curl -X POST "{{BASE_URL}}/api/transactions" \
  -H "api-key: {{API_KEY}}" \
  -H "Content-Type: application/json" \
  -d '{
    "transactionType": "Sale",
    "checkData": {
      "nameOnCheck": "Jane Doe",
      "routingNumber": "021000021",
      "accountNumber": "1234567890",
      "accountType": "Checking",
      "secCode": "Ppd"
    },
    "invoiceData": {
      "amounts": { "base": 25.00, "total": 25.00 }
    }
  }'
```

.NET:

```csharp
using var http = new HttpClient { BaseAddress = new Uri("{{BASE_URL}}") };
http.DefaultRequestHeaders.Add("api-key", "{{API_KEY}}");

var response = await http.PostAsJsonAsync("/api/transactions", new
{
    transactionType = "Sale",
    checkData = new
    {
        nameOnCheck = "Jane Doe",
        routingNumber = "021000021",
        accountNumber = "1234567890",
        accountType = "Checking",
        secCode = "Ppd"
    },
    invoiceData = new
    {
        amounts = new { @base = 25.00m, total = 25.00m }
    }
});

response.EnsureSuccessStatusCode();

var debit = await response.Content.ReadFromJsonAsync<JsonElement>();
var transactionId = debit.GetProperty("id").GetString();
var merchantId = debit.GetProperty("merchantId").GetString();
```

**What this step answers with** (HTTP 200)

Abridged to the properties this step depends on. A real response carries more.

```json
{
  "id": "9f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",
  "merchantId": "3a7b1c9d-2e4f-4a6b-8c8d-9e0f1a2b3c4d",
  "transactionType": "Sale",
  "resultCode": "Ok",
  "authorizedAmount": 25.00,
  "creationTime": "2026-02-04T18:22:41.517Z",
  "responseData": {
    "resultCode": "Ok",
    "resultMessage": "ACH Sale Approved",
    "secCode": "Ppd"
  }
}
```

### 2. Read the outcome off the response

On your side

The sandbox answers with the result code "Ok" and the message "ACH Sale Approved" in the response body. Read that as acceptance into the ACH network, not as a card-style authorization: no issuer checked a balance and no funds are held. The debit now clears through the network on its own schedule, and it can still come back as a return days later. Treat an accepted debit as money in flight rather than money received.

**What this step answers with** (HTTP 200)

Abridged to the properties this step depends on. A real response carries more.

```json
{
  "id": "{{transactionId}}",
  "merchantId": "3a7b1c9d-2e4f-4a6b-8c8d-9e0f1a2b3c4d",
  "transactionType": "Sale",
  "resultCode": "Ok",
  "authorizedAmount": 25.00,
  "creationTime": "2026-02-04T18:22:41.517Z",
  "responseData": {
    "resultCode": "Ok",
    "resultMessage": "ACH Sale Approved",
    "secCode": "Ppd"
  }
}
```

## Confirm what was accepted

### 3. Read the transaction back

API call

`GET /api/transactions/{{transactionId}}`

[Reference for this operation](https://devportal-simpay-sbx.winkpg.io/docs/api/transactionsGet.md)

Read the debit you just created. The create response and the stored transaction are the same record, and this record is the one a later return lands on. Reconcile against it rather than against the create response alone, so a request that times out on your side still has somewhere to recover the outcome from.

cURL:

```bash
curl "{{BASE_URL}}/api/transactions/{{transactionId}}" \
  -H "api-key: {{API_KEY}}"
```

.NET:

```csharp
var transaction = await http.GetFromJsonAsync<JsonElement>(
    $"/api/transactions/{transactionId}");

var result = transaction.GetProperty("responseData")
    .GetProperty("resultMessage").GetString();
```

**What this step answers with** (HTTP 200)

Abridged to the properties this step depends on. A real response carries more.

```json
{
  "id": "{{transactionId}}",
  "merchantId": "3a7b1c9d-2e4f-4a6b-8c8d-9e0f1a2b3c4d",
  "transactionType": "Sale",
  "resultCode": "Ok",
  "authorizedAmount": 25.00,
  "creationTime": "2026-02-04T18:22:41.517Z",
  "responseData": {
    "resultCode": "Ok",
    "resultMessage": "ACH Sale Approved",
    "secCode": "Ppd"
  }
}
```

## Be ready for the return

### 4. Handle the return that arrives later

On your side

On the live rail a return arrives days after the debit was accepted, long after this flow has finished. Build your integration so a transaction can leave an accepted state and enter a returned one: the record you read back above is the one the NACHA return code lands on. The return scenario below collapses that wait to a single sandbox call so you can prove the path now, and the webhook blueprint is how your system hears about a return without polling for it.

[Simulate an ACH return](https://devportal-simpay-sbx.winkpg.io/docs/blueprints/simulate-an-ach-return.md)

[Receive and verify webhooks](https://devportal-simpay-sbx.winkpg.io/docs/blueprints/receive-and-verify-webhooks.md)

## Run the whole flow as one script

Every step above in one script you can copy and run. Replace {{API_KEY}} with your own API key and {{BASE_URL}} with this instance's API address, and set any value the script asks you for at the top. A step that happens outside the API stays a comment.

### cURL

```bash
brew install jq
```

```bash
#!/usr/bin/env bash
# Accept an ACH payment
#
# Debit a bank account over the API, understand what an ACH approval promises and what it doesn't,
# and be ready for the return that can arrive days later.
#
# Every API call in this blueprint, in order. Each value a call returns is passed to the calls after
# it. A step that happens outside the API is a comment, and any failed call stops the script.

set -euo pipefail

BASE_URL="{{BASE_URL}}"
API_KEY="{{API_KEY}}"

# Sends one request and prints the response body. A failed call prints the API's answer and stops
# the script.
call() {
  local method="$1" path="$2" body="${3:-}" out
  local args=(-sS --fail-with-body -X "$method" "$BASE_URL$path" -H "api-key: $API_KEY")
  if [ -n "$body" ]; then
    args+=(-H "Content-Type: application/json" -d "$body")
  fi
  if ! out=$(curl "${args[@]}"); then
    printf '%s\n' "$out" >&2
    return 1
  fi
  printf '%s' "$out"
}

# Percent-encodes a value for use in a URL.
urlencode() {
  jq -rn --arg value "$1" '$value | @uri'
}

# Phase 1: Send the debit

# Step 1: Send the sale with check data
step1=$(call POST "/api/transactions" '{
  "transactionType": "Sale",
  "checkData": {
    "nameOnCheck": "Jane Doe",
    "routingNumber": "021000021",
    "accountNumber": "1234567890",
    "accountType": "Checking",
    "secCode": "Ppd"
  },
  "invoiceData": {
    "amounts": { "base": 25.00, "total": 25.00 }
  }
}')
transactionId=$(jq -er '.id | if . == null then error("The response carried no value for transactionId.") else tostring end' <<< "$step1")
merchantId=$(jq -er '.merchantId | if . == null then error("The response carried no value for merchantId.") else tostring end' <<< "$step1")

# Step 2: Read the outcome off the response
# The sandbox answers with the result code "Ok" and the message "ACH Sale Approved" in the response
# body. Read that as acceptance into the ACH network, not as a card-style authorization: no issuer
# checked a balance and no funds are held. The debit now clears through the network on its own
# schedule, and it can still come back as a return days later. Treat an accepted debit as money in
# flight rather than money received.

# Phase 2: Confirm what was accepted

# Step 3: Read the transaction back
call GET "/api/transactions/$(urlencode "$transactionId")" > /dev/null

# Phase 3: Be ready for the return

# Step 4: Handle the return that arrives later
# On the live rail a return arrives days after the debit was accepted, long after this flow has
# finished. Build your integration so a transaction can leave an accepted state and enter a returned
# one: the record you read back above is the one the NACHA return code lands on. The return scenario
# below collapses that wait to a single sandbox call so you can prove the path now, and the webhook
# blueprint is how your system hears about a return without polling for it.
```

### PowerShell

```powershell
# Accept an ACH payment
#
# Debit a bank account over the API, understand what an ACH approval promises and what it doesn't,
# and be ready for the return that can arrive days later.
#
# Every API call in this blueprint, in order. Each value a call returns is passed to the calls after
# it. A step that happens outside the API is a comment, and any failed call stops the script.

$ErrorActionPreference = 'Stop'

$baseUrl = '{{BASE_URL}}'
$apiKey = '{{API_KEY}}'

# Sends one request and returns the parsed response body. A failed call stops the script.
function Invoke-BlueprintCall([string] $Method, [string] $Path, [string] $Body) {
    $arguments = @{
        Method  = $Method
        Uri     = $baseUrl + $Path
        Headers = @{ 'api-key' = $apiKey }
    }
    if ($Body) {
        $arguments.ContentType = 'application/json'
        $arguments.Body = [System.Text.Encoding]::UTF8.GetBytes($Body)
    }
    Invoke-RestMethod @arguments
}

# Turns a value read off a response back into the text the API sent. A missing value stops the
# script.
function ConvertTo-CaptureValue($Value, [string] $Name) {
    if ($null -eq $Value) { throw "The response carried no value for $Name." }
    if ($Value -is [datetime] -and $Value.Kind -eq 'Unspecified') { return $Value.ToString('yyyy-MM-ddTHH:mm:ss.FFFFFFF', [cultureinfo]::InvariantCulture) }
    if ($Value -is [datetime]) { return $Value.ToUniversalTime().ToString('o') }
    if ($Value -is [bool]) { return $Value.ToString().ToLowerInvariant() }
    [string]$Value
}

# Phase 1: Send the debit

# Step 1: Send the sale with check data
$body = @'
{
  "transactionType": "Sale",
  "checkData": {
    "nameOnCheck": "Jane Doe",
    "routingNumber": "021000021",
    "accountNumber": "1234567890",
    "accountType": "Checking",
    "secCode": "Ppd"
  },
  "invoiceData": {
    "amounts": { "base": 25.00, "total": 25.00 }
  }
}
'@
$step1 = Invoke-BlueprintCall -Method 'POST' -Path '/api/transactions' -Body $body
$transactionId = ConvertTo-CaptureValue $step1.id 'transactionId'
$merchantId = ConvertTo-CaptureValue $step1.merchantId 'merchantId'

# Step 2: Read the outcome off the response
# The sandbox answers with the result code "Ok" and the message "ACH Sale Approved" in the response
# body. Read that as acceptance into the ACH network, not as a card-style authorization: no issuer
# checked a balance and no funds are held. The debit now clears through the network on its own
# schedule, and it can still come back as a return days later. Treat an accepted debit as money in
# flight rather than money received.

# Phase 2: Confirm what was accepted

# Step 3: Read the transaction back
$null = Invoke-BlueprintCall -Method 'GET' -Path "/api/transactions/$([uri]::EscapeDataString($transactionId))"

# Phase 3: Be ready for the return

# Step 4: Handle the return that arrives later
# On the live rail a return arrives days after the debit was accepted, long after this flow has
# finished. Build your integration so a transaction can leave an accepted state and enter a returned
# one: the record you read back above is the one the NACHA return code lands on. The return scenario
# below collapses that wait to a single sandbox call so you can prove the path now, and the webhook
# blueprint is how your system hears about a return without polling for it.
```

### TypeScript

```typescript
// Accept an ACH payment
//
// Debit a bank account over the API, understand what an ACH approval promises and what it doesn't,
// and be ready for the return that can arrive days later.
//
// Every API call in this blueprint, in order. Each value a call returns is passed to the calls
// after it. A step that happens outside the API is a comment, and any failed call stops the script.

export {};

const baseUrl = '{{BASE_URL}}';
const apiKey = '{{API_KEY}}';

// Sends one request and returns the parsed response body. A failed call throws.
async function call(method: string, path: string, body?: unknown): Promise<any> {
  const headers: Record<string, string> = { 'api-key': apiKey };
  if (body !== undefined) {
    headers['Content-Type'] = 'application/json';
  }

  const response = await fetch(baseUrl + path, {
    method,
    headers,
    body: body === undefined ? undefined : JSON.stringify(body),
  });

  const text = await response.text();
  if (!response.ok) {
    throw new Error(`${method} ${path} answered ${response.status}: ${text}`);
  }

  return text ? JSON.parse(text) : null;
}

// Turns a value read off a response into the text the API sent. A missing value throws.
function capture(value: unknown, name: string): string {
  if (value === undefined || value === null) {
    throw new Error(`The response carried no value for ${name}.`);
  }

  return typeof value === 'string' ? value : JSON.stringify(value);
}

// Phase 1: Send the debit

// Step 1: Send the sale with check data
const step1 = await call('POST', '/api/transactions', {
  "transactionType": "Sale",
  "checkData": {
    "nameOnCheck": "Jane Doe",
    "routingNumber": "021000021",
    "accountNumber": "1234567890",
    "accountType": "Checking",
    "secCode": "Ppd"
  },
  "invoiceData": {
    "amounts": { "base": 25.00, "total": 25.00 }
  }
});
const transactionId = capture(step1.id, 'transactionId');
const merchantId = capture(step1.merchantId, 'merchantId');

// Step 2: Read the outcome off the response
// The sandbox answers with the result code "Ok" and the message "ACH Sale Approved" in the response
// body. Read that as acceptance into the ACH network, not as a card-style authorization: no issuer
// checked a balance and no funds are held. The debit now clears through the network on its own
// schedule, and it can still come back as a return days later. Treat an accepted debit as money in
// flight rather than money received.

// Phase 2: Confirm what was accepted

// Step 3: Read the transaction back
await call('GET', `/api/transactions/${encodeURIComponent(transactionId)}`);

// Phase 3: Be ready for the return

// Step 4: Handle the return that arrives later
// On the live rail a return arrives days after the debit was accepted, long after this flow has
// finished. Build your integration so a transaction can leave an accepted state and enter a
// returned one: the record you read back above is the one the NACHA return code lands on. The
// return scenario below collapses that wait to a single sandbox call so you can prove the path now,
// and the webhook blueprint is how your system hears about a return without polling for it.
```

### C#

```csharp
// Accept an ACH payment
//
// Debit a bank account over the API, understand what an ACH approval promises and what it doesn't,
// and be ready for the return that can arrive days later.
//
// Every API call in this blueprint, in order. Each value a call returns is passed to the calls
// after it. A step that happens outside the API is a comment, and any failed call stops the script.

using System.Text;
using System.Text.Json;

var baseUrl = "{{BASE_URL}}";
var apiKey = "{{API_KEY}}";

using var http = new HttpClient();
http.DefaultRequestHeaders.Add("api-key", apiKey);

// Sends one request and returns the parsed response body. A failed call throws.
async Task<JsonElement> CallAsync(string method, string path, string? body = null)
{
    using var request = new HttpRequestMessage(new HttpMethod(method), baseUrl + path);
    if (body is not null)
    {
        request.Content = new StringContent(body, Encoding.UTF8, "application/json");
    }

    using var response = await http.SendAsync(request);
    var json = await response.Content.ReadAsStringAsync();
    if (!response.IsSuccessStatusCode)
    {
        throw new HttpRequestException($"{method} {path} answered {(int)response.StatusCode}: {json}");
    }

    return json.Length == 0 ? default : JsonSerializer.Deserialize<JsonElement>(json);
}

// Turns a value read off a response into the text the API sent. A missing value throws.
static string Capture(JsonElement value, string name) => value.ValueKind switch
{
    JsonValueKind.String => value.GetString()!,
    JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => value.GetRawText(),
    _ => throw new InvalidOperationException($"The response carried no value for {name}.")
};

// Phase 1: Send the debit

// Step 1: Send the sale with check data
var step1 = await CallAsync("POST", "/api/transactions", """
    {
      "transactionType": "Sale",
      "checkData": {
        "nameOnCheck": "Jane Doe",
        "routingNumber": "021000021",
        "accountNumber": "1234567890",
        "accountType": "Checking",
        "secCode": "Ppd"
      },
      "invoiceData": {
        "amounts": { "base": 25.00, "total": 25.00 }
      }
    }
    """);
var transactionId = Capture(step1.GetProperty("id"), "transactionId");
var merchantId = Capture(step1.GetProperty("merchantId"), "merchantId");

// Step 2: Read the outcome off the response
// The sandbox answers with the result code "Ok" and the message "ACH Sale Approved" in the response
// body. Read that as acceptance into the ACH network, not as a card-style authorization: no issuer
// checked a balance and no funds are held. The debit now clears through the network on its own
// schedule, and it can still come back as a return days later. Treat an accepted debit as money in
// flight rather than money received.

// Phase 2: Confirm what was accepted

// Step 3: Read the transaction back
await CallAsync("GET", $"/api/transactions/{Uri.EscapeDataString(transactionId)}");

// Phase 3: Be ready for the return

// Step 4: Handle the return that arrives later
// On the live rail a return arrives days after the debit was accepted, long after this flow has
// finished. Build your integration so a transaction can leave an accepted state and enter a
// returned one: the record you read back above is the one the NACHA return code lands on. The
// return scenario below collapses that wait to a single sandbox call so you can prove the path now,
// and the webhook blueprint is how your system hears about a return without polling for it.
```

### Python

```bash
pip install requests
```

```python
# Accept an ACH payment
#
# Debit a bank account over the API, understand what an ACH approval promises and what it doesn't,
# and be ready for the return that can arrive days later.
#
# Every API call in this blueprint, in order. Each value a call returns is passed to the calls after
# it. A step that happens outside the API is a comment, and any failed call stops the script.

from urllib.parse import quote

import json
import requests

BASE_URL = "{{BASE_URL}}"
API_KEY = "{{API_KEY}}"


# Sends one request and returns the parsed response body. A failed call raises.
def call(method, path, body=None):
    response = requests.request(
        method,
        BASE_URL + path,
        headers={"api-key": API_KEY},
        json=body,
    )
    response.raise_for_status()
    return response.json() if response.content else None


# Turns a value read off a response into the text the API sent. A missing value raises.
def capture(value, name):
    if value is None:
        raise ValueError(f"The response carried no value for {name}.")
    return value if isinstance(value, str) else json.dumps(value)


# Phase 1: Send the debit

# Step 1: Send the sale with check data
step1 = call("POST", "/api/transactions", {
  "transactionType": "Sale",
  "checkData": {
    "nameOnCheck": "Jane Doe",
    "routingNumber": "021000021",
    "accountNumber": "1234567890",
    "accountType": "Checking",
    "secCode": "Ppd"
  },
  "invoiceData": {
    "amounts": { "base": 25.00, "total": 25.00 }
  }
})
transaction_id = capture(step1["id"], "transactionId")
merchant_id = capture(step1["merchantId"], "merchantId")

# Step 2: Read the outcome off the response
# The sandbox answers with the result code "Ok" and the message "ACH Sale Approved" in the response
# body. Read that as acceptance into the ACH network, not as a card-style authorization: no issuer
# checked a balance and no funds are held. The debit now clears through the network on its own
# schedule, and it can still come back as a return days later. Treat an accepted debit as money in
# flight rather than money received.

# Phase 2: Confirm what was accepted

# Step 3: Read the transaction back
call("GET", f"/api/transactions/{quote(transaction_id, safe='')}")

# Phase 3: Be ready for the return

# Step 4: Handle the return that arrives later
# On the live rail a return arrives days after the debit was accepted, long after this flow has
# finished. Build your integration so a transaction can leave an accepted state and enter a returned
# one: the record you read back above is the one the NACHA return code lands on. The return scenario
# below collapses that wait to a single sandbox call so you can prove the path now, and the webhook
# blueprint is how your system hears about a return without polling for it.
```

- [Blueprints](https://devportal-simpay-sbx.winkpg.io/docs/blueprints.md): every blueprint this instance publishes.

## See also

- [All documentation](https://devportal-simpay-sbx.winkpg.io/llms.txt): the machine-readable index of every public page on this site.
