Payment core
A framework-agnostic TS payment core for Lithuanian banks — hardened webhook and amount handling.
Payment core
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.
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.
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
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.
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.
Main modules and user path
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
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
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
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
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
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.
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.
Related articles
Read next
Related project stories
These projects share nearby technical or product decisions, so they show how the same principle behaves in another context.
Dev-storyCMS
A dynamic headless CMS on webedge-db — content types, media, roles, and the public read API behind our sites and their articles.
Dev-storyWebEdge public site
Our lt/en/ru site on Astro, content pulled from the WebEdge CMS.
Dev-storyPi local-model workflow
Pi extension: the local model does the work, GPT-5.6 only gates the plan and review.
Have a similar idea?
Discuss your project