# How to verify a bank account for ACH payments
URL: https://www.quiltt.dev/get-started/tutorials/account-verification
Description: Verify a connected bank account and retrieve its ACH account and routing numbers with Quiltt, so you can move money. Server-side Node.js guide with code examples.
Navigation: get-started → tutorials → account-verification
Tags: Tutorials, Payments, Webhooks
Content Length: 13k characters

This guide shows you how to verify a user's bank account and retrieve its ACH
account and routing numbers, so you can fund accounts, pay out, or collect
payments. Quiltt verifies the account in the background after a user connects
it, then sends an `account.verified` webhook; your handler fetches the ACH
numbers and hands them to your payment processor.

What you'll set up:

1. A Connector with the **Account Numbers** product enabled, so Quiltt verifies
   connected accounts
2. A webhook handler that receives `account.verified` events when an account is
   ready for money movement
3. A server-to-server call that retrieves ACH account and routing numbers from
   the REST API
4. A hand-off to a payment processor to move money

The examples use Node.js 18+, but the patterns apply to any backend.

Info - Using a Quiltt-supported processor?:
If you move money with a Quiltt-supported processor like
[Straddle](/integrations/payments/straddle), you don't need to handle raw
account numbers at all. Create a
[Processor Token](/integrations/payments/straddle) for the verified account and
hand that to the processor. Quiltt passes the account details across securely
on your behalf. Follow this guide only when you retrieve ACH numbers yourself to
drive your own processor.

## How account verification works

Account and routing numbers are sensitive, so Quiltt never exposes them in
client-side code or GraphQL. Instead, you verify the account once and pull the
numbers server-to-server:

1. A user connects an account through the [Connector](/connector) with the
   **Account Numbers** product enabled.
2. Quiltt verifies the account for money movement and fires the
   `account.verified` webhook.
3. Your handler verifies the webhook, then calls the REST
   [Account Numbers API](/api/numbers) for that account.
4. You pass the returned `number` and `routing` to a payment processor to
   initiate an ACH transfer.

The `verified` field on an [Account](/api/accounts) tells you the same thing
through GraphQL: when it is `true`, the account is ready for money movement and
ACH numbers are available via REST.

Warning:
Account numbers are sensitive financial data. Retrieve and store them
server-side only. Never expose them in client-side code, logs, or unencrypted
communications.

## Prerequisites

