How to Architect a Fintech App: A Technical Build Guide

TL;DR
- Compliance (ledger design, KYC/AML state, audit logging, data residency) determines how you build the rest of the app.
- Money lives in an append-only, double-entry ledger. Every transfer is two entries that cancel out; nothing is ever updated, which is what makes the system auditable and concurrency-safe.
- Keep card numbers off your servers entirely. Hosted fields and tokenization reduce PCI DSS scope to a self-assessment questionnaire instead of a full audit.
- Real-time infrastructure (notifications, support chat, fraud alerts) reacts to the ledger; it doesn't drive it. Events are only sent after a transaction commits, never before, so the customer is never told about money that didn't move.
Building a fintech app isn't like building a project management tool.
The skeleton may look the same. An API, a database, a login flow, a web client. Fintech apps have all the same parts as a regular SaaS product, but none of the same rules. Once money moves, compliance has to govern what you build. A fintech app has to get money, identity, logging, data residency, and card handling right before the first customer signs up, because the regulations require it. Unfortunately, those are the hardest parts to get right.
So the thinking has to come before the build. That means knowing what the components are, which of them you own, and what each one has to get right before anyone writes a line of code.
This piece walks through those decisions in order: how to model money in an append-only ledger, where compliance and security constraints shape the schema, and how real-time notifications, chat, and fraud alerts fit in once the ledger is the source of truth.
The Components of a Fintech App

Fintech comes in lots of flavors: P2P payments, neobanks, lending, brokerage, buy now pay later, payroll, or card issuing. But really, every fintech app has to answer the same five questions up front:
- Where does a balance live, and can it ever be overwritten?
- Who's allowed to move money, and where exactly is that checked?
- What gets logged, and could anyone edit the log afterward?
- Which country does each piece of customer data sit in?
- Does a card number ever reach a server you run?
Now, we're going to go through a "technical guide" to fintech apps here, but, in reality, none of these really is about the code. For instance, the trigger that makes a ledger append-only is about ten lines.
This is all about the decisions. Which rules live in the database, which parts you own and which you buy, and what has to be true before a single dollar moves. Every fintech app ends up with the same six components to hold those decisions, no matter what's on top.
| Component | What it does | Who owns it |
|---|---|---|
| Ledger | Records every movement of money as an append-only entry in Postgres. Balances are sums over it. | You |
| Compliance layer | Holds KYC and KYB state on each account holder, checks it in one place before any money moves, writes an audit log in the same transaction as the money, and pins a region for every store. | You, with a verification vendor behind an interface |
| Security boundary | Keeps card numbers with the processor and stores a token, encrypts the few columns that identify a person, and hands the client only short-lived tokens. | You own the boundary; the processor captures the card |
| Risk scoring | Scores each transfer after it commits and raises alerts into the real-time layer. | A vendor behind an interface, with a velocity rule as the fallback |
| Real-time layer | Transaction notifications, support chat, fraud prompts and scam detection on messages, all fed by events after the ledger commits. | Stream Chat, Activity Feeds, and Moderation |
| Licensing | Money transmitter licenses or a sponsor bank in the US, FCA authorization in the UK, PCI DSS, and SOC 2 attestations. | Legal and compliance, started at the same time as the build |
Much of this is vendor-based. You don't want to build your own payment rails from scratch. You want to buy. Then anything you buy sits behind an interface in your own code (IdentityVerifier, PaymentProcessor, RiskMonitor), so swapping a vendor later is one adapter. "Which vendor" is a separate decision from the technical guide.
Here, we're going to build a small P2P payments app using Node, TypeScript, Postgres, a React client, and Stream Chat, Activity Feeds, and Moderation APIs for support chat, notifications, and scam detection, respectively.
Here's how they connect when a customer, Maya, sends a business, Ridge Coffee, $25 for some coffee beans (Ethiopian, single-origin).

