Loading...
Search the Quiltt documentation
Loading...
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:
account.verified events when an account is
ready for money movementThe examples use Node.js 18+, but the patterns apply to any backend.
If you move money with a Quiltt-supported processor like Straddle, you don't need to handle raw account numbers at all. Create a Processor Token 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.
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:
account.verified webhook.number and routing to a payment processor to
initiate an ACH transfer.The verified field on an Account 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.
Account numbers are sensitive financial data. Retrieve and store them server-side only. Never expose them in client-side code, logs, or unencrypted communications.
Add your credentials to a .env file:
# .env
QUILTT_API_KEY_SECRET=your_api_key_secret_here
QUILTT_WEBHOOK_SECRET=your_webhook_subscription_secret_here
Keep QUILTT_API_KEY_SECRET and QUILTT_WEBHOOK_SECRET server-side only. Never
expose them in client code or commit them to version control.
Account verification only runs when a Connector requests the Account Numbers product. In the Dashboard:
SANDBOX for development).SANDBOX for
guaranteed verified accounts.When a user connects a depository account through this Connector, Quiltt verifies it for money movement automatically.
Create a webhook subscription that points at your handler. In the Dashboard:
/account_verified).account.verified.QUILTT_WEBHOOK_SECRET.For programmatic setup, see the Webhooks setup guide.
ACH numbers are available only through a server-to-server call to the REST Account Numbers API. This endpoint uses Environment scope, so authenticate with your API Key secret as a Bearer token:
GEThttps://api.quiltt.io/v1/accounts/{accountId}/ach_numbersCopy endpoint URL to clipboardThis function fetches the verified ACH numbers for a single account:
// quiltt.ts
const REST_ENDPOINT = 'https://api.quiltt.io/v1'
export type AchNumbers = {
accountId: string
number: string
routing: string
}
export async function fetchAchNumbers(
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:
{
"accountId": "acct_12Hz9Dz7vEAuljYvhmPcvM9",
"number": "1234567890",
"routing": "021000021"
}
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.
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:
// server.ts
import express, { Request, Response } from 'express'
import crypto from 'crypto'
import { fetchAchNumbers, initiatePayment } from './quiltt'
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}`)
})
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.
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.
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, you don't touch raw account numbers at all. Instead of the ACH endpoint in step 3, create a Processor Token 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 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:
// quiltt.ts (continued)
import type { AchNumbers } from './quiltt'
export async function initiatePayment(profileId: string, ach: AchNumbers) {
// 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
})
}
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 reference for the full security guidance.
Test the full flow end to end:
Start your server and expose it with a tunnel:
node server.js
ngrok http 3000
Confirm the subscription's target URL points at your tunnel URL.
Connect a depository account in the Dashboard
Connector preview. In a SANDBOX Environment, use the Mock provider for
a guaranteed verified account.
Watch your server logs. Within a few seconds you should see an
account.verified event and a confirmation that the ACH numbers were
retrieved.
Confirm the account reports verified: true in GraphQL:
query GetVerifiedAccounts {
accounts(filter: { verified: true }) {
id
name
verified
}
}
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.401 Unauthorized from the ACH endpoint:
Use your API Key secret as a Bearer token. Account Numbers use Environment
scope, not a Session token.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.
verified field and account
filters