All projects

Payment core

A framework-agnostic TS payment core for Lithuanian banks — hardened webhook and amount handling.

TypeScriptBank integrationsCrypto
01

Challenge

Payment logic kept getting rewritten per product and tangled with whatever framework and database that product used — which is exactly where payment bugs hide.

02

What we did

A pure TypeScript core that holds the payment domain rules with no framework or database imports; each Lithuanian bank or PSP is a thin provider adapter, and the core is hardened where money actually breaks: webhook signature and freshness checks, guards against amount overflow and excess decimals, validated redirect targets, and manual-review flows when a callback reference or amount does not match.

03

Result

A new product connects payments by writing adapters around one tested, safety-checked core, instead of reimplementing bank flows and error states each time.

Dev-story article

Payment core: how the project was built

A browser redirect back from a bank means the user returned to your site — it does not mean money moved, yet plenty of integrations mark orders paid on exactly that signal. This core was built around the opposite assumption: a redirect proves nothing, and the only trustworthy state comes from a signed callback or a server-side status re-check. The tension throughout was that each bank link protocol, card aggregator, and wallet API signs, encodes, and reports amounts differently, so the hardening had to be shared logic rather than per-provider copy-paste.

Sections

05

Modules

05

Stack

TypeScript + Bank integrations

01

Why the project exists

Payment logic kept getting rewritten per product and tangled with whatever framework and database that product used — which is exactly where payment bugs hide.

A browser redirect back from a bank means the user returned to your site — it does not mean money moved, yet plenty of integrations mark orders paid on exactly that signal. This core was built around the opposite assumption: a redirect proves nothing, and the only trustworthy state comes from a signed callback or a server-side status re-check. The tension throughout was that each bank link protocol, card aggregator, and wallet API signs, encodes, and reports amounts differently, so the hardening had to be shared logic rather than per-provider copy-paste.

02

What was built

A pure TypeScript core that holds the payment domain rules with no framework or database imports; each Lithuanian bank or PSP is a thin provider adapter, and the core is hardened where money actually breaks: webhook signature and freshness checks, guards against amount overflow and excess decimals, validated redirect targets, and manual-review flows when a callback reference or amount does not match.

It is a TypeScript package holding only payment domain rules — request signing, callback validation, status normalization, amount and currency checks, duplicate-callback handling, and audit-safe normalized events. The core imports no framework, HTTP runtime, or database client: it takes a plain PaymentHttpRequest struct so the same provider code runs from our reactive backend, Node workers, or tests. Several real Lithuanian bank and PSP providers are implemented; banks without official merchant materials ship as strict skeletons that throw rather than guess.

03

Main modules and user path

M01

Signed callback and webhook verification per protocol (RSA-SHA512/SHA1/SHA256, detached JWS, hex HMAC-SHA256) over the exact raw request bytes, with return redirects modeled as provesPayment:false so a forged success URL can never finalize an order

M02

Replay defense via opt-in freshness windows: undated callbacks or ones whose timestamp/JWS iat falls outside tolerance are rejected as untrusted, defaulting off so legitimately delayed callbacks still land

M03

Money guards as shared helpers: a decimalAmountToCents that returns undefined past Number.MAX_SAFE_INTEGER, and rejection of amounts carrying more than two decimals instead of silently rounding 12.999 up to 1300 cents

M04

Manual-review routing on mismatch: when a webhook reference, amount, or currency disagrees with the authoritative server-retrieved order, the event goes to requires_manual_review as a tamper signal rather than trusting either side blindly

M05

A framework-agnostic core plus thin adapters, with strict provider skeletons that throw explicit implementation-required errors until real endpoints, fields, signatures, and certificates exist

04

Architecture and technology decisions

Built with TypeScript, Bank integrations, Crypto.

Pure TypeScript with pluggable Web Crypto and Node crypto adapters, no framework imports in the core entrypoint, and a CI gate holding 100% statement, branch, function, and line coverage across runtime source.

05

Result and lessons

A new product connects payments by writing adapters around one tested, safety-checked core, instead of reimplementing bank flows and error states each time.

The same provider code runs unchanged across backends and tests, and every money-finalizing path fails closed — an overflowing amount, a replayed callback, or a mismatched reference surfaces no payment or a review flag instead of a wrong one.

Read next

These projects share nearby technical or product decisions, so they show how the same principle behaves in another context.

Have a similar idea?

Discuss your project