I built a free, browser-only European payment QR generator (and an MCP server)
Architecting Client-Side Payment QR Systems: IBAN Validation, SPAYD/EPC Standards, and MCP Safety Patterns
Current Situation Analysis
Financial workflows in Europe are plagued by a persistent "last mile" data entry problem. Despite the proliferation of digital banking, a significant volume of invoices, broker instructions, and cross-border payment requests still lack machine-readable payment codes. This forces developers and end-users to manually transcribe IBANs, amounts, and reference symbols into banking applications.
Manual transcription introduces a non-trivial error rate. A single transposed digit in an IBAN or an incorrect variable symbol can route funds to an unintended recipient, creating reconciliation nightmares and potential financial loss. While QR-based payment standards like SPAYD (Central Europe) and EPC/GiroCode (SEPA region) exist to mitigate this, adoption is fragmented. Many legacy systems, small vendors, and international senders do not generate these codes, leaving a gap that developers must bridge.
Furthermore, many existing QR generation solutions rely on server-side APIs. This architecture introduces three critical risks:
- Data Sovereignty: Sensitive financial data (IBANs, amounts) is transmitted to third-party servers, violating privacy principles and potentially GDPR requirements.
- Latency and Availability: Network-dependent generation adds latency and creates a single point of failure.
- Operational Cost: Per-request API fees accumulate rapidly in high-volume applications.
The industry often overlooks that payment QR generation is a deterministic, local computation. There is no cryptographic signing or remote validation required to produce a valid SPAYD or EPC string. By shifting this logic to the client, developers can eliminate privacy risks, reduce latency to sub-10ms, and remove operational costs entirely.
WOW Moment: Key Findings
The shift from server-side to client-side QR generation yields measurable improvements across security, performance, and reliability metrics. The following comparison highlights the architectural advantages of a local engine approach.
| Approach | Data Sovereignty | Generation Latency | Operational Cost | Failure Mode |
|---|---|---|---|---|
| Server-Side API | Low (Data transmitted) | 100msβ500ms+ | Per-request fees | API downtime / Rate limits |
| Client-Side Engine | High (Local only) | <10ms | Zero | Browser crash / Memory limit |
Why this matters: Client-side generation transforms payment QR creation from a network operation into a pure computation. This enables offline functionality, guarantees that financial data never leaves the user's device, and allows for immediate feedback loops (e.g., validating an IBAN before the QR is drawn). For AI-integrated workflows, this architecture is essential: it allows models to generate payment instructions without exposing sensitive data to external services, maintaining the integrity of the privacy boundary.
Core Solution
Building a robust client-side payment QR system requires three distinct layers: input validation, payload construction, and safe output handling. The following implementation demonstrates a TypeScript-based engine that supports SPAYD and EPC/GiroCode standards, includes rigorous IBAN validation, and integrates safely with Model Context Protocol (MCP) servers.
1. IBAN Validation via Modulo-97
The ISO 13616 standard mandates a modulo-97 checksum for IBANs. This validation must occur before any QR generation to prevent encoding invalid data. The algorithm rearranges the IBAN, converts alphabetic characters to numeric equivalents, and verifies the remainder.
/**
* Validates an IBAN using the ISO 13616 mod-97 algorithm.
* Returns true if the checksum is valid, false otherwise.
*/
function validateIbanChecksum(iban: string): boolean {
// Normalize: remove spaces and convert to uppercase
const normalized = iban.replace(/\s/g, '').toUpperCase();
// Rearrange: Move first 4 characters to the end
const rearranged = normalized.slice(4) + normalized.slice(0, 4);
// Convert letters to numbers: A=10, B=11, ... Z=35
const numericString = rearranged
.split('')
.map(char => {
const code = char.charCodeAt(0);
// ASCII 'A' is 65. We want A -> 10. So subtract 55.
return code >= 65 && code <= 90 ? (code - 55).toString() : char;
})
.join('');
// Perform mod-97 check using BigInt for large number support
// The result must equal 1 for a valid IBAN
return BigInt(numericString) % 97n === 1n;
}
2. Deterministic Payload Construction
SPAYD and EPC/GiroCode use different string formats. SPAYD is a star-delimited key-value structure, while EPC/GiroCode is a newline-separated fixed-line format. The engine must handle currency formatting strictly to avoid rounding discrepancies.
type PaymentFormat = 'SPAYD' | 'EPC_GIROCODE';
interface PaymentDetails {
iban: string;
amount: number;
currency: string;
recipientName?: string;
bic?: string;
variableSymbol?: string;
remittanceInfo?: string;
}
class PaymentPayloadBuilder {
/**
* Constructs a SPAYD payload string.
* Format: SPD*1.0*ACC:...
*/
static buildSPAYD(details: PaymentDetails): string {
if (!validateIbanChecksum(details.iban)) {
throw new Error('Invalid IBAN checksum');
}
const amountStr = details.amount.toFixed(2);
const segments = [
'SPD*1.0',
`ACC:${details.iban}`,
`AM:${amountStr}`,
`CC:${details.currency}`,
];
if (details.variableSymbol) {
segments.push(`X-VS:${details.variableSymbol}`);
}
return segments.join('*');
}
/**
* Constructs an EPC/GiroCode payload string.
* Format: 12 newline-separated lines.
*/
static buildEPC(details: PaymentDetails): string {
if (!validateIbanChecksum(details.iban)) {
throw new Error('Invalid IBAN checksum');
}
if (!details.recipientName) {
throw new Error('EPC/GiroCode requires recipient name');
}
if (details.currency !== 'EUR') {
throw new Error('EPC/GiroCode supports EUR only');
}
const amountStr = `EUR${details.amount.toFixed(2)}`;
const lines = [
'BCD', // Service Tag
'001', // Version
details.bic || '', // BIC (optional)
details.recipientName, // Name
details.iban, // IBAN
amountStr, // Amount
'SCT', // Purpose (SEPA Credit Transfer)
'', // Remittance Info (unstructured)
'', // Structured Remittance
details.remittanceInfo || '', // Remittance Info
'', // Hash
'' // Reserved
];
return lines.join('\n');
}
}
3. MCP Integration with Safety Patterns
When exposing QR generation via an MCP server, a critical safety pattern is required. MCP clients may render image content blocks as pixels for the model, preventing the model from accessing the raw bytes. If the model is instructed to save the QR, it may attempt to regenerate the image from the text payload, risking data corruption.
The solution is to return the image as both a rendered block and a base64 text payload, instructing the model to use the text payload for file operations.
import { encodeBase64 } from './utils/base64';
import { generateQRMatrix } from './qr/encoder';
interface MCPToolResponse {
content: Array<{ type: 'image' | 'text'; data?: string; text?: string }>;
}
/**
* Handles payment QR generation in an MCP context.
* Implements safety pattern to prevent model regeneration.
*/
async function handlePaymentQRGeneration(args: PaymentDetails): Promise<MCPToolResponse> {
const format = args.currency === 'EUR' ? 'EPC_GIROCODE' : 'SPAYD';
const payload = format === 'EPC_GIROCODE'
? PaymentPayloadBuilder.buildEPC(args)
: PaymentPayloadBuilder.buildSPAYD(args);
// Generate QR image bytes locally
const qrBytes = generateQRMatrix(payload);
const base64Data = encodeBase64(qrBytes);
// SAFETY: Return base64 text alongside image block.
// This allows the model to write exact bytes without regeneration.
return {
content: [
{
type: 'image',
data: base64Data,
mimeType: 'image/png'
},
{
type: 'text',
text: JSON.stringify({
payload,
format,
png_base64: base64Data,
instruction: 'Use png_base64 to write the file. Do not regenerate the QR.'
})
}
]
};
}
Architecture Decisions
- Local-Only Processing: Libraries like
tesseract.jsfor OCR andjsQRfor encoding are lazy-loaded in the browser. This ensures zero backend dependency and maintains the privacy guarantee. - Strict Validation: IBAN validation is enforced before payload construction. This prevents encoding invalid data, which would result in a scannable but unusable QR code.
- Currency Handling: Amounts are formatted using
toFixed(2)to ensure consistent decimal representation. This avoids floating-point rounding errors that could alter the payment amount. - MCP Safety: The dual-return pattern (image block + base64 text) mitigates the risk of model hallucination or regeneration, ensuring the QR code bytes match the validated payload exactly.
Pitfall Guide
1. IBAN Mod-97 Implementation Errors
Explanation: Developers often misimplement the letter-to-number conversion or fail to rearrange the IBAN correctly. A common mistake is using the wrong ASCII offset or forgetting to move the first four characters.
Fix: Use the standard algorithm: rearrange first 4 chars to end, convert letters using charCodeAt - 55, and verify BigInt % 97n === 1n.
2. EPC/GiroCode Recipient Name Omission
Explanation: The EPC specification requires the recipient name field. Omitting this results in a QR code that banking apps may reject or fail to parse.
Fix: Enforce recipientName as a required field in the EPC builder. Throw an error if missing.
3. MCP Image Regeneration Risk
Explanation: In MCP clients, image content blocks are often rendered as pixels. The model cannot access the raw bytes. If instructed to save the QR, the model may regenerate it from the text payload, potentially introducing transcription errors. Fix: Always return the base64-encoded image bytes as a text field. Instruct the model to use this text payload for file operations and never regenerate the image.
4. Currency Formatting Inconsistencies
Explanation: Using raw numbers for amounts can lead to formatting issues (e.g., 1250 vs 1250.00). SPAYD and EPC expect fixed decimal places.
Fix: Always format amounts using toFixed(2) before embedding in the payload.
5. OCR Hallucination in Payment Extraction
Explanation: When using OCR to extract payment details from invoices, the model may misread characters (e.g., 0 as O, 1 as l). This can lead to invalid IBANs or incorrect amounts.
Fix: Validate all OCR-extracted IBANs using the mod-97 checksum before generating the QR. If validation fails, prompt the user for manual correction.
6. SPAYD Delimiter Escaping
Explanation: SPAYD uses * as a delimiter. If user-provided fields (like variable symbols) contain *, the payload structure breaks.
Fix: Sanitize input fields to remove or escape delimiter characters before constructing the SPAYD string.
7. Lazy Loading Failures
Explanation: Client-side libraries like tesseract.js require worker initialization. Failing to await worker readiness can cause runtime errors.
Fix: Implement a loading state and await worker initialization before processing images. Handle initialization failures gracefully.
Production Bundle
Action Checklist
- Implement mod-97 IBAN validation and enforce it before QR generation.
- Use
toFixed(2)for all monetary amounts to ensure consistent formatting. - Return base64 text payload alongside image blocks in MCP responses.
- Validate OCR-extracted IBANs against the checksum before use.
- Enforce recipient name requirement for EPC/GiroCode payloads.
- Sanitize input fields to prevent delimiter injection in SPAYD.
- Handle lazy loading of
tesseract.jsandjsQRwith proper error states. - Test QR generation with edge cases (max length variable symbols, special characters).
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| High-Volume B2B Invoicing | Client-Side Engine | Ensures data privacy, zero latency, no API costs. | Zero |
| AI-Automated Payment Workflows | MCP Server with Safety Pattern | Enables automation while preventing model regeneration risks. | Dev time |
| Cross-Border SEPA Payments | EPC/GiroCode Format | Standard compliance for EUR transfers across SEPA zone. | None |
| Central European Domestic | SPAYD Format | Native support for CZ/SK banking apps and variable symbols. | None |
| Legacy Invoice Processing | Client-Side OCR + Validation | Extracts data from images/PDFs locally without data leakage. | Browser compute |
Configuration Template
Use this configuration object to initialize the payment QR engine with safety defaults.
const PAYMENT_QR_CONFIG = {
formats: {
SPAYD: {
enabled: true,
delimiter: '*',
version: '1.0',
maxVariableSymbolLength: 10
},
EPC_GIROCODE: {
enabled: true,
version: '001',
serviceTag: 'BCD',
purpose: 'SCT',
requireRecipientName: true,
supportedCurrency: 'EUR'
}
},
validation: {
strictIbanChecksum: true,
enforceAmountDecimals: true,
sanitizeDelimiters: true
},
mcp: {
returnBase64Payload: true,
preventRegeneration: true,
safetyInstruction: 'Use png_base64 to write file. Do not regenerate.'
},
ocr: {
enabled: true,
validateExtractedIban: true,
workerTimeout: 5000
}
};
Quick Start Guide
- Install Dependencies: Add
jsQRfor QR encoding andtesseract.jsfor OCR to your project.npm install jsqr tesseract.js - Implement Validation: Copy the
validateIbanChecksumfunction into your utility module. - Build Payloads: Use
PaymentPayloadBuilderto construct SPAYD or EPC strings based on payment details. - Generate QR: Pass the payload to
jsQRto generate the QR matrix and render it to a canvas or image element. - Integrate MCP: If building an MCP server, implement
handlePaymentQRGenerationto return safe responses with base64 payloads.
Mid-Year Sale β Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register β Start Free Trial7-day free trial Β· Cancel anytime Β· 30-day money-back
