ACH
RDFI
Receive and process incoming ACH transactions as an RDFI
Overview
An RDFI (Receiving Depository Financial Institution) is the financial institution that receives ACH transactions on behalf of a receiver. When operating as an RDFI, the Twisp ACH processor handles incoming ACH files, validates transactions, updates account balances, and generates return files when necessary.
The ACH RDFI processor enables you to:
- Receive and process ACH credit and debit transactions
- Apply transactions to customer accounts with proper ledger accounting
- Handle exceptions through suspense and exception accounts
- Generate return files for transactions that cannot be processed
- Maintain complete audit trails through workflow execution tracking
Getting Started
Prerequisites
Before processing RDFI files, you need:
- ACH Configuration - Created via
Mutation.ach.createConfiguration() - Required Accounts - Settlement, suspense, and exception accounts (a fee account unless the configuration is RDFI-only, and a pending account when using auto-pending)
- Webhook Endpoint - For receiving transaction decisioning requests (optional in auto-pending mode)
- Journal - For posting transactions
Quick Start Example
# 1. Create webhook endpoint for ACH decisioning
mutation CreateEndpoint {
events {
createEndpoint(
input: {
endpointId: "b84512f1-a67e-4dc2-94dd-66c48b4d13fb"
status: ENABLED
endpointType: ACH_PROCESSOR
url: "https://your-domain.com/webhooks/ach"
subscription: []
description: "ACH RDFI webhook processor"
}
) {
endpointId
}
}
}
# 2. Create required accounts
mutation CreateAccounts {
# Settlement account - where funds transit
settlement: createAccount(
input: {
accountId: "37f7e8a6-171f-411d-ad59-7b1f40f505ea"
code: "settlement.ach"
name: "ACH Settlement"
normalBalanceType: DEBIT
config: {
enableConcurrentPosting: true
}
}
) {
accountId
}
# Suspense account - for transactions to unknown accounts
suspense: createAccount(
input: {
accountId: "3171b0c2-6e9f-41aa-a5a6-ee927deb27cf"
code: "suspense.ach"
name: "ACH Suspense"
config: {
enableConcurrentPosting: true
}
}
) {
accountId
}
# Exception account - for failed transactions
exception: createAccount(
input: {
accountId: "4a8f2b1e-3c9d-4f7e-a5b6-1d8e9f0a2b3c"
code: "exception.ach"
name: "ACH Exception"
config: {
enableConcurrentPosting: true
}
}
) {
accountId
}
# Fee account - for ACH processing fees
fee: createAccount(
input: {
accountId: "5b9e3c2f-4d0e-5a8f-b6c7-2e9f0a1b3c4d"
code: "fee.ach"
name: "ACH Fee Income"
normalBalanceType: CREDIT
config: {
enableConcurrentPosting: true
}
}
) {
accountId
}
}
# 3. Create a journal for ACH transactions
mutation CreateJournal {
createJournal(
input: {
journalId: "8d7e6f5a-4b3c-2d1e-0f9a-8b7c6d5e4f3a"
name: "ACH Processing Journal"
status: ACTIVE
}
) {
journalId
}
}
# 4. Create ACH configuration
mutation CreateACHConfig {
ach {
createConfiguration(
input: {
configId: "b96d358e-50b8-4ae5-8b07-2e8f33f396c6"
endpointId: "b84512f1-a67e-4dc2-94dd-66c48b4d13fb"
journalId: "8d7e6f5a-4b3c-2d1e-0f9a-8b7c6d5e4f3a"
settlementAccountId: "37f7e8a6-171f-411d-ad59-7b1f40f505ea"
suspenseAccountId: "3171b0c2-6e9f-41aa-a5a6-ee927deb27cf"
exceptionAccountId: "4a8f2b1e-3c9d-4f7e-a5b6-1d8e9f0a2b3c"
feeAccountId: "5b9e3c2f-4d0e-5a8f-b6c7-2e9f0a1b3c4d"
odfiHeaderConfiguration: {
immediateDestination: "021000021"
immediateDestinationName: "Your Bank Name"
immediateOrigin: "1234567890"
immediateOriginName: "Your Company Name"
}
timeZone: "America/New_York"
}
) {
configId
version
}
}
}
RDFI Workflow
1. Upload ACH File
When you receive an ACH file from the Fed or your upstream processor, upload it to Twisp:
mutation CreateUpload {
files {
createUpload(
input: {
key: "incoming-ach-20251114.ach"
uploadType: ACH
contentType: "text/plain"
}
) {
uploadURL
}
}
}
Upload the file using the returned URL:
curl -T incoming-ach-20251114.ach -XPUT '<uploadURL>'
2. Process ACH File
Start processing the uploaded file:
mutation ProcessFile {
ach {
processFile(
input: {
configId: "b96d358e-50b8-4ae5-8b07-2e8f33f396c6"
fileKey: "incoming-ach-20251114.ach"
fileType: RDFI
}
) {
fileId
}
}
}
3. Monitor File Processing
Check the status of file processing:
query GetFileStatus {
ach {
file(
fileKey: "incoming-ach-20251114.ach"
configId: "b96d358e-50b8-4ae5-8b07-2e8f33f396c6"
) {
fileId
processingStatus
processingDetail
hasExceptions
processingStatistics {
numEntriesUnprocessed
totalCreditAmount
totalDebitAmount
}
}
}
}
Processing Status Values:
NEW- File created, not yet processingUPLOADED- File uploaded and queuedVALIDATING- File format validation in progressPARTITIONING- Preparing for parallel processingPROCESSING- Routing entries and requesting decisions for forward entries (auto-pending: posting entries to the pending account)PROCESSED- All entries processed, awaiting settlementsPENDING- Auto-pending entries posted, awaiting manual settle/returnCOMPLETED- All transactions settled or returnedERROR- Unrecoverable error occurredINVALID- File failed validation
For RDFI files, hasExceptions: true means that at least one unmatched regular return was posted to the configured exception account. Matched returns and NOCs do not set this field.
4. Route Entries and Handle Forward-Entry Webhooks
An inbound RDFI file can mix forward entries, regular returns, and NOCs. Twisp routes each record according to its addenda before invoking webhook decisioning:
| Record | Processing behavior | Webhook |
|---|---|---|
| Forward credit or debit | Creates an RDFI workflow for settlement or return decisioning | Yes |
| Regular return with a matching original trace | Executes RETURN on the original ODFI workflow and associates its trace with the inbound return record | No |
| Regular return without an eligible original workflow | Posts an idempotent transaction to the exception account and sets hasExceptions on the file | No |
| Dishonored return with a matching return trace | Executes DISHONOR on the original RDFI workflow. In auto-pending mode, the settled posting goes to the pending account; otherwise it goes to the workflow's current account. | Yes, when the configuration has an endpoint and auto-pending is disabled |
| Dishonored return without a matching return trace | Posts the entry to the exception account. No workflow execution is created, so it cannot be contested through workflow.executeTask | No |
| Contested dishonored return | Posts the entry to the exception account because Twisp does not originate ODFI dishonored returns that an inbound contest could match | No |
| NOC or refused NOC | Skips the informational record without ledger activity | No |
Regular returns are matched within the ACH configuration by the original trace number in the Addenda 99 record. If multiple workflows share that trace, Twisp selects one that has not already processed RETURN. If no trace matches, or all matching workflows have already returned, Twisp treats the entry as an unmatched return and posts it to the exception account.
This routing applies to regular Addenda 99 returns. See Dishonored Returns and Contests for the dishonor flow.
For each forward entry, Twisp sends a webhook to your endpoint. You must respond with instructions on how to handle the transaction. (Configurations with auto-pending enabled skip this step entirely — every forward entry posts as PENDING automatically.)
Webhook Payload Format:
{
"workflowName": "ACH.RDFI.CR",
"workflowTask": "CREATE",
"executionId": "60f7ac42-ff72-48c7-af58-ee1f9a2db1e0",
"configurationId": "3a1b9c52-7d44-4f0e-9c1a-2b6e8f4d10aa",
"fileId": "8e2c4f17-5b9a-4d3e-8f21-9a7c6b3d5e02",
"fileKey": "incoming/ach/20251114/payroll.ach",
"fileHeader": {
"id": "file-header-id",
"immediateDestination": "021000021",
"immediateOrigin": "1234567890",
"fileCreationDate": "251114",
"fileCreationTime": "1030",
"fileIDModifier": "A",
"immediateDestinationName": "Your Bank Name",
"immediateOriginName": "Originating Company",
"referenceCode": ""
},
"batchHeader": {
"id": "batch-id",
"serviceClassCode": "220",
"companyName": "PAYROLL CO",
"companyDiscretionaryData": "",
"companyIdentification": "1234567890",
"standardEntryClassCode": "PPD",
"companyEntryDescription": "PAYROLL",
"companyDescriptiveDate": "",
"effectiveEntryDate": "251115",
"settlementDate": " ",
"originatorStatusCode": "1",
"odfiIdentification": "12345678",
"batchNumber": "0000001"
},
"entryDetail": {
"id": "entry-id",
"transactionCode": "22",
"rdfiIdentification": "02100002",
"checkDigit": "1",
"dfiAccountNumber": "123456789",
"amount": "150000",
"identificationNumber": "employee-123",
"individualName": "John Doe",
"discretionaryData": "",
"addendaRecordIndicator": "0",
"traceNumber": "123456780000001",
"category": "Forward"
}
}
The payload identifies the source of the transaction so you can correlate webhooks back to the file and configuration that produced them:
configurationIdis the ACH configuration that processed the file.fileIdis the unique identifier of the file. Use it to look up the file (and group all of its entries) via the file operations APIs.fileKeyis the storage key of the file as it was received.executionIduniquely identifies this entry's processing workflow.
Response Format:
You respond with an action that tells Twisp how to handle the transaction. The action you return determines which state the entry moves into:
| Action | What it represents | When to use |
|---|---|---|
SETTLE | Accept the transaction. Twisp encumbers the funds on CREATE and automatically settles them at when (or the batch effective date). | The standard path for a transaction you can accept and let Twisp settle on schedule. |
PENDING | Accept the transaction but hold it. Twisp posts the encumbrance and then stops — it does not auto-settle. You settle or return the entry yourself later, and while it is held you can move the hold to a different account. | You can accept the funds now but need to finish out-of-band review (fraud, compliance, manual approval) before they settle. |
RETURN | Reject the transaction. Twisp reverses any encumbrance and records the return so it can be included in a return file. Provide an addenda99 return code. | The transaction cannot be accepted — insufficient funds, closed or invalid account, unauthorized, etc. |
RETRY | Defer the decision. Twisp redelivers the same webhook with exponential backoff. | Your system is temporarily unavailable or you need more time to decide. |
Option 1: Settle (Accept Transaction)
{
"action": "SETTLE",
"accountId": "d2f7183f-8e9c-45e7-9a98-ef1897ddb930",
"when": "2025-11-15T00:00:00.000Z",
"metadata": {
"customerId": "cust-123",
"transactionType": "payroll"
},
"entryMetadata": {
"customerId": "cust-123"
}
}
whenis optional. If omitted, uses the effective date from the batch header- If
whenis in the past, the transaction settles immediately metadatais optional and attached to the ledger transactionentryMetadatais optional and attached to the ledger entries
Option 2: Pending (Accept and Hold)
Respond with PENDING to accept the transaction into a pending hold layer without settling it. Twisp posts the encumbrance but does not schedule automatic settlement — you decide later whether to settle or return the entry.
{
"action": "PENDING",
"accountId": "d2f7183f-8e9c-45e7-9a98-ef1897ddb930",
"metadata": {
"review": "manual_fraud_check",
"holdReason": "large_first_time_deposit"
},
"entryMetadata": {
"customerId": "cust-123"
}
}
- The funds are held on the encumbrance layer (
SYS_ACH_ENCUMBRANCE_*ledger codes), keeping them separate from settled, available balances. - Twisp does not auto-settle a pending entry — it waits until you resolve it.
- Resolve the entry by executing a
SETTLEorRETURNtask on its workflow execution, using theexecutionIdfrom the webhook payload and the workflowcodefrom the webhook'sworkflowName(ACH.RDFI.CR→ACH_RDFI_CR,ACH.RDFI.DR→ACH_RDFI_DR):
mutation ResolvePendingEntry {
workflow {
executeTask(
input: {
code: "ACH_RDFI_CR" # or "ACH_RDFI_DR" for debit entries
executionId: "60f7ac42-ff72-48c7-af58-ee1f9a2db1e0"
task: "SETTLE" # or "RETURN"
}
) {
executionId
task
}
}
}
Use this when you can accept the funds now but need to complete additional review before they become available.
Moving a pending hold to a different account
While an entry is pending, you can move the held funds to a different account by executing the PENDING task again with a new accountId. Twisp reverses the encumbrance on the current account and reposts it against the new account — on the same encumbrance layer, atomically:
mutation MovePendingEntry {
workflow {
executeTask(
input: {
code: "ACH_RDFI_CR" # or "ACH_RDFI_DR" for debit entries
executionId: "60f7ac42-ff72-48c7-af58-ee1f9a2db1e0"
task: "PENDING"
params: {
accountId: "1f0e2d3c-4b5a-6978-8695-a4b3c2d1e0f9"
}
}
) {
executionId
task
}
}
}
- Only entries whose current task is
PENDINGcan be moved. Entries that were accepted withSETTLE, or that have already settled or returned, reject the move with an invalid state transition error. - A subsequent
SETTLEorRETURNacts on the account currently holding the funds. - Moves can be repeated — each one relocates the hold, including back to a previously used account.
- Re-executing
PENDINGwith the entry's currentaccountIdis a no-op only when theeffectivedate is also unchanged. - The reposted hold may take a new
effectivedate. The reversal that voids the prior entry always posts at that entry's own effective date; when noeffectiveis supplied, the repost keeps the current one. - Allowed
paramsareaccountId,effective,metadata, andentryMetadata; anything else is rejected. - The destination cannot be the ACH configuration's settlement account.
Option 3: Return (Reject Transaction)
{
"action": "RETURN",
"accountId": "d2f7183f-8e9c-45e7-9a98-ef1897ddb930",
"addenda99": {
"returnCode": "R01",
"addendaInformation": "Insufficient Funds"
},
"metadata": {
"reason": "account_balance_insufficient"
}
}
Common Return Codes:
R01- Insufficient FundsR02- Account ClosedR03- No Account / Unable to Locate AccountR04- Invalid Account NumberR05- Unauthorized Debit to Consumer AccountR07- Authorization Revoked by CustomerR08- Payment StoppedR10- Customer Advises Not Authorized
See complete return code reference
Option 4: Retry (Temporary Error)
{
"action": "RETRY"
}
Use RETRY when:
- Your system is temporarily unavailable
- You need more time to make a decision
- There's a transient error in your processing
Twisp will exponentially back off and retry the webhook.
5. Generate Return File
After processing is complete, generate a return file for any transactions you rejected:
mutation GenerateReturnFile {
ach {
generateFile(
input: {
configId: "b96d358e-50b8-4ae5-8b07-2e8f33f396c6"
fileKey: "return-20251114.ach"
fileType: RDFI_RETURN
generateEmpty: false
}
) {
fileKey
generated
}
}
}
generateEmpty: false means the file is only created if there are returns to include.
Use options.fileHeaderReferenceCode to set the file header's Reference Code field (positions 87-94), for example to match the header layout of files you originate outside Twisp. Up to 8 printable ASCII characters, with no leading or trailing spaces; space-filled when omitted. See File Header Reference Code for details.
Use options.fileModifier to set the file header's file ID modifier (position 34). A single character from A-Z or 0-9, required when the ACH configuration sets userSupplied on its File Modifier Configuration and rejected otherwise. See Supplying the File ID Modifier for details.
Return files are unbalanced by default. If your financial institution requires balanced files, set offsetConfiguration with enableBalancedReturnNOCs: true on the ACH configuration — a trailing offset batch then balances the file's totals. See Offset Configuration for details.
6. Download Return File
Download the generated return file:
mutation DownloadReturn {
files {
createDownload(
key: "return-20251114.ach"
) {
downloadURL
}
}
}
Then download using the URL:
curl '<downloadURL>' -o return-20251114.ach
Transmit this file to the originating ODFI via your normal file transmission process (SFTP, etc.).
Auto Pending Mode
Auto-pending is a hands-off alternative to webhook decisioning. When an ACH configuration has autoPending: true, processing an RDFI file sends no create webhooks — every entry is automatically posted as PENDING to the configuration's pending account, where it waits for you to settle or return it out-of-band.
Use auto-pending when you want to receive files without operating a decisioning endpoint: entries land in a single pending account, and your reconciliation process resolves them on its own schedule.
Configuration
mutation CreateAutoPendingConfig {
ach {
createConfiguration(
input: {
configId: "c07e469f-61c9-4bf6-9c18-3f9a44f407d7"
journalId: "8d7e6f5a-4b3c-2d1e-0f9a-8b7c6d5e4f3a"
settlementAccountId: "37f7e8a6-171f-411d-ad59-7b1f40f505ea"
suspenseAccountId: "3171b0c2-6e9f-41aa-a5a6-ee927deb27cf"
exceptionAccountId: "4a8f2b1e-3c9d-4f7e-a5b6-1d8e9f0a2b3c"
direction: RDFI
autoPending: true
pendingAccountId: "6c0f4d3e-5e1f-6b9a-c7d8-3f0a1b2c4d5e"
odfiHeaderConfiguration: {
immediateDestination: "021000021"
immediateDestinationName: "Your Bank Name"
immediateOrigin: "1234567890"
immediateOriginName: "Your Company Name"
}
timeZone: "America/New_York"
}
) {
configId
version
}
}
}
Note what is different from a standard configuration:
pendingAccountIdis required — every entry in every file posts here.pendingAccountIdmust be different from the settlement, suspense, and exception accounts.endpointIdis optional — auto-pending sends no webhooks, even when an endpoint is configured.feeAccountIdis optional because this configuration isdirection: RDFI(fees only apply to ODFI operations).directionmust beRDFIorBOTH; enabling auto-pending on anODFIconfiguration is a validation error.
Processing Behavior
Upload and process files exactly as in the RDFI workflow above. The differences begin after partitioning:
- Each entry posts an encumbrance to the pending account — the same posting a webhook
PENDINGresponse with that account would produce. - Once all entries are posted, the file transitions
PROCESSING → PENDING(instead ofPROCESSED). - The file holds in
PENDINGwhile Twisp monitors the entries. When every entry has been settled or returned, the file transitions toCOMPLETED.
Resolving Pended Entries
Without webhooks, you discover each entry's executionId from the file's records via Query.ach.file():
query PendedEntries {
ach {
file(fileKey: "incoming-ach-20251114.ach", configId: "c07e469f-61c9-4bf6-9c18-3f9a44f407d7") {
processingStatus
records(first: 1000) {
nodes {
execution {
executionId
task
}
}
}
}
}
}
Entries with task: "PENDING" await resolution. Settle or return each one by executing a task on its workflow execution — the same mechanism used to resolve a webhook-pended entry:
mutation ResolveEntry {
workflow {
executeTask(
input: {
executionId: "60f7ac42-ff72-48c7-af58-ee1f9a2db1e0"
task: "SETTLE" # or "RETURN"
}
) {
executionId
task
}
}
}
Returned entries are queued for the next return file generation, exactly as in the standard flow.
Exceptions
- If posting to the pending account fails permanently (for example, the account is locked), the entry is posted to the suspense account and queued for return — no webhook is involved.
Dishonored Returns and Contests
When an inbound dishonored return matches a return previously sent by Twisp, Twisp runs the DISHONOR task on the original RDFI workflow. Normally, Twisp then sends a dishonor webhook when the ACH configuration has an endpoint. Auto-pending configurations send no webhooks: the dishonor posts on the settled layer of the pending account and remains at DISHONOR until you move or contest it through workflow.executeTask.
The initial DISHONOR task accepts an optional accountId on any RDFI configuration, posting the dishonored return directly to that account. Auto-pending uses this capability to select the configured pending account.
After routing, the dishonor webhook reports the account currently holding the dishonored return, and a later CONTEST records that same holding account on the queued contested-return item. It does not preserve the original forward entry's account as a separate field.
The dishonor webhook uses the normal ACH webhook endpoint with these contest-specific fields:
{
"workflowName": "ACH.RDFI.CR",
"workflowTask": "DISHONOR",
"executionId": "60f7ac42-ff72-48c7-af58-ee1f9a2db1e0",
"accountId": "d2f7183f-8e9c-45e7-9a98-ef1897ddb930",
"addenda99Dishonored": {
"dishonoredReturnReasonCode": "R68",
"returnTraceNumber": "123456780000002"
}
}
workflowTaskisDISHONOR.executionIdidentifies the original RDFI workflow. Use it if you contest the dishonor later throughworkflow.executeTask.accountIdis the account currently holding the dishonored return.addenda99Dishonoredcontains the dishonored-return details received in the file.
Moving a dishonored return to a different account
While a workflow is at DISHONOR, you can move its settled posting to a different account by executing DISHONOR again with a new accountId. Twisp reverses the live dishonor posting on the current account and reposts it against the new account on the same settled layer:
In auto-pending mode, the dishonored return initially posts to the configured pending account even though no dishonor webhook is delivered. Name the destination account explicitly when moving it out of that account.
mutation MoveDishonoredReturn {
workflow {
executeTask(
input: {
code: "ACH_RDFI_CR" # or "ACH_RDFI_DR" for debit entries
executionId: "60f7ac42-ff72-48c7-af58-ee1f9a2db1e0"
task: "DISHONOR"
params: {
accountId: "1f0e2d3c-4b5a-6978-8695-a4b3c2d1e0f9"
}
}
) {
executionId
task
}
}
}
- A subsequent
CONTESTacts on the account currently holding the funds. - Moves can be repeated, including back to a previously used account.
- Re-executing
DISHONORwith the currentaccountIdis a no-op only when theeffectivedate is also unchanged. - The reposted transaction may take a new
effectivedate. The reversal always posts at the prior transaction's own effective date; when noeffectiveis supplied, the repost keeps the current one. - The only allowed
paramsareaccountIdandeffective. - The destination cannot be the ACH configuration's settlement account.
- Unlike a pending-account move, a dishonor move rejects
metadataandentryMetadata. The reposted transaction keeps the original dishonor's annotations. - Every move voids the moved-from transaction before reposting, including when the original entry had already settled. On that already-settled path, a later contest is a separate live contra posting and does not void the current dishonor transaction.
Contest immediately from the webhook
Return CONTEST to create the contested return immediately:
{
"action": "CONTEST",
"metadata": {
"decision": "contest"
}
}
The webhook response cannot set addenda99Contested. Twisp chooses the contested return code from the dishonored return code:
| Dishonored code | Default contested code |
|---|---|
R62 | R77 |
R67 | R75 |
R68 | R73 |
R69 | R74 |
R70 | R76 |
R61 has no single default contested code. To contest an R61, or to choose a different valid code for any dishonor, use the workflow task path below.
Contest with a chosen reason code
Do not return action: "CONTEST" from the dishonor webhook. A response with no action leaves the workflow at DISHONOR:
{}
Then execute the CONTEST task with the executionId from the webhook. Put the reason code inside params.addenda99Contested:
mutation ContestDishonoredReturn {
workflow {
executeTask(
input: {
code: "ACH_RDFI_CR" # or "ACH_RDFI_DR" for debit entries
executionId: "60f7ac42-ff72-48c7-af58-ee1f9a2db1e0"
task: "CONTEST"
params: {
addenda99Contested: {
contestedReturnCode: "R71"
}
metadata: {
decision: "misrouted_dishonor"
}
}
}
) {
executionId
task
}
}
}
For CONTEST, the caller-settable top-level params are addenda99Contested, effective, metadata, and entryMetadata. effective is an optional YYYY-MM-DD posting date. Twisp reads the account and dishonored-return details from the existing workflow context. Do not put contestedReturnCode directly under params; it must be nested under addenda99Contested as shown above.
The addenda99Contested object accepts:
| Field | Required | Behavior |
|---|---|---|
contestedReturnCode | No | Must be R71, R72, R73, R74, R75, R76, or R77. Twisp keeps the code you provide. When omitted, Twisp uses the default mapping above. It is required for an R61 dishonor. |
typeCode | No | When provided, must be 99. Twisp sets it to 99 in the generated file. |
originalSettlementDate | No | Must be a three-digit ACH Julian day from 001 through 366. For a matched dishonor, Twisp uses the authoritative settlement date from the original entry. |
Twisp derives all trace numbers, return codes, and settlement dates from the matched original entry, return, and dishonored return. Do not send those derived fields. Unknown fields return an error instead of being ignored.
If you omit addenda99Contested, the workflow uses the same default mapping as an immediate webhook contest:
params: {}
The contest is added to the next generated RDFI return file. IAT contested returns are not supported.
Transaction Lifecycle and Ledger Accounting
The RDFI processor uses a three-stage workflow with double-entry accounting at each stage.
Stage 1: CREATE (Initial Encumbrance)
When a transaction webhook is received, an encumbrance is created on the target account:
For Credits (Incoming Deposits):
DR Settlement Account (Encumbrance Layer)
CR Customer Account (Encumbrance Layer)
For Debits (Outgoing Withdrawals):
DR Customer Account (Encumbrance Layer)
CR Settlement Account (Encumbrance Layer)
The encumbrance layer reserves funds but doesn't affect available balance. This allows you to:
- Track expected funds before they settle
- Maintain visibility of in-flight transactions
- Reconcile with external ACH reports
Stage 2: SETTLE (Final Settlement)
When you respond with "action": "SETTLE", two things happen:
- Reverse the encumbrance:
Opposite of CREATE entries with negative amounts
- Post to settled layer:
DR/CR Customer Account (Settled Layer)
DR/CR Settlement Account (Settled Layer)
The settled layer represents final, available balances that customers can access.
Stage 3: RETURN (Rejection)
When you respond with "action": "RETURN", the transaction is reversed:
- Reverse the encumbrance (same as SETTLE step 1)
- Post return to settled layer (opposite direction of a normal settlement)
- Queue for return file generation
Returns are included in the next return file you generate via Mutation.ach.generateFile().
Balance Layer Illustration
┌─────────────────────────────────────────────────┐
│ ENCUMBRANCE Layer │
│ • In-flight ACH transactions │
│ • Not available to customer │
│ • Tracks expected debits/credits │
└─────────────────────────────────────────────────┘
↓ SETTLE
┌─────────────────────────────────────────────────┐
│ SETTLED Layer │
│ • Final, available balance │
│ • Customer can withdraw/spend │
│ • Appears in balance queries │
└─────────────────────────────────────────────────┘
Querying Balances
Check account balances across all layers:
query GetAccountBalance {
balance(
accountId: "d2f7183f-8e9c-45e7-9a98-ef1897ddb930"
journalId: "8d7e6f5a-4b3c-2d1e-0f9a-8b7c6d5e4f3a"
currency: "USD"
) {
settled {
crBalance {
units
currency
}
drBalance {
units
currency
}
}
pending {
crBalance {
units
currency
}
drBalance {
units
currency
}
}
encumbrance {
crBalance {
units
currency
}
drBalance {
units
currency
}
}
version
}
}
Available Balance Calculation:
Available = Settled - Pending - Encumbrance (for debits)
Return Generation
Create returns for transactions that cannot be processed:
Return Decision Logic
Automatic and manual return triggers:
- Insufficient Funds: Account balance insufficient for debit
- Account Closed: Target account no longer active
- Invalid Account: Account number not found
- Unauthorized: Transaction not authorized by account holder
- Stop Payment: Account holder placed stop payment order
Return Codes
Select appropriate return code:
- R01: Insufficient Funds
- R02: Account Closed
- R03: No Account / Unable to Locate Account
- R04: Invalid Account Number
- R05: Unauthorized Debit to Consumer Account (improper authorization)
- R07: Authorization Revoked by Customer
- R08: Payment Stopped
- R10: Customer Advises Not Authorized
- R29: Corporate Customer Advises Not Authorized
Return Timing
Return deadlines by code:
- 2 Business Days: Most return codes (R01-R04, R07-R08, etc.)
- 60 Calendar Days: Unauthorized returns (R05, R07, R10, R29)
- Next Business Day: Same-day ACH returns
Return File Generation
Create NACHA return files:
- Return Entry: Create return detail record in Twisp
- Return Batch: Group returns in batches
- Return File Generation: Generate complete NACHA return file via Files API
- File Download: Retrieve generated return file from Twisp
- File Transmission: You transmit return file to originating ODFI via SFTP/FTPS
Monitoring and Observability
Query Files by Status
Find all files in a specific processing state:
query GetProcessingFiles {
ach {
files(
first: 100
index: { name: PROCESSING_STATUS }
where: {
configId: { eq: "b96d358e-50b8-4ae5-8b07-2e8f33f396c6" }
processingStatus: { eq: "PROCESSING" }
created: { gte: "2025-11-01T00:00:00Z" }
}
) {
nodes {
fileId
fileKey
processingStatus
processingDetail
hasExceptions
processingStatistics {
numEntriesUnprocessed
totalCreditAmount
totalDebitAmount
}
created
modified
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
After processing, review files where hasExceptions is true and reconcile their unmatched-return transactions in the exception account. Those transactions use the inbound file ID as their correlation ID and retain the complete parsed RDFI workflow entry in their transaction and entry metadata.
Track Workflow Execution
Forward entries create RDFI workflow executions that you can query. A matched return updates the original ODFI workflow execution instead of creating a forward-entry decisioning execution. NOCs and unmatched returns do not create one.
query GetWorkflowExecution {
workflow {
execution(
executionId: "60f7ac42-ff72-48c7-af58-ee1f9a2db1e0"
) {
workflowId
executionId
task
params
output {
state
}
activities {
action
entityType
entityId
entity {
... on Transaction {
transactionId
effective
description
}
... on AchWorkflowTrace {
traceNumber
fileId
configId
}
}
}
created
modified
version
}
}
}
The activities field shows all transactions and ACH traces created by this workflow, giving you complete auditability.
Exception Handling
Suspense Account
When a transaction targets an account that doesn't exist, it's automatically posted to your configured suspenseAccountId. This allows you to:
- Accept the transaction (avoiding a return)
- Research the correct account
- Create a manual journal entry to move funds to the correct account
Example scenario:
- ACH credit arrives for account number "123456789"
- Account doesn't exist in your system
- Transaction is posted:
DR Settlement Account CR Suspense Account - You investigate and find the correct account is "123456790"
- You create a journal entry to move the funds:
DR Suspense Account CR Correct Customer Account
Exception Account
When a transaction fails due to velocity controls, account state issues, or other processing errors, it's posted to your exceptionAccountId. An inbound regular return is also posted there when Twisp cannot find an originated workflow with a matching original trace number. Common scenarios:
- Velocity Control Violation: Transaction exceeds configured velocity limits
- Account Locked: Target account is frozen or locked
- Processing Error: Temporary system issue
- Unmatched Return: No ODFI workflow matches the return's original trace number, or all matching workflows have already returned
Unmatched returns use transaction code SYS_ACH_UNKNOWN_RETURN_CR or SYS_ACH_UNKNOWN_RETURN_DR, post against the settlement account at the SETTLED layer, and set hasExceptions on the file. The posting is idempotent using the entry record ID as its transaction ID, including for zero-dollar returns. The inbound file ID is the correlation ID, and the complete parsed RDFI workflow entry is available in transaction and entry metadata for reconciliation.
Funds from rejected forward entries should typically be returned to the originator via a return file. Unmatched inbound returns require manual investigation and reconciliation against the exception account.
Velocity Control Integration
The RDFI processor enforces any velocity controls attached to the target account. Enforcement happens when the entry is posted — at the ENCUMBRANCE layer during CREATE and at the SETTLED layer during SETTLE.
If a velocity control is tripped, Twisp automatically executes a return rather than posting the transaction. This applies at both states:
- At CREATE, if encumbering the funds would exceed a limit, the entry is returned instead of being accepted (regardless of the
SETTLE/PENDINGaction you responded with). - At SETTLE, if final settlement would exceed a limit, the entry is returned at settlement time.
This provides built-in protection against overdrafts and unauthorized transactions without requiring you to track balances yourself.
Overriding enforcement for a specific entry: the metadata and entryMetadata you return on the webhook response are attached to the transaction and its entries, and are visible to a velocity control's condition; a CEL expression that decides whether an entry is eligible to apply to the limit. By writing a condition that reads these values, you can exempt specific entries from a control (for example, tagging entryMetadata.override: "approved" and excluding those entries in the limit's condition).
Practical Examples
Example 1: Basic RDFI Setup and Processing
Complete flow from setup to settlement:
# Step 1: Setup (run once)
mutation Setup {
# Create webhook endpoint
endpoint: events {
createEndpoint(
input: {
endpointId: "webhook-001"
status: ENABLED
endpointType: ACH_PROCESSOR
url: "https://api.yourcompany.com/ach/webhook"
subscription: []
}
) { endpointId }
}
# Create journal
journal: createJournal(
input: {
journalId: "journal-001"
name: "ACH Journal"
status: ACTIVE
}
) { journalId }
# Create accounts (abbreviated)
settlement: createAccount(
input: {
accountId: "acct-settlement"
code: "settlement"
name: "ACH Settlement"
normalBalanceType: DEBIT
config: { enableConcurrentPosting: true }
}
) { accountId }
# Create ACH config
config: ach {
createConfiguration(
input: {
configId: "config-001"
endpointId: "webhook-001"
journalId: "journal-001"
settlementAccountId: "acct-settlement"
suspenseAccountId: "acct-suspense"
exceptionAccountId: "acct-exception"
feeAccountId: "acct-fee"
odfiHeaderConfiguration: {
immediateDestination: "021000021"
immediateDestinationName: "Federal Reserve Bank"
immediateOrigin: "1234567890"
immediateOriginName: "Your Company"
}
timeZone: "America/New_York"
}
) { configId }
}
}
# Step 2: Upload file (when received from Fed)
mutation UploadFile {
files {
createUpload(
input: {
key: "incoming-20251114-001.ach"
uploadType: ACH
contentType: "text/plain"
}
) { uploadURL }
}
}
# Use uploadURL to PUT file content
# Step 3: Process file
mutation ProcessFile {
ach {
processFile(
input: {
configId: "config-001"
fileKey: "incoming-20251114-001.ach"
fileType: RDFI
}
) { fileId }
}
}
# Step 4: Monitor processing
query MonitorFile {
ach {
file(
fileKey: "incoming-20251114-001.ach"
configId: "config-001"
) {
processingStatus
processingStatistics {
numEntriesUnprocessed
totalCreditAmount
totalDebitAmount
}
}
}
}
# Step 5: Generate returns (after webhooks complete)
mutation GenerateReturns {
ach {
generateFile(
input: {
configId: "config-001"
fileKey: "return-20251114-001.ach"
fileType: RDFI_RETURN
generateEmpty: false
}
) {
fileKey
generated
}
}
}
# Step 6: Download returns
mutation DownloadReturns {
files {
createDownload(
key: "return-20251114-001.ach"
) { downloadURL }
}
}
Example 2: Webhook Handler Implementation
Sample webhook handler in Node.js:
app.post('/ach/webhook', async (req, res) => {
const { workflowName, workflowTask, executionId, entryDetail } = req.body;
try {
// Extract transaction details
const accountNumber = entryDetail.dfiAccountNumber;
const amount = parseFloat(entryDetail.amount) / 100; // Amount is in cents
const isDebit = entryDetail.transactionCode.startsWith('2'); // 22, 23, 24
const isCredit = entryDetail.transactionCode.startsWith('3'); // 32, 33, 34
// Look up customer account
const account = await findAccountByNumber(accountNumber);
if (!account) {
// Account not found - will go to suspense
return res.json({
action: 'SETTLE',
accountId: SUSPENSE_ACCOUNT_ID,
metadata: {
reason: 'account_not_found',
originalAccountNumber: accountNumber
}
});
}
if (account.status === 'CLOSED') {
// Account closed - return with R02
return res.json({
action: 'RETURN',
accountId: account.id,
addenda99: {
returnCode: 'R02',
addendaInformation: 'Account Closed'
}
});
}
if (isDebit) {
// Check balance for debits
const balance = await getAccountBalance(account.id);
if (balance < amount) {
return res.json({
action: 'RETURN',
accountId: account.id,
addenda99: {
returnCode: 'R01',
addendaInformation: 'Insufficient Funds'
}
});
}
}
// All checks passed - settle the transaction
return res.json({
action: 'SETTLE',
accountId: account.id,
when: new Date().toISOString(), // Settle immediately
metadata: {
customerId: account.customerId,
originalTraceNumber: entryDetail.traceNumber
}
});
} catch (error) {
console.error('Webhook processing error:', error);
// Retry on errors
return res.json({
action: 'RETRY'
});
}
});
API Reference
GraphQL Operations
Configuration:
Query.ach.configuration(id: UUID!)- Get ACH configurationQuery.ach.configurations(first: Int!)- List all configurationsMutation.ach.createConfiguration(input: AchCreateConfigurationInput!)- Create configurationMutation.ach.updateConfiguration(configId: UUID!, input: AchUpdateConfigurationInput!)- Update configuration
File Operations:
Query.ach.file(id: UUID, fileKey: String, configId: UUID)- Get file statusQuery.ach.files(index: AchFileInfoIndexInput!, where: AchFileInfoFilterInput!, first: Int!)- Query filesMutation.ach.processFile(input: AchProcessFileInput!)- Process uploaded fileMutation.ach.generateFile(input: AchGenerateFileInput!)- Generate return/NOC fileMutation.files.createUpload(input: CreateUploadInput!)- Get upload URLMutation.files.createDownload(key: String!)- Get download URL
Workflow Operations:
Query.workflow.execution(executionId: UUID!)- Get workflow execution detailsMutation.workflow.executeTask(input: WorkflowInput!)- Settle or return a pending entry, move a pending hold, or contest a dishonored return
Return Codes Reference
When returning a transaction, use the appropriate return code in the addenda99.returnCode field.
Standard Return Codes
| Code | Reason | Description | Timing |
|---|---|---|---|
R01 | Insufficient Funds | Available balance is not sufficient to cover the dollar value of the debit entry | 2 business days |
R02 | Account Closed | Previously active account has been closed by customer or RDFI | 2 business days |
R03 | No Account/Unable to Locate Account | Account number structure is valid and passes editing process, but does not correspond to individual or is not an open account | 2 business days |
R04 | Invalid Account Number | Account number structure not valid; entry may fail check digit validation or may contain an incorrect number of digits | 2 business days |
R05 | Improper Debit to Consumer Account | A CCD, CTX, or CBR debit entry was transmitted to a Consumer Account of the Receiver and was not authorized by the Receiver | 60 days |
R06 | Returned per ODFI's Request | ODFI has requested RDFI to return the ACH entry (optional to RDFI - ODFI indemnifies RDFI) | 2 business days |
R07 | Authorization Revoked by Customer | Consumer, who previously authorized ACH payment, has revoked authorization from Originator | 60 days |
R08 | Payment Stopped | Receiver of a recurring debit transaction has stopped payment to a specific ACH debit | 2 business days |
R09 | Uncollected Funds | Sufficient book or ledger balance exists to satisfy dollar value of the transaction, but the dollar value of transaction is in process of collection | 2 business days |
R10 | Customer Advises Originator is Not Known to Receiver and/or Originator is Not Authorized | The receiver does not know the Originator's identity and/or has not authorized the Originator to debit | 60 days |
R11 | Customer Advises Entry Not in Accordance with the Terms of the Authorization | The Originator and Receiver have a relationship, and an authorization to debit exists, but there is an error or defect in the payment | 60 days |
R12 | Branch Sold to Another DFI | Financial institution receives entry destined for an account at a branch that has been sold to another financial institution | 2 business days |
R13 | RDFI not qualified to participate | Financial institution does not receive commercial ACH entries | 2 business days |
R14 | Representative payee deceased or unable to continue in that capacity | The representative payee authorized to accept entries on behalf of a beneficiary is either deceased or unable to continue in that capacity | 2 business days |
R15 | Beneficiary or bank account holder deceased | (1) the beneficiary entitled to payments is deceased or (2) the bank account holder other than a representative payee is deceased | 2 business days |
R16 | Bank account frozen | Funds in bank account are unavailable due to action by RDFI or legal order | 2 business days |
R17 | File record edit criteria | Fields rejected by RDFI processing (identified in return addenda) | 2 business days |
R20 | Non-payment bank account | Entry destined for non-payment bank account defined by regulation | 2 business days |
R23 | Credit entry refused by receiver | Receiver returned entry because minimum or exact amount not remitted, bank account is subject to litigation, or payment represents an overpayment | 2 business days |
R29 | Corporate customer advises not authorized | RDFI has been notified by corporate receiver that debit entry of originator is not authorized | 2 business days |
Best Practices
Return Timing:
- Most returns must be sent within 2 business days of settlement date
- Unauthorized returns (R05, R07, R10, R11) can be returned up to 60 days after settlement
- Same-day ACH returns must be sent by the same business day
Common Scenarios:
// Insufficient funds
{
"action": "RETURN",
"accountId": "account-id",
"addenda99": {
"returnCode": "R01",
"addendaInformation": "Insufficient Funds"
}
}
// Account closed
{
"action": "RETURN",
"accountId": "account-id",
"addenda99": {
"returnCode": "R02",
"addendaInformation": "Account Closed"
}
}
// Account not found
{
"action": "RETURN",
"accountId": "account-id",
"addenda99": {
"returnCode": "R03",
"addendaInformation": "No Account"
}
}
// Unauthorized transaction
{
"action": "RETURN",
"accountId": "account-id",
"addenda99": {
"returnCode": "R10",
"addendaInformation": "Not Authorized"
}
}
Best Practices
Security
- Webhook Authentication: Validate webhook signatures to ensure requests are from Twisp
- HTTPS Only: Always use HTTPS endpoints for webhooks
- Idempotency: Handle duplicate webhooks gracefully using
executionId
Performance
- Fast Webhook Response: Respond to webhooks within 30 seconds
- Async Processing: Queue webhook processing if complex logic is needed
- Retry Logic: Implement exponential backoff for webhook retries
Monitoring
- Alert on Status Changes: Monitor file processing status for errors
- Track Return Rates: High return rates may indicate data quality issues
- Balance Reconciliation: Daily reconciliation of settlement account
Compliance
- Return Timeframes: Adhere to NACHA return deadlines (2 days for most codes)
- Authorization Records: Maintain proof of authorization for debits
- Transaction History: Keep complete audit trail for 7 years
- Reg E Compliance: Honor consumer dispute rights (60-day investigation period)
Further Reading
For practical guidance on receiving ACH transactions:
- Handling ACH Returns and NOCs - Return processing and NOC management
- Reconciling ACH Files - File validation and reconciliation procedures
- Processing ACH Payments - Risk management and best practices
For additional technical details:
- Configuration - RDFI configuration parameters
- File Operations - File upload and parsing API specifications
- ODFI Reference - Origination perspective
- ACH Processor - Conceptual overview