- [Quiltt Dashboard](https://dashboard.quiltt.dev) account
  ([sign up](https://dashboard.quiltt.dev/signup))
- An API Key secret from the [Dashboard](https://dashboard.quiltt.dev)
- Node.js 18 or higher
- A publicly reachable URL for local testing
  - Use a tunneling tool such as [ngrok](https://ngrok.com)

Add your credentials to a `.env` file:

```bash
# .env
QUILTT_API_KEY_SECRET=your_api_key_secret_here
QUILTT_WEBHOOK_SECRET=your_webhook_subscription_secret_here
```

Warning:
Keep `QUILTT_API_KEY_SECRET` and `QUILTT_WEBHOOK_SECRET` server-side only. Never
expose them in client code or commit them to version control.

## 1. Enable the Account Numbers product

Account verification only runs when a Connector requests the **Account Numbers**
product. In the [Dashboard](https://dashboard.quiltt.dev):

1. Open **Connectors** and create or edit a Connector.
2. Select your Environment (`SANDBOX` for development).
3. In the **Connect** section, enable the **Account Numbers** product.
4. Enable at least one provider. Use the **Mock** provider in `SANDBOX` for
   guaranteed verified accounts.

When a user connects a depository account through this Connector, Quiltt
verifies it for money movement automatically.

## 2. Subscribe to the verification event

Create a webhook subscription that points at your handler. In the
[Dashboard](https://dashboard.quiltt.dev):

1. Open the **Webhooks** link within your Environment.
2. Click **Create Webhook Subscription**.
3. Set the target URL to your handler endpoint (for example, your ngrok URL
   plus `/account_verified`).
4. Subscribe to `account.verified`.
5. Copy the subscription secret into `QUILTT_WEBHOOK_SECRET`.

For programmatic setup, see the [Webhooks setup guide](/webhooks/setup).

## 3. Retrieve ACH numbers from the REST API

ACH numbers are available only through a server-to-server call to the REST
[Account Numbers API](/api/numbers). This endpoint uses Environment scope, so
authenticate with your API Key secret as a Bearer token:

API Endpoint - GET https://api.quiltt.io/v1/accounts/{accountId}/ach_numbers:

This function fetches the verified ACH numbers for a single account:

```typescript
// quiltt.ts
const REST_ENDPOINT = 'https://api.quiltt.io/v1'

  number: string
  routing: string]

  accountId: string,
  [retries = 3, delayMs = 2000] = {},
): Promise<AchNumbers> {
  const url = `${REST_ENDPOINT}/accounts/${accountId}/ach_numbers`

  for (let attempt = 0; attempt <= retries; attempt++) {
    const response = await fetch(url, {
      headers: {
        Authorization: `Bearer $[process.env.QUILTT_API_KEY_SECRET]`,
        'Content-Type': 'application/json',
      },
    })

    if (response.status === 200) [return response.json()]

    // 202 Accepted: Finicity numbers are still being fetched. Wait, then retry.
    if (response.status === 202) [await new Promise((resolve) => setTimeout(resolve, delayMs))
      continue]

    const [message, instruction] = await response.json()
    throw new Error(`$[response.status] ${message}: ${instruction}`)
  }

  throw new Error('ACH numbers were not ready after several attempts. Retry later.')
}
```

For Finicity accounts whose numbers are not cached yet, the endpoint returns
`202 Accepted` while it fetches them asynchronously. The request blocks for up to
25 seconds, so most calls return `200` on the first try. Treat `202` as a
signal to wait briefly and retry, as the loop above does.

A successful response returns the account and routing numbers:

```json
["accountId": "acct_12Hz9Dz7vEAuljYvhmPcvM9",
  "number": "1234567890",
  "routing": "021000021"]
```

Info:
The endpoint returns numbers only for verified **depository** accounts. A credit
or loan account returns `400 Bad Request`, a Connection without the Account
Numbers product returns `403 Forbidden`, and an account that can never be
verified returns `410 Gone`.

## 4. Handle the verification webhook

Verify every incoming webhook before acting on it, then fetch the ACH numbers
for each verified account. The `account.verified` event carries the account ID
in `record.id`:

```typescript
// server.ts

const app = express()
const PORT = 3000

const QUILTT_WEBHOOK_SECRET = process.env.QUILTT_WEBHOOK_SECRET
const QUILTT_WEBHOOK_VERSION = 1
const QUILTT_WEBHOOK_WINDOW = 300 // Five minutes

const processedEvents = new Set<string>()

// Capture the raw request body so the signature is computed over the exact
// bytes Quiltt sent. Re-serializing parsed JSON can change them and reject
// valid webhooks.
app.use(express.json({ verify: (req, _res, buf) => [(req as any).rawBody = buf] }))

app.post('/account_verified', async (req: Request, res: Response) => {
  const timestamp = req.header('Quiltt-Timestamp')
  const isExpired =
    Date.now() / 1000 - Number(timestamp) > QUILTT_WEBHOOK_WINDOW

  if (!timestamp || isExpired) [return res.status(204).send()]

  const payload = (req as any).rawBody.toString('utf8')

  const signature = crypto
    .createHmac('sha256', QUILTT_WEBHOOK_SECRET)
    .update(`${QUILTT_WEBHOOK_VERSION}${timestamp}${payload}`)
    .digest('base64')

  if (req.header('Quiltt-Signature') !== signature) [return res.status(204).send()]

  // Acknowledge within 20 seconds, then process.
  res.status(204).send()

  for (const event of req.body.events) {
    // Skip events you've already processed (Quiltt retries deliveries).
    if (processedEvents.has(event.id)) continue
    processedEvents.add(event.id)

    if (event.type === 'account.verified') {
      const accountId = event.record.id
      const profileId = event.profile.id

      const ach = await fetchAchNumbers(accountId)
      // Hand the verified numbers to your payment processor. Never log them.
      await initiatePayment(profileId, ach)
      console.log(`Retrieved ACH numbers for account ${accountId}`)
    }
  }
})

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`)
})
```

Info:
Return a `2xx` response within 20 seconds. Acknowledge the webhook first, then
fetch the numbers, so a slow request never causes Quiltt to retry a delivery you
already received.

Warning:
The in-memory `processedEvents` set keeps this example short. In production,
back idempotency with a persistent store, such as Redis with a TTL that
matches the webhook delivery window, so deduplication survives restarts and the
set doesn't grow unbounded.

## 5. Move money with a payment processor

Quiltt verifies accounts and exposes their numbers, but it doesn't move money
itself. You have two options.

**Use a Quiltt-supported processor (recommended).** With
[Straddle](/integrations/payments/straddle), you don't touch raw account numbers
at all. Instead of the ACH endpoint in step 3, create a
[Processor Token](/integrations/payments/straddle) for the verified account and
send that to Straddle. Quiltt passes the account details across securely. If
this fits your use case, follow the
[Straddle guide](/integrations/payments/straddle) after step 2 and skip ACH
retrieval entirely.

**Bring your own processor.** If you move money through your own processor, pass
the verified `number` and `routing` from step 3 to its API:

```typescript
// quiltt.ts (continued)

  // Replace with your payment processor's API.
  // Pass ach.number and ach.routing. Never store or log them in plaintext.
  await paymentProcessor.charges.create([accountNumber: ach.number,
    routingNumber: ach.routing,
    // ...plus amount, currency, and payment mandate])
}
```

Warning:
When logging is necessary, mask all but the last 4 digits of an account number,
store numbers only when required, and use encrypted storage and TLS 1.2 or
higher. See the [Account Numbers](/api/numbers) reference for the full security
guidance.

## Verification

Test the full flow end to end:

1. Start your server and expose it with a tunnel:

   ```bash
   node server.js
   ngrok http 3000
   ```

2. Confirm the subscription's target URL points at your tunnel URL.
3. Connect a depository account in the [Dashboard](https://dashboard.quiltt.dev)
   Connector preview. In a `SANDBOX` Environment, use the **Mock** provider for
   a guaranteed verified account.
4. Watch your server logs. Within a few seconds you should see an
   `account.verified` event and a confirmation that the ACH numbers were
   retrieved.
5. Confirm the account reports `verified: true` in GraphQL:

   ```graphql
   query GetVerifiedAccounts {
     accounts(filter: [verified: true]) [id
       name
       verified]
   }
   ```

## Troubleshooting

- Signature check fails:
  Verify against the exact bytes Quiltt sent. Some frameworks rewrite the body,
  so disable that for this route.
- `403 Forbidden` from the ACH endpoint:
  Enable the **Account Numbers** product on the Connector, then reconnect the
  account.
- `400 Bad Request` from the ACH endpoint:
  Request ACH numbers only for `DEPOSITORY` accounts. Filter out credit and
  loan accounts first.
- `account.verified` never arrives:
  Confirm the Account Numbers product is enabled and the account is a checking
  or savings account.
- `410 Gone` from the ACH endpoint:
  The account can never be verified for money movement. Ask the user to
  reconnect a different account. See [Reconnect](/connector/reconnect).
- `401 Unauthorized` from the ACH endpoint:
  Use your API Key secret as a Bearer token. Account Numbers use Environment
  scope, not a Session token.

## Next steps

You now verify bank accounts and retrieve ACH numbers automatically whenever an
account becomes ready for money movement. From here, wire the numbers into your
processor's mandate and charge flow.

### Related tutorials

- [Transaction sync tutorial](/get-started/tutorials/transaction-sync) - React
  to sync webhooks and store data
- [Authentication Tutorial](/get-started/tutorials/authentication) - Session
  token setup and management
- [Multi-Aggregator Connectors Tutorial](/get-started/tutorials/connectors) -
  Build a full connection flow

### Reference documentation

- [Account Numbers API reference](/api/numbers) - ACH endpoint, responses, and
  security guidance
- [Accounts API reference](/api/accounts) - The `verified` field and account
  filters
- [Payments integrations](/integrations/payments) - Straddle Processor Tokens
  and more
- [Webhooks guide](/webhooks) - Event types, payloads, and verification

### Questions

[Contact support](mailto:support@quiltt.io)