pymnt-tools
v1.1.1
Published
Payments format parsers and webhook signature verifiers — ISO 8583, EMV BER-TLV, EMVCo QR, UPI links, ISO 9564 PIN blocks and key check values, and Razorpay/Stripe/Cashfree/PayU signatures. No dependencies, runs in Node, the browser and on the edge.
Maintainers
Readme
pymnt-tools
Payments format parsers and webhook signature verifiers, with no dependencies.
ISO 8583, EMV BER-TLV, EMVCo/Bharat QR, UPI links, ISO 9564 PIN blocks, card number inspection, IFSC validation, and webhook signatures for Razorpay, PayU, Cashfree and Stripe.
These are the libraries behind tools.moveahead.tech, extracted rather than reimplemented — the same functions, compiled from the same source, so the package and the website cannot disagree about what a message says.
npm install pymnt-toolsNode 18+, ESM. Every module is pure and isomorphic: no node: imports, no DOM,
crypto through Web Crypto. They run unchanged in Node, in a browser bundle and on
edge runtimes.
Nothing here has a hard dependency. The one optional extra is qrcode, needed
only if you call renderQrSvg to draw a UPI QR — parsing and building UPI links
does not touch it.
Modules
Import the one you need — there is no barrel, so you pay only for what you use.
Every module has a browser front end that runs the identical function, which is usually the fastest way to check what a message contains before you write the code that reads it.
| Import | What it does | Try it first |
| --- | --- | --- |
| pymnt-tools/iso8583 | Parse and build ISO 8583, with the bitmap computed from the fields set | ISO 8583 parser & builder |
| pymnt-tools/emv-tlv | EMV BER-TLV, including ISO 8583 field 55, with TVR/AIP/TSI bit decoding | EMV tag decoder |
| pymnt-tools/emvco | EMVCo merchant QR (Bharat QR), with CRC validation | Bharat QR / EMVCo decoder |
| pymnt-tools/upi | Build, parse and validate upi://pay links; render QR as SVG | UPI link & QR generator |
| pymnt-tools/pin-block | ISO 9564 PIN blocks, formats 0–3, key component XOR, and DES/3DES/AES check values | PIN block toolkit |
| pymnt-tools/card | Luhn, network detection from IIN ranges, PAN formatting | Card test toolkit |
| pymnt-tools/webhook-signature | Verify and sign gateway webhook signatures | Webhook signature verifier |
| pymnt-tools/ifsc | Validate and normalise Indian IFSC codes | IFSC lookup |
Those pages paste in nothing and send nothing — the marked tools compute in the browser — so a payload you cannot share is still safe to inspect there.
Webhook signatures
The reason this exists. Every other library covers GitHub, Slack and Shopify; this one covers the Indian gateways, and tells you which string was signed.
import { providersById } from "pymnt-tools/webhook-signature";
const result = await providersById.razorpay.verify({
payload: rawRequestBody, // the exact bytes, never JSON.parse'd
secret: process.env.RAZORPAY_WEBHOOK_SECRET,
signature: request.headers["x-razorpay-signature"],
});
if (result.status !== "valid") {
// steps[] shows what was signed and what was compared, which is
// almost always where the answer is.
console.log(result.summary, result.steps);
}Providers: razorpay, razorpay-payment (checkout/order/subscription/link),
stripe, cashfree, payu. Comparisons use a constant-time equality, and
PayU's hash string masks the salt before it appears in any output.
The raw body matters. A signature is computed over exact bytes. If your framework has already parsed and re-serialised the body, the signature cannot verify and the mismatch is not your secret's fault. In Express use
express.raw(); in Next.js readawait request.arrayBuffer().
ISO 8583, and field 55
import { parseIso8583 } from "pymnt-tools/iso8583";
import { parseIso8583Field55 } from "pymnt-tools/emv-tlv";
const message = parseIso8583(hexDump, { layout: "packed" });
const de55 = message.fields.find((field) => field.id === 55);
const emv = parseIso8583Field55(de55.value);
for (const node of emv.nodes) {
console.log(node.tag, node.name, node.description ?? node.hex);
}The parser reports where alignment broke rather than returning garbage — the tag or field it was reading and the byte offset — because on a malformed message that is the only useful output. Packed BCD and EBCDIC input are both supported.
The builder computes the bitmap from the fields you set. Length problems are errors and character-set problems are warnings, deliberately: a wrong-length value shifts every field after it, while a value of the right length in the wrong character set still assembles and is the switch's business to reject.
PIN blocks
import { buildPinBlock, decodePinBlock } from "pymnt-tools/pin-block";
const { block, steps } = buildPinBlock({
format: "0",
pin: "1234",
pan: "4111111111111111",
});
// block === "041225EEEEEEEEEE"
// steps shows the PIN field and the PAN field separately, then the XORThis is the clear block — the formatting layer either side of encryption. A live PIN block is encrypted from the pad to the HSM, and nothing here decrypts one.
Key check values come with it, and report every reading the length admits:
import { keyCheckValues } from "pymnt-tools/pin-block";
// key = 000102030405060708090A0B0C0D0E0F
await keyCheckValues(key);
// 16 bytes is double-length 3DES *or* AES-128, and nothing in the bytes says
// which, so both are returned rather than one being guessed at:
// [ { algorithm: "3DES", kcv: "DDADA1", reading: "double-length 3DES" },
// { algorithm: "AES", kcv: "C6A13B", reading: "AES-128" } ]DES is implemented here because Web Crypto does not offer it and payment key management still runs on 3DES. It is a single-block cipher for computing check values — no mode, no IV, no padding — and it is pinned against the published FIPS vectors. Do not use it to encrypt anything.
Note that decoding a format 0 block with the wrong PAN does not fail: the XOR has
no integrity check, so you get a plausible wrong PIN. Check issues — a padding
warning is the only signal.
Ambiguity is reported, not hidden
IIN ranges genuinely overlap between networks, so inspectCard returns every
match rather than picking one:
import { inspectCard } from "pymnt-tools/card";
const card = inspectCard("4111 1111 1111 1111");
card.luhn.valid; // true
card.networks.map((match) => match.network.name); // every network whose ranges cover itPresenting one confident answer where the data does not support one is the failure mode that costs the most credibility, so none of these modules do it.
Test data only
The card and PIN block modules are built for test data. Use test PANs and test PINs; nothing here should meet a real card number or a real PIN.
Changes
1.1.1 — Stripe verification no longer reports "the timestamp is inside the
replay window" when t= is not a Unix timestamp. The HMAC covers t as a
string, so a hand-assembled header can carry a matching digest with an
unparseable timestamp; the age comparison was then skipped and the success path
claimed a window that had never been checked. It now returns invalid with
reason: "replay_window" and says the signature itself is correct. Stripe does
not send such a header — this only affects hand-built ones.
1.1.0 — DES, 3DES and AES key check values (keyCheckValues).
Related
- The tools, in a browser — the same functions with a UI
pymnt-forward— deliver captured webhooks to localhost
Licence
MIT
