This walkthrough covers the practical mechanics of connecting the Openaggr enrichment API to a React Native personal finance app. We will go from a raw transaction list to a fully categorized, icon-mapped, webhook-updated transaction feed. The code here uses Expo SDK 51 and React Native 0.74, but the patterns apply to any RN setup.
The scenario: you have a mobile PFM app. Users have connected their bank accounts via an open banking provider. You are receiving normalized transaction objects with amounts, timestamps, and raw description strings. You want to display those transactions with clean merchant names, spending category labels, and category-specific icons. You also want the enrichment to happen server-side and push updates to the app via webhook rather than running enrichment calls from the client on every screen load.
Architecture Overview
The architecture we recommend for this pattern has three components: your backend service, Openaggr's enrichment API, and the React Native client. The client never calls Openaggr directly. All enrichment calls happen server-side.
When your banking provider delivers new transactions (via their webhook or polling), your backend sends them to POST /enrich and stores the enriched results. The client fetches enriched transactions from your backend. When Openaggr's webhook fires with async enrichment updates, your backend processes those and triggers a push notification or real-time update to the client.
This keeps your API key off the client, which matters for mobile apps where bundle contents can be extracted. It also means enrichment latency is not on the critical path for your UI: the user sees transactions immediately (with a loading skeleton or a "pending" state), and enriched data populates as it arrives.
Step 1: Backend Enrichment Call
On your Node.js (or equivalent) backend, the enrichment call for a batch of new transactions looks like this:
// server/enrichment.js
const OPENAGGR_API_KEY = process.env.OPENAGGR_API_KEY;
const ENRICH_ENDPOINT = 'https://api.openaggr.com/v1/enrich';
async function enrichTransactionBatch(rawTransactions) {
const payload = {
transactions: rawTransactions.map(tx => ({
id: tx.id,
description: tx.description,
amount: tx.amount,
currency: tx.currency,
date: tx.date,
direction: tx.amount > 0 ? 'credit' : 'debit'
}))
};
const response = await fetch(ENRICH_ENDPOINT, {
method: 'POST',
headers: {
'Authorization': `Bearer ${OPENAGGR_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`Enrichment API error: ${response.status}`);
}
return response.json();
// Returns: { enriched: [...], batch_id: "bat_xxx" }
}
The enriched response includes a batch_id which you store alongside your transactions. This identifier is used to match up async webhook updates later. Store the enriched fields in your transactions table: merchant_name, category_primary, category_code, category_confidence, and enrichment_status (which starts as processing for async enrichments and moves to complete on the webhook).
Step 2: Webhook Setup for Async Updates
For high-volume transaction processing, not all enrichments resolve synchronously in the initial response. Openaggr's webhook fires when batch enrichment is complete or when individual transaction enrichments update. Register your webhook endpoint in the dashboard and handle the payload like this:
// server/webhook-handler.js
const express = require('express');
const crypto = require('crypto');
const router = express.Router();
router.post('/openaggr-webhook', express.raw({ type: 'application/json' }), async (req, res) => {
// Verify webhook signature
const signature = req.headers['x-openaggr-signature'];
const expectedSig = crypto
.createHmac('sha256', process.env.OPENAGGR_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
if (signature !== `sha256=${expectedSig}`) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
if (event.type === 'batch.enrichment.complete') {
const { batch_id, enriched } = event.data;
// Update your database with final enrichment results
await updateTransactionsFromEnrichment(batch_id, enriched);
// Push real-time update to affected users via your notification system
const userIds = await getUserIdsForBatch(batch_id);
await notifyUsersOfUpdate(userIds, 'transactions_updated');
}
res.sendStatus(200);
});
The webhook signature verification is non-negotiable. Unsigned webhook endpoints are a common attack vector, and financial data updates triggered by spoofed webhooks are a meaningful risk.
Step 3: Fetching Enriched Transactions in React Native
On the client side, the transaction list screen fetches from your backend, which returns already-enriched data:
// app/hooks/useTransactions.js
import { useState, useEffect, useCallback } from 'react';
import { useAuth } from './useAuth';
export function useTransactions(accountId) {
const { token } = useAuth();
const [transactions, setTransactions] = useState([]);
const [loading, setLoading] = useState(true);
const fetchTransactions = useCallback(async () => {
try {
const response = await fetch(
`${process.env.EXPO_PUBLIC_API_URL}/accounts/${accountId}/transactions`,
{ headers: { Authorization: `Bearer ${token}` } }
);
const data = await response.json();
setTransactions(data.transactions);
} catch (err) {
console.error('Failed to fetch transactions:', err);
} finally {
setLoading(false);
}
}, [accountId, token]);
useEffect(() => {
fetchTransactions();
}, [fetchTransactions]);
return { transactions, loading, refetch: fetchTransactions };
}
Each transaction object returned from your backend should include the enriched fields plus a status flag:
{
"id": "txn_9a2f8c1",
"amount": -4.75,
"date": "2025-10-28",
"merchant_name": "Blue Bottle Coffee",
"category_primary": "Food and Drink",
"category_code": "food_drink.coffee_shop",
"category_confidence": 0.94,
"enrichment_status": "complete"
}
Step 4: Category Icon Mapping
The category codes returned by Openaggr follow a two-level hierarchy: primary.subcategory. You need to map these to icons and colors in your UI. We recommend defining this mapping as a static file so it can be updated independently of business logic:
// app/config/categoryMap.js
export const CATEGORY_MAP = {
'food_drink.coffee_shop': { icon: 'coffee', color: '#92400E', label: 'Coffee' },
'food_drink.restaurants': { icon: 'utensils', color: '#EF4444', label: 'Dining' },
'food_drink.groceries': { icon: 'shopping-cart', color: '#22C55E', label: 'Groceries' },
'transport.rideshare': { icon: 'car', color: '#F59E0B', label: 'Rideshare' },
'transport.public': { icon: 'bus', color: '#F59E0B', label: 'Transit' },
'transport.fuel': { icon: 'gas-pump', color: '#F59E0B', label: 'Gas' },
'entertainment.streaming': { icon: 'play-circle', color: '#A855F7', label: 'Streaming' },
'health.pharmacy': { icon: 'pills', color: '#06B6D4', label: 'Pharmacy' },
'health.fitness': { icon: 'dumbbell', color: '#06B6D4', label: 'Fitness' },
'shopping.general': { icon: 'bag-shopping', color: '#8B5CF6', label: 'Shopping' },
// Fallback
'uncategorized': { icon: 'circle', color: '#9CA3AF', label: 'Other' }
};
export function getCategoryMeta(categoryCode, confidence) {
// Fall back to primary category if subcategory not mapped
const subcategoryMeta = CATEGORY_MAP[categoryCode];
if (subcategoryMeta) return subcategoryMeta;
const primaryCode = categoryCode?.split('.')[0];
const primaryMeta = CATEGORY_MAP[`${primaryCode}.general`] || CATEGORY_MAP['uncategorized'];
// If confidence is low, indicate uncertainty in the label
if (confidence < 0.7) {
return { ...primaryMeta, label: `${primaryMeta.label}?` };
}
return primaryMeta;
}
The confidence threshold in getCategoryMeta is worth thinking through. Transactions with confidence below 0.7 are cases where the model has less certainty. Surfacing a question mark in the label gives the user a signal to verify, which also serves as a passive feedback mechanism if your app supports manual recategorization.
Handling the Pending State in UI
When a transaction arrives before enrichment is complete (status is processing), show a loading skeleton rather than an empty category field. In React Native with Animated:
// app/components/TransactionRow.js
function TransactionRow({ transaction }) {
const isPending = transaction.enrichment_status === 'processing';
const categoryMeta = isPending
? null
: getCategoryMeta(transaction.category_code, transaction.category_confidence);
return (
<View style={styles.row}>
{isPending ? (
<SkeletonBox width={32} height={32} borderRadius={16} />
) : (
<CategoryBadge icon={categoryMeta.icon} color={categoryMeta.color} />
)}
<View style={styles.details}>
<Text style={styles.merchantName}>
{isPending ? transaction.raw_description : transaction.merchant_name}
</Text>
{!isPending && (
<Text style={styles.categoryLabel}>{categoryMeta.label}</Text>
)}
</View>
<Text style={styles.amount}>
{formatAmount(transaction.amount, transaction.currency)}
</Text>
</View>
);
}
We are not saying you must always show the raw description as a fallback. For some UX contexts, "Pending" is a cleaner placeholder. The choice depends on how many transactions in your user base typically arrive in the processing state simultaneously: if it is rare (sync enrichment covers most cases), a brief skeleton is fine. If your users often sync large transaction batches, you may want a more intentional pending UX.
Testing Against the Sandbox
Before going to production, test the full integration against the sandbox environment. The sandbox accepts real transaction description strings and returns realistic enrichment responses, but all billing is suppressed. Two things worth testing explicitly: webhook signature verification (send a request with a tampered signature and confirm your handler rejects it), and the behavior when category_confidence is below your chosen threshold (seed some ambiguous merchant strings like SAMS CLUB #8204 or TARGET 00012345 to see confidence distribution in practice).
The integration pattern described here is roughly what we see most mobile finance teams land on after a few iterations. The key structural decisions: enrichment on the server, confidence-aware UI fallbacks, and webhook-driven async updates. Get those three right and the rest is cosmetic.