The request goes through the compliance layer first, which checks that Maya is allowed to move money at all. An unverified customer is refused here. If she's verified, the ledger records the transfer, and that's when the money moves. Ridge's balance is up $25, Maya's is down $25, and Maya gets her coffee.
Everything else happens after that commit to the ledger:
- The real-time layer sends notifications to both customers. Ridge's says, "You received $25.00 from Maya." That must only be sent for something the ledger has already recorded.
- Risk scoring looks at the transfer alongside the account's recent activity. For an ordinary transfer from a legitimate customer, it stays quiet, and that's the end of it. If a rule fires, Maya gets a "Was this you?" prompt in her support chat, and her answer decides whether the account is frozen.
Risk scoring runs after the ledger write because risk-scoring APIs can be slow or unavailable, and you don't want that to stop the transfer. Instead, a flag freezes the account and starts an investigation.
The compliance layer is the first thing a request hits, but the ledger is the first thing to design. Until you've decided how money is recorded, none of the other components have anything to work with.
Recording Money in an Append-Only Ledger
Immediately, this seems simple. You just want an accounts table with a balance column. Any transfer is then just two UPDATEs. This will work, but it's not a reasonable answer for a finance system:
- Two transfers hit the same account at the same time, both read the same balance, and the second one overwrites the first. Money gets created or destroyed and nothing errors.
- Someone asks what the balance was at 3 pm last Tuesday, and there's no way to know, because that number has been overwritten a few hundred times since.
- The bank's statement disagrees with your table by $40, and there's nothing itemized to compare it against, so you can't find the $40.
You need something finance has used for centuries: a ledger. A ledger is a running record of every transaction, written once and never erased. Every entry moves value out of one account and into another, so the entries for any transaction cancel out; any account's balance is the sum of its entries, and a mistake is fixed by writing a new entry, never by changing an old one. Accountants call it double-entry bookkeeping.
Here, Maya sending Ridge Coffee $25 is a −2500 entry on Maya's account and a +2500 entry on Ridge's. Her balance is the SUM over her entries. Nothing is ever updated. If something was wrong, you can post a reversal, which is two more entries. But even then, the history of the mistake stays in the table. This is essential for auditing.
The platform's own accounts live in the same table as the customers'. When Maya deposits $5,000, that money has to come from somewhere for the entries to cancel out, so the app has its own settlement account, and the deposit is −500000 on the settlement account and +500000 on Maya's. Amounts are integers in cents, or whatever the minor unit is, and the currency is stored on every entry.
Here's the table creation:
CREATE TABLE IF NOT EXISTS ledger_entries (
id bigserial PRIMARY KEY,
transaction_id uuid NOT NULL REFERENCES transactions(id),
account_id text NOT NULL REFERENCES accounts(id),
amount_minor bigint NOT NULL CHECK (amount_minor <> 0),
currency char(3) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE OR REPLACE FUNCTION ledger_entries_immutable() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'ledger_entries is append-only: % is not allowed', TG_OP
USING ERRCODE = 'integrity_constraint_violation';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER ledger_entries_immutable
BEFORE UPDATE OR DELETE ON ledger_entries
FOR EACH ROW EXECUTE FUNCTION ledger_entries_immutable();That trigger is critical. The rule that nothing in the ledger ever changes lives in the database, so it applies to the migration someone runs a year from now, the support tool, and whoever has psql open at two in the morning. If someone tries:
UPDATE ledger_entries SET amount_minor = 1 WHERE id = 1They should get:
ERROR: ledger_entries is append-only: UPDATE is not allowed.The second rule is that every transaction has to balance. A transfer inserts its entries one at a time, and the check runs once at the end on COMMIT. A half-written transaction can't commit.
CREATE OR REPLACE FUNCTION ledger_transaction_balanced() RETURNS trigger AS $$
DECLARE
total bigint;
BEGIN
SELECT COALESCE(SUM(amount_minor), 0) INTO total
FROM ledger_entries WHERE transaction_id = NEW.transaction_id;
IF total <> 0 THEN
RAISE EXCEPTION 'transaction % does not balance (sum = %)', NEW.transaction_id, total
USING ERRCODE = 'integrity_constraint_violation';
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE CONSTRAINT TRIGGER ledger_transaction_balanced
AFTER INSERT ON ledger_entries
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION ledger_transaction_balanced();
CREATE OR REPLACE VIEW account_balances AS
SELECT a.id AS account_id, a.holder_id, a.currency, a.type, a.status,
COALESCE(SUM(e.amount_minor), 0)::bigint AS balance_minor
FROM accounts a
LEFT JOIN ledger_entries e ON e.account_id = a.id
GROUP BY a.id;The account_balances view is where balances come from. When the SUM gets slow, and at some point it will, you add a snapshot table and treat it as a cache of the view. The entries are still the truth.
Thus, every single transfer is a single database transaction that does five things:
- Claims the idempotency key and returns the original result if it has seen the key before.
- Locks the source account row and checks the balance.
- Writes the two entries.
- Writes an outbox row for each party that needs a notification.
- Writes an audit row.
If any of those fail, none happen. And if there's a bug in this code that writes a wrong entry, the deferred trigger catches it at COMMIT anyway.
return withTransaction(async (tx) => {
const existing = await tx.query<{ id: string }>(
'SELECT id FROM transactions WHERE idempotency_key = $1',
[input.idempotencyKey],
);
if (existing.rows[0]) {
return { transactionId: existing.rows[0].id, replayed: true, fromBalanceAfter: await balance(tx, input.fromAccountId) };
}
const src = await tx.query<{ id: string; status: string; currency: string }>(
'SELECT id, status, currency FROM accounts WHERE id = $1 FOR UPDATE',
[input.fromAccountId],
);
// ...
if (src.rows[0].status === 'frozen') throw new TransferError('account_frozen', 'source account is frozen');
const available = await balance(tx, input.fromAccountId);
if (!gte(available, input.amount)) {
throw new TransferError('insufficient_funds', 'insufficient funds');
}
const txn = await tx.query<{ id: string }>(
`INSERT INTO transactions (idempotency_key, kind, metadata)
VALUES ($1, 'transfer', $2) RETURNING id`,
[input.idempotencyKey, { memo: input.memo ?? null, from: input.fromAccountId, to: input.toAccountId }],
);
const transactionId = txn.rows[0].id;
await tx.query(
`INSERT INTO ledger_entries (transaction_id, account_id, amount_minor, currency) VALUES
($1, $2, $3, $5),
($1, $4, $6, $5)`,
[transactionId, input.fromAccountId, (-input.amount.minor).toString(), input.toAccountId, input.amount.currency, input.amount.minor.toString()],
);
// ... outbox rows and the audit row, same transactionThe FOR UPDATE on the source account is what makes two concurrent transfers behave. The second one blocks until the first commits, then it sees the new balance, and if there isn't enough left, it fails with insufficient_funds instead of taking the account negative.
So Maya sends Ridge Coffee $25 for coffee beans.

The activity list on the left is read straight from the ledger. The −$25.00 row sits under the opening deposit, and the $4,975.00 at the top is those two added together.
Now she hits retry. Or her phone drops off Wi-Fi and the app retries for her, which is the case that matters. The client generated an idempotency key before the first attempt and sends the same key again, so the server finds the existing transaction and returns it. Nothing moves.
There's also a UNIQUE constraint on the key, so if two retries somehow get past that SELECT at the same moment, the second INSERT fails and rolls back.

Every request that moves money carries a key. That includes the ones your own batch jobs make, and it includes every call out to a payment processor, because they have the same problem on their side.
Building Compliance Into the Data Model
Now we can start thinking about everything that supports that ledger, and the movement of money, as the actors want it to happen.
The easy way to do identity verification is a boolean. You send the customer through an identity verification service, something like Stripe Identity or Socure, that checks their ID document and a selfie. When its webhook comes back saying they passed, you set the user's verified flag to true. From then on, every handler that moves money checks that boolean.
Then a business signs up, and a business isn't verified the way a person is. A KYB vendor like Baselayer verifies the entity against government records, collects the beneficial owners, verifies each of them, and then keeps monitoring the business after it's onboarded. The boolean can't say any of that.
So in this app, we've built KYC as a state machine. This means each account holder has a kyc_state that can only change along a fixed set of moves:
- A person goes from
pendingtoverifiedorrejected, and fromverifiedtoblocked. - A business has one extra state,
owners_collected, betweenpendingandverified, so the entity can't be verified before its beneficial owners are. - Any other move is refused, so a rejected customer can't drift back to verified because a webhook arrived twice.
- Every transition is written to the audit log with who triggered it.
This state machine is then read before any money moves to determine verification each time:
const PERSON_TRANSITIONS: Record<PersonState, PersonState[]> = {
pending: ['verified', 'rejected'],
verified: ['blocked'],
rejected: [],
blocked: [],
};
const BUSINESS_TRANSITIONS: Record<BusinessState, BusinessState[]> = {
pending: ['owners_collected', 'rejected'],
owners_collected: ['verified', 'rejected'],
verified: ['blocked'],
rejected: [],
blocked: [],
};
// Anything that moves money passes through here.
export function requireKyc(req: Request, res: Response, next: NextFunction) {
const holder = req.holder;
if (!holder) return res.status(401).json({ error: 'not signed in' });
if (holder.kyc_state !== 'verified') {
// ... write a transfer.denied audit row
return res.status(403).json({
error: 'identity_not_verified',
kyc_state: holder.kyc_state,
message: 'Finish identity verification before moving money.',
});
}
next();
}requireKyc is Express middleware that runs on POST /transfers. It reads the holder's state and, if it isn't verified, refuses the request with a 403. It writes an audit row for the refusal, because auditors want to see the attempts that were turned away as well as the transfers that went through.
Both kinds of holders, person and business, are in the schema from the first migration, even though only people get onboarded at first. Adding that distinction to a live holders table later means migrating every row and every query that touches it.
This is what Maya sees if she tries to pay Ridge Coffee before she's finished verification.

She gets a 403 with identity_not_verified, and a transfer.denied row appears in the audit log with kyc_not_verified as the reason.
A vendor does the verification itself, and the integration works the same way whichever one you pick:
- You start a verification session for the holder.
- The customer goes through the vendor's hosted flow.
- The result comes back as a webhook, and you check its signature.
- You move the holder to the new state.
In the app, that's an IdentityVerifier interface with start() and parseWebhook(), plus a BusinessVerifier for businesses. Stripe Identity, Socure, and ID.me work for IdentityVerifier, and Baselayer for BusinessVerifier. We've compared the identity vendors if you're ready to pick one.
An Audit Log That Can Prove It Hasn't Been Edited
If the audit log is a normal table, anyone with write access to the database can edit it, and an editable log doesn't prove anything. And if the log is written after the money moves, in a separate call, a crash between the two leaves a transfer with no record at all.
Each row in our audit log holds the actor, the action, the resource, some metadata and a timestamp, plus the hash of the previous row and a hash over all of that. It's written inside the same transaction as the ledger entries, so a transfer and its audit row commit together or not at all.
export async function audit(tx: Tx, input: AuditInput): Promise<AuditEntry> {
await tx.query('SELECT pg_advisory_xact_lock(4242)');
const last = await tx.query<{ hash: string }>('SELECT hash FROM audit_log ORDER BY seq DESC LIMIT 1');
const prev_hash = last.rows[0]?.hash ?? GENESIS;
const body = {
actor: input.actor,
action: input.action,
resource: input.resource ?? null,
meta: input.meta ?? {},
at: new Date().toISOString(),
prev_hash,
};
const hash = sha256(canonical(body));
const inserted = await tx.query<{ seq: string }>(
`INSERT INTO audit_log (actor, action, resource, meta, at, prev_hash, hash)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING seq`,
[body.actor, body.action, body.resource, body.meta, body.at, body.prev_hash, hash],
);
return { seq: Number(inserted.rows[0].seq), ...body, hash };
}The pg_advisory_xact_lock advisory lock prevents two transactions from chaining off the same previous hash. canonical() sorts the keys at every level before hashing, because jsonb doesn't keep key order and the hash can't depend on it. The table has the same immutability trigger as the ledger.
Our app's admin page runs verifyChain() every time it loads.

Green means every row's hash matches its contents and its prev_hash matches the row before. If a DBA turns off the trigger and edits row 3, the trigger didn't stop them, but every hash from row 3 onwards is now wrong, and the banner goes red and says which row. In production, you'd also ship this log to WORM storage, with a retention period set per jurisdiction.
Pinning Every Stateful Dependency to One Region
Data residency has a way of coming up late, when an enterprise customer or a regulator asks where the chat transcripts live and the answer turns out to be a different continent. Every stateful dependency has a region. That includes your identity vendor's copy of the documents and your chat and feeds provider.
Stream apps are pinned to a region when you create them (US East, EU West, Singapore, Sydney, or Mumbai right now), and we can't move an app between regions after that. So this app checks its two regions against each other before it does anything else, and refuses to start if they don't match.
export function assertRegionConsistency(): void {
if (config.STREAM_REGION !== config.DB_REGION) {
console.error(
`Region mismatch: Postgres is tagged ${config.DB_REGION} but the Stream app is in ${config.STREAM_REGION}. ` +
'Refusing to start: customer data would be split across jurisdictions.',
);
process.exit(1);
}
}That's about the cheapest control in the whole build. The holders table also has a region column, so the residency claim is stored next to the data it applies to.
Keeping Card Data and Secrets Off Your Servers
PCI DSS applies to every system that stores, processes, or transmits a card number. If a full card number passes through your API, that service is in scope for the assessment, and so are its logs, its database, and anything connected to them. The simplest way to keep the assessment small is to never handle the number.
That's what hosted fields are for. The processor serves the card form inside your page or app, the browser sends the number straight to the processor and gets a token back, and the token is all your server ever sees. In this app, the payment_methods table holds the processor's name, the token, the card brand, and the last four digits, and that's it.
export interface PaymentProcessor {
// Called with the token the processor's hosted field returned to the client.
// Returns the card metadata that is safe to store.
attach(holderId: string, clientToken: string): Promise<PaymentMethodToken>;
charge(token: string, amount: Money, idempotencyKey: string): Promise<{ chargeId: string }>;
refund(chargeId: string, amount: Money): Promise<{ refundId: string }>;
}Nothing there takes a card number. attach() takes the token the hosted field handed the browser and returns what's safe to store. charge() takes the stored token and an idempotency key, the same way a transfer does. Stripe and Braintree both offer hosted fields, and using them means you can validate with SAQ A, the short self-assessment questionnaire, instead of a full audit.
The API never returns the plaintext. Kay's page shows the last four digits of a card number, and if you run SELECT tax_id_enc FROM holders directly against the database, you just see ciphertext.
Where Real-Time Fits: Notifications, Support Chat and Fraud Alerts
The obvious way to send a "you sent $25" notification is to write the ledger and then call the notification API from the same request handler. The problem is that those are two separate writes with no transaction covering both. If the process dies right after the ledger commits, the customer never hears about the transfer. If the notification goes out first and then the commit fails, the customer gets told about money that never moved, which is worse.
So everything in the real-time layer follows three basic rules:
- It's told about events after the ledger has committed them
- It's never the source of truth for anything
- It's only given the data it needs to show the customer
A Transactional Outbox Feeding Notifications
Instead of calling the notification API from the request, the transfer writes a row into an outbox table in the same database transaction as the ledger entries.
The "outbox" is a list of notifications to send. Because the row is written in the same transaction as the money, it exists if and only if the transfer happened. Each row holds only what the notification needs: the direction, the amount, the other party's display name, and the memo.
A separate worker checks the table every second and sends whatever is waiting.
await client.query('BEGIN');
const { rows } = await client.query<OutboxRow>(
`SELECT id, aggregate_type, aggregate_id, event_type, payload, attempts
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT $1
FOR UPDATE SKIP LOCKED`,
[batchSize],
);
for (const row of rows) {
try {
await publishNotification(toActivity(row));
await client.query('UPDATE outbox SET published_at = now(), attempts = attempts + 1 WHERE id = $1', [row.id]);
} catch (err) {
await client.query('UPDATE outbox SET attempts = attempts + 1, last_error = $2 WHERE id = $1', [row.id, (err as Error).message]);
}
}
await client.query('COMMIT');FOR UPDATE SKIP LOCKED lets you run several workers at once without two of them picking up the same row. So if the worker dies after sending the notification but before marking the row as sent, the row gets picked up again on the next pass, Feeds sees an id it already has, and the customer still gets exactly one notification. You can stop the worker halfway through a batch, restart it, and the notification count doesn't change.
Every customer has a feed called notification:<holderId>. The client opens it with watch: true, which keeps a WebSocket open so new items appear without polling. Meanwhile, Ridge Coffee is signed in on another browser, and Maya's $25 shows up there a moment later.

The feed item has Maya's display name and the amount, and nothing else. It didn't get account ids, tax ids, or a card token, because the feed doesn't need any of that to show one line of text.
Fraud Alerts That Ask the Customer
An in-app support chat is an ideal place for fraud alerts. The customer is already signed in, they're already looking at it, and a chat message can carry a question with replies.
Risk scoring is done behind a RiskMonitor interface. You give it the transfer and some account context, and it returns a decision. In this app, the only rule is a simple velocity check that flags three or more outgoing transfers of $500 or more within ten minutes. In production, that's where a vendor like Sardine or Unit21 goes.
When something is flagged, the API writes a risk event, an outbox row for the feed notification, and an audit row, all in one database transaction. Only then does it send the chat message.
export async function raiseVelocityAlert(holderId: string, decision: Extract<RiskDecision, { flagged: true }>, transactionId: string) {
const summary = `${(decision.detail as { count: number }).count} large transfers in the last ${(decision.detail as { windowMinutes: number }).windowMinutes} minutes`;
const riskEventId = await withTransaction(async (tx) => {
const ev = await tx.query<{ id: string }>(
`INSERT INTO risk_events (holder_id, source, severity, payload) VALUES ($1, 'velocity', $2, $3) RETURNING id`,
[holderId, decision.severity, { rule: decision.rule, ...decision.detail, transactionId }],
);
const id = ev.rows[0].id;
await tx.query(
`INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload) VALUES ('risk_event', $1, 'fraud.alert', $2)`,
[id, { holderId, riskEventId: id, severity: decision.severity, summary }],
);
await audit(tx, { actor: SYSTEM_USER_ID, action: 'risk.flagged', resource: `risk_event:${id}`, meta: { source: 'velocity', rule: decision.rule, holderId } });
return id;
});
await sendFraudPrompt(holderId, riskEventId, summary);
return riskEventId;
}sendFraudPrompt sends an ordinary chat message from the system user into the customer's support channel. The message includes a fraud_check attachment that holds the risk event ID. The client renders that attachment type as two buttons.
<Chat client={client}>
<WithComponents overrides={{ Attachment: RiskAttachment }}>
<Channel channel={channel}>
<Window>
<ChannelHeader />
<MessageList />
<MessageComposer />
</Window>
</Channel>
</WithComponents>
</Chat>WithComponents is how you replace a built-in component in Stream. RiskAttachment handles the fraud_check and scam_warning attachment types itself and passes any other attachment to the default renderer.
To trip the rule, Maya sends Ridge Coffee $500 three times in a row.

The third transfer goes through because the rule runs after the commit. Then a high-priority item appears in her feed, and the question appears in her support chat. Only one alert is open per customer at a time, so if she keeps sending in the meantime, she isn't asked again until she's answered this one.
When she taps a button, the client makes an authenticated POST /risk/:id/resolve call. If she answers "No, freeze my account", the API records the answer on the risk event, sets the account to frozen, and writes an outbox row and an audit row, all in one transaction. Only after that commits does it post the confirmation to chat and add a support agent to the channel.
await tx.query('UPDATE risk_events SET resolution = $2, resolved_at = now() WHERE id = $1', [riskEventId, resolution]);
let frozen = false;
if (resolution === 'denied_by_user') {
await tx.query(`UPDATE accounts SET status = 'frozen' WHERE holder_id = $1`, [event.holder_id]);
await tx.query(
`INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload) VALUES ('risk_event', $1, 'account.frozen', $2)`,
[riskEventId, { holderId: event.holder_id, riskEventId }],
);
frozen = true;
}
await audit(tx, {
actor: actorHolderId,
action: `risk.${resolution}`,
resource: `risk_event:${riskEventId}`,
meta: { frozen, source: event.source },
});
// ... after COMMIT:
await sendSystemMessage(result.holderId, 'Thanks. We have frozen your account so nothing else can move while we look into this.');
await escalateToAgent(result.holderId, 'They will help you secure the account and reverse anything that was not you.');The confirmation message and the escalation happen after the transaction commits, so if chat fails at that point, the account is still frozen.

A freeze only stops money going out. Maya's next transfer is refused with a 423, but anything coming in still lands, so if she's owed a reversal, she gets it. escalateToAgent adds the agent as a member of the support channel, and that membership is what makes the conversation appear on her side.

Agents aren't members of every customer's channel by default. They're added when something is escalated, so they only see the conversation they have been brought into and nothing else.
Catching the Scam in the Conversation
The velocity rule only sees transfers. It can't see the message that talked the customer into making them. A note that says "send me the 6-digit code you just got and I'll release your prize money" is a clear sign of a scam, but nothing in the ledger will ever show it.
In this app, the note attached to a payment is sent as a chat message in a p2p channel between the two customers, and a moderation policy on that channel type runs our AI text moderation on every message and flags anything that looks like a scam.
await streamClient.moderation.upsertConfig({
key: P2P_POLICY_KEY,
llm_config: {
enabled: true,
async: false,
rules: [
{
label: 'SCAM',
description: 'Fraudulent content, phishing attempts, requests for one-time codes, or deceptive payment requests',
action: 'flag',
},
],
},
});The key chat:p2p applies the policy to every p2p channel. The server sends the note into the channel on the payer's behalf with force_moderation turned on, so the policy runs immediately. Because the action is a flag, the message is delivered as normal, and an item is added to the moderation review queue. The server then looks up the message in the review queue by its ID, which tells it immediately whether the note was flagged, without needing a public webhook URL.
The same policy checks messages a client sends directly to Stream, but the server only learns about them through the moderationflagged webhook.
This time, Ridge Coffee sends money. It pays Maya $10 and attaches the prize-money note.

The transfer goes through. The note is flagged as SCAM, and the response to the sender says so. On Maya's side, a warning appears in her feed, and a card appears in her support chat with a "Talk to support" button, which brings an agent into the conversation the same way the fraud prompt does.

The risk event is stored with source = 'moderation', and in Kay's admin view it sits next to the velocity one with source = 'velocity'. The two signals come from different places, but they end up in the same table and reach the customer through the same support chat.
Choosing a Stack for the Ledger, the Types, and the Client
For the ledger, Postgres is the right choice until you're at very high volume. Transactions, row locks, triggers, and deferred constraints are everything the ledger above needed, and they keep the rules inside the database, where every code path has to follow them.
A dedicated ledger database becomes worth it when so many transfers hit the same accounts that row-level locking becomes the bottleneck. TigerBeetle is built for exactly that. Its only schema is double-entry accounts and transfers, and the database itself enforces that nothing is ever updated. Until you're at that volume, though, it's a second database to run and a second thing to back up.
Money in Code
It helps a lot if the type system can tell money apart from an ordinary number. The Money type in this app is a branded object that holds a bigint of minor units and a currency.
export type Money = Readonly<{ minor: bigint; currency: Currency }> & { readonly __brand: 'Money' };
export function add(a: Money, b: Money): Money {
assertSameCurrency(a, b);
return money(a.minor + b.minor, a.currency);
}With that in place, you can't pass a plain number where money is expected; you can't accidentally add dollars to euros, and because the amount is a bigint, a float never gets near it.
This app is in TypeScript because our SDKs are TypeScript. Java, Kotlin, Go, and Rust give you the same guarantees and a few more, such as the compiler checking that a switch over the KYC states handles every state, which is a big part of why those languages are so common in payments backends.
Launch Readiness: Licensing and Regulatory Sign-Off
Licensing takes longer than building, and you can't leave it until the code is done. The application itself needs to understand your compliance program and how money flows through the product. Those have to exist before you can even file.
In the US, there's no single national license for moving other people's money. There are two separate things you need:
- Registration with FinCEN as a money services business. You file Form 107 within 180 days of starting. It's free, and you renew it every two years. It doesn't replace state licensing.
- A money transmitter license from every state your customers live in, which means every state except Montana. Review times run from about three months in the quickest states to 18 months or more in New York and California. Application fees are small compared with the surety bond and minimum net worth each state requires, which range from $10,000 to over $1 million per state. Operating without a license you need is a federal crime under 18 U.S.C. § 1960.
The faster route is a sponsor bank. The bank holds the licenses, and you operate as its program manager, which is how neobanks and embedded finance products are set up. In return, you take on the bank's KYC standards, audit requirements, and ledger as the record your ledger has to reconcile against every day. If the bank ends the relationship while you're growing, you have to find another one or stop, which is why some fintechs get their own licenses anyway.
In the UK, a firm that holds customer money needs FCA authorization as a payment institution or an electronic money institution. The FCA has three months to decide on a complete application and up to twelve on an incomplete one, and in practice it takes six to twelve months end to end.
The safeguarding rules that came into force on 7 May 2026 require you to reconcile customer funds every day, and your ledger must be able to do that. In the EU, the equivalent authorization comes from the national regulator in the member state where you're established.
Two attestations come up in sponsor-bank and enterprise conversations:
- PCI DSS v4.0.1 is the only version of the card standard still in force, and every requirement in it, including the 51 that were future-dated when it was published, has been mandatory since 31 March 2025. If you use hosted fields, you validate with SAQ A, and it's worth agreeing on the exact scope with your assessor early.
- A SOC 2 Type II report covers an observation period of several months, so that has to start before launch too.
You can't launch until compliance signs off, and the examiner or the sponsor bank's compliance team will want to see the controls working. From the build above, that means showing:
- The ledger can't be edited, every transaction balances, repeated requests don't create repeated transfers, and a reconciliation job checks it against the settlement account.
- The audit log can't be edited, each row is chained to the one before it, copies go to write-once storage, and each jurisdiction sets its retention period.
- KYC and KYB state is checked on the server before money moves, vendor webhooks have their signatures verified, and businesses are monitored after onboarding.
- All data is stored in the same region, and the app refuses to start if regions don't match.
- No card number is stored or handled by any system you run, and the PCI scope is agreed with your assessor.
- Notifications and chat messages carry only the data they need to display, client tokens expire, and the moderation policy and webhook are configured.
You can demonstrate all of that live, from Maya's 403 to Postgres refusing the UPDATE to the audit banner turning red.
What to Build First
Build the ledger first, with tests. The tests should cover the trigger that stops a transaction from committing unless it balances, the trigger that stops rows from being changed, a transfer with insufficient funds, and a request retried with the same idempotency key.
Add the KYC check, the audit log, and the region check before you build the second feature, because each one means migrating every row if you add it later. And start the licensing applications while you're still building. In the slow states, the approval will take longer than the code.
The real-time layer is the part customers actually see, and it's the one part you don't have to build yourself. In the app above, our Chat carries the fraud prompt and the support escalation, Activity Feeds delivers the transaction notifications, and Moderation catches the scam in the payment note, and all of it is fed by events from a ledger you control.
