Loading...
Loading...
This guide shows you how to keep your database in sync with each user's transactions. Quiltt sends a webhook whenever a Connection syncs; your handler fetches the new transactions and stores them.
The handler you build:
connection.synced.successful webhooks when transaction data changesThe examples use Node.js 18+, but the patterns apply to any backend.
Quiltt syncs financial data in the background after a user connects an account. Rather than poll the API on a timer, you subscribe to webhooks and react when data is ready:
connection.synced.successful webhook.The connection.synced.successful event family tells you what changed:
| Event | When it fires | What to fetch |
|---|---|---|
connection.synced.successful.initial | The first sync after a user connects | Every transaction for the Profile |
connection.synced.successful.historical | Older transactions arrive after the initial sync | Transactions in the event's date range |
When an event carries metadata.startDate and metadata.endDate, fetch only that range. When it doesn't, the Connection is already up to date and no transactions need fetching.
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.
Create a webhook subscription that points at your handler. In the Dashboard:
/quiltt_webhook).connection.synced.successful. This automatically includes every connection.synced.successful.* event.QUILTT_WEBHOOK_SECRET.For programmatic setup, see the Webhooks setup guide.
Server-to-server requests to a Profile's data use Basic Auth, which is not subject to the per-Profile Session token rate limits. Encode profileId:API_KEY_SECRET as Base64 and send it as the Authorization header:
// quiltt.ts
const GRAPHQL_ENDPOINT = 'https://api.quiltt.io/v1/graphql'
function authHeader(profileId: string): string {
const encoded = Buffer.from(
`${profileId}:${process.env.QUILTT_API_KEY_SECRET}`
).toString('base64')
return `Basic ${encoded}`
}
See the Authentication guide for the full list of scopes and headers.
The transactions query uses cursor-based pagination, capped at 100 records per page. Page through the results with pageInfo.endCursor until hasNextPage is false.
This function fetches every transaction for a Profile within an optional date range:
// quiltt.ts (continued)
const TRANSACTIONS_QUERY = `
query SyncTransactions($after: String, $filter: TransactionFilter) {
transactions(first: 100, after: $after, sort: DATE_DESC, filter: $filter) {
edges {
node {
id
date
description
amount
entryType
status
account {
id
name
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`
type DateRange = { startDate?: string; endDate?: string }
export async function fetchTransactions(profileId: string, range: DateRange = {}) {
const filter =
range.startDate && range.endDate
? { date_gte: range.startDate, date_lte: range.endDate }
: undefined
const transactions = []
let after: string | null = null
do {
const response = await fetch(GRAPHQL_ENDPOINT, {
method: 'POST',
headers: {
Authorization: authHeader(profileId),
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: TRANSACTIONS_QUERY,
variables: { after, filter },
}),
})
const { data } = await response.json()
const page = data.transactions
transactions.push(...page.edges.map((edge) => edge.node))
after = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null
} while (after)
return transactions
}
Each Transaction has a stable id. Quiltt retries failed webhook deliveries up to 20 times, so the same transaction can arrive more than once. Upsert on the id to keep your store consistent:
// store.ts
import { fetchTransactions } from './quiltt'
export async function syncTransactions(profileId: string, range = {}) {
const transactions = await fetchTransactions(profileId, range)
for (const transaction of transactions) {
// Replace with your database's upsert.
// Keying on transaction.id makes repeated syncs safe.
await db.transactions.upsert({
where: { id: transaction.id },
update: transaction,
create: { ...transaction, profileId },
})
}
return transactions.length
}
Verify every incoming webhook before acting on it, then route connection.synced.successful events to your sync function. Pass the event's date metadata through so historical syncs fetch only the affected range:
// server.ts
import express, { Request, Response } from 'express'
import crypto from 'crypto'
import { syncTransactions } from './store'
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>()
app.use(express.json())
app.post('/quiltt_webhook', async (req: Request, res: Response) => {
const timestamp = req.header('Quiltt-Timestamp')
if (!timestamp || Date.now() / 1000 - Number(timestamp) > QUILTT_WEBHOOK_WINDOW) {
return res.status(204).send()
}
const payload = JSON.stringify(req.body)
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.startsWith('connection.synced.successful')) {
const profileId = event.profile.id
const range = event.metadata ?? {}
const count = await syncTransactions(profileId, range)
console.log(`Synced ${count} transactions for ${profileId}`)
}
}
})
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`)
})
Return a 2xx response within 20 seconds. Acknowledge the webhook first, then run the sync, so a slow query never causes Quiltt to retry a delivery you already received.
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 an account in the Dashboard Connector preview. In a SANDBOX Environment, use the Mock provider for guaranteed data.
Watch your server logs. Within a few seconds you should see a connection.synced.successful.initial event and a count of synced transactions.
Query your database to confirm the transactions were stored.
| Problem | Cause | Fix |
|---|---|---|
| Signature check fails | The raw request body changed before verification | Verify against the exact bytes Quiltt sent. Some frameworks rewrite the body—disable that for this route. |
401 Unauthorized from GraphQL | Wrong Basic Auth encoding | Encode profileId:API_KEY_SECRET, not the API key alone. |
| No webhook arrives | Subscription URL unreachable or wrong event type | Confirm the tunnel is running and the subscription includes connection.synced.successful. |
| Duplicate rows | Insert instead of upsert | Key your write on transaction.id. |
You now sync transactions automatically whenever a user's data changes. The same pattern works for other Profile data—swap the query and the event type.