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:

  1. ACH Configuration - Created via Mutation.ach.createConfiguration()
  2. Required Accounts - Settlement, suspense, and exception accounts (a fee account unless the configuration is RDFI-only, and a pending account when using auto-pending)
  3. Webhook Endpoint - For receiving transaction decisioning requests (optional in auto-pending mode)
  4. 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 processing
  • UPLOADED - File uploaded and queued
  • VALIDATING - File format validation in progress
  • PARTITIONING - Preparing for parallel processing
  • PROCESSING - Routing entries and requesting decisions for forward entries (auto-pending: posting entries to the pending account)
  • PROCESSED - All entries processed, awaiting settlements
  • PENDING - Auto-pending entries posted, awaiting manual settle/return
  • COMPLETED - All transactions settled or returned
  • ERROR - Unrecoverable error occurred
  • INVALID - 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:

RecordProcessing behaviorWebhook
Forward credit or debitCreates an RDFI workflow for settlement or return decisioningYes
Regular return with a matching original traceExecutes RETURN on the original ODFI workflow and associates its trace with the inbound return recordNo
Regular return without an eligible original workflowPosts an idempotent transaction to the exception account and sets hasExceptions on the fileNo
Dishonored return with a matching return traceExecutes 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 tracePosts the entry to the exception account. No workflow execution is created, so it cannot be contested through workflow.executeTaskNo
Contested dishonored returnPosts the entry to the exception account because Twisp does not originate ODFI dishonored returns that an inbound contest could matchNo
NOC or refused NOCSkips the informational record without ledger activityNo

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:

  • configurationId is the ACH configuration that processed the file.
  • fileId is the unique identifier of the file. Use it to look up the file (and group all of its entries) via the file operations APIs.
  • fileKey is the storage key of the file as it was received.
  • executionId uniquely 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:

ActionWhat it representsWhen to use
SETTLEAccept 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.
PENDINGAccept 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.
RETURNReject 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.
RETRYDefer 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"
  }
}
  • when is optional. If omitted, uses the effective date from the batch header
  • If when is in the past, the transaction settles immediately
  • metadata is optional and attached to the ledger transaction
  • entryMetadata is 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 SETTLE or RETURN task on its workflow execution, using the executionId from the webhook payload and the workflow code from the webhook's workflowName (ACH.RDFI.CRACH_RDFI_CR, ACH.RDFI.DRACH_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 PENDING can be moved. Entries that were accepted with SETTLE, or that have already settled or returned, reject the move with an invalid state transition error.
  • A subsequent SETTLE or RETURN acts 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 PENDING with the entry's current accountId is a no-op only when the effective date is also unchanged.
  • The reposted hold may take a new effective date. The reversal that voids the prior entry always posts at that entry's own effective date; when no effective is supplied, the repost keeps the current one.
  • Allowed params are accountId, effective, metadata, and entryMetadata; 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 Funds
  • R02 - Account Closed
  • R03 - No Account / Unable to Locate Account
  • R04 - Invalid Account Number
  • R05 - Unauthorized Debit to Consumer Account
  • R07 - Authorization Revoked by Customer
  • R08 - Payment Stopped
  • R10 - 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:

  • pendingAccountId is required — every entry in every file posts here.
  • pendingAccountId must be different from the settlement, suspense, and exception accounts.
  • endpointId is optional — auto-pending sends no webhooks, even when an endpoint is configured.
  • feeAccountId is optional because this configuration is direction: RDFI (fees only apply to ODFI operations).
  • direction must be RDFI or BOTH; enabling auto-pending on an ODFI configuration is a validation error.

Processing Behavior

Upload and process files exactly as in the RDFI workflow above. The differences begin after partitioning:

  1. Each entry posts an encumbrance to the pending account — the same posting a webhook PENDING response with that account would produce.
  2. Once all entries are posted, the file transitions PROCESSING → PENDING (instead of PROCESSED).
  3. The file holds in PENDING while Twisp monitors the entries. When every entry has been settled or returned, the file transitions to COMPLETED.

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"
  }
}
  • workflowTask is DISHONOR.
  • executionId identifies the original RDFI workflow. Use it if you contest the dishonor later through workflow.executeTask.
  • accountId is the account currently holding the dishonored return.
  • addenda99Dishonored contains 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 CONTEST acts on the account currently holding the funds.
  • Moves can be repeated, including back to a previously used account.
  • Re-executing DISHONOR with the current accountId is a no-op only when the effective date is also unchanged.
  • The reposted transaction may take a new effective date. The reversal always posts at the prior transaction's own effective date; when no effective is supplied, the repost keeps the current one.
  • The only allowed params are accountId and effective.
  • The destination cannot be the ACH configuration's settlement account.
  • Unlike a pending-account move, a dishonor move rejects metadata and entryMetadata. 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 codeDefault contested code
R62R77
R67R75
R68R73
R69R74
R70R76

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:

FieldRequiredBehavior
contestedReturnCodeNoMust 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.
typeCodeNoWhen provided, must be 99. Twisp sets it to 99 in the generated file.
originalSettlementDateNoMust 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:

  1. Reverse the encumbrance:
Opposite of CREATE entries with negative amounts
  1. 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:

  1. Reverse the encumbrance (same as SETTLE step 1)
  2. Post return to settled layer (opposite direction of a normal settlement)
  3. 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:

  1. Accept the transaction (avoiding a return)
  2. Research the correct account
  3. 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/PENDING action 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 configuration
  • Query.ach.configurations(first: Int!) - List all configurations
  • Mutation.ach.createConfiguration(input: AchCreateConfigurationInput!) - Create configuration
  • Mutation.ach.updateConfiguration(configId: UUID!, input: AchUpdateConfigurationInput!) - Update configuration

File Operations:

  • Query.ach.file(id: UUID, fileKey: String, configId: UUID) - Get file status
  • Query.ach.files(index: AchFileInfoIndexInput!, where: AchFileInfoFilterInput!, first: Int!) - Query files
  • Mutation.ach.processFile(input: AchProcessFileInput!) - Process uploaded file
  • Mutation.ach.generateFile(input: AchGenerateFileInput!) - Generate return/NOC file
  • Mutation.files.createUpload(input: CreateUploadInput!) - Get upload URL
  • Mutation.files.createDownload(key: String!) - Get download URL

Workflow Operations:

  • Query.workflow.execution(executionId: UUID!) - Get workflow execution details
  • Mutation.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

CodeReasonDescriptionTiming
R01Insufficient FundsAvailable balance is not sufficient to cover the dollar value of the debit entry2 business days
R02Account ClosedPreviously active account has been closed by customer or RDFI2 business days
R03No Account/Unable to Locate AccountAccount number structure is valid and passes editing process, but does not correspond to individual or is not an open account2 business days
R04Invalid Account NumberAccount number structure not valid; entry may fail check digit validation or may contain an incorrect number of digits2 business days
R05Improper Debit to Consumer AccountA CCD, CTX, or CBR debit entry was transmitted to a Consumer Account of the Receiver and was not authorized by the Receiver60 days
R06Returned per ODFI's RequestODFI has requested RDFI to return the ACH entry (optional to RDFI - ODFI indemnifies RDFI)2 business days
R07Authorization Revoked by CustomerConsumer, who previously authorized ACH payment, has revoked authorization from Originator60 days
R08Payment StoppedReceiver of a recurring debit transaction has stopped payment to a specific ACH debit2 business days
R09Uncollected FundsSufficient book or ledger balance exists to satisfy dollar value of the transaction, but the dollar value of transaction is in process of collection2 business days
R10Customer Advises Originator is Not Known to Receiver and/or Originator is Not AuthorizedThe receiver does not know the Originator's identity and/or has not authorized the Originator to debit60 days
R11Customer Advises Entry Not in Accordance with the Terms of the AuthorizationThe Originator and Receiver have a relationship, and an authorization to debit exists, but there is an error or defect in the payment60 days
R12Branch Sold to Another DFIFinancial institution receives entry destined for an account at a branch that has been sold to another financial institution2 business days
R13RDFI not qualified to participateFinancial institution does not receive commercial ACH entries2 business days
R14Representative payee deceased or unable to continue in that capacityThe representative payee authorized to accept entries on behalf of a beneficiary is either deceased or unable to continue in that capacity2 business days
R15Beneficiary 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 deceased2 business days
R16Bank account frozenFunds in bank account are unavailable due to action by RDFI or legal order2 business days
R17File record edit criteriaFields rejected by RDFI processing (identified in return addenda)2 business days
R20Non-payment bank accountEntry destined for non-payment bank account defined by regulation2 business days
R23Credit entry refused by receiverReceiver returned entry because minimum or exact amount not remitted, bank account is subject to litigation, or payment represents an overpayment2 business days
R29Corporate customer advises not authorizedRDFI has been notified by corporate receiver that debit entry of originator is not authorized2 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:

For additional technical details:

Previous
ODFI