How Do I Validate ISO 20022 Data Between a React Frontend and a Python REST API?

I’m concerned about how data types are handled in webserver applications. In particular, I’m concerned about how a message would be validated at the webpage and how we would be sure data types were correct at the server side after being originated at the web site. My REST implementation is in Python. My web page uses TypeScript and React. Can you tell me how the validation at the web page can be sufficient that it will be error-free as possible at the server and I don’t send garbage that can’t even be deserialized into the types declared at the server?

The answer starts with a principle: the spec is the single source of truth for your validation rules. The MCP Server exposes the exact constraints — regex patterns, length bounds, digit limits — that ISO 20022 defines for every primitive type. If you translate those constraints faithfully into both your Zod schemas (TypeScript) and your Pydantic models (Python), the two layers will reject exactly the same values. Nothing valid gets blocked. Nothing invalid slips through.

Let me pull the actual constraints for the types you’ll encounter most in payments initiation.

[Calling universal_lookup on IBAN2007Identifier, BICFIDec2014Identifier, Max35Text, ActiveOrHistoricCurrencyAndAmount…]

Here’s what the spec actually says, field by field:

ISO 20022 type Constraint Source
Max35Text String, minLength 1, maxLength 35 ISO spec
IBAN2007Identifier Pattern: [A-Z]{2,2}[0-9]{2,2}[a-zA-Z0-9]{1,30} + valid IBAN checksum ISO 13616
BICFIDec2014Identifier Pattern: [A-Z0-9]{4,4}[A-Z]{2,2}[A-Z0-9]{2,2}([A-Z0-9]{3,3}){0,1} ISO 9362:2014
ActiveOrHistoricCurrencyAndAmount Decimal ≥ 0, totalDigits 18, fractionDigits 5, currency = ISO 4217 3-letter code ISO 4217
ChargeBearerType1Code Enum: DEBT, CRED, SHAR, SLEV Closed code set

These are not conventions or best practices — they are the spec values. Any payload that passes these constraints will deserialize correctly at the server. Anything that fails them is structurally invalid before it ever reaches business logic.


The Strategy

Define the constraints once per type, in both layers, derived from the same spec source. The TypeScript layer validates user input before submission. The Python layer validates again on arrival — not because you distrust the frontend, but because network payloads can come from anywhere.

User input
   │
   ▼
[Zod schema] ── rejects bad data ── form error shown
   │
   ▼
HTTP POST (clean JSON)
   │
   ▼
[Pydantic model] ── rejects bad data ── 422 Unprocessable Entity
   │
   ▼
Business logic (guaranteed valid types)

Both validation layers must agree. If they diverge — even slightly — you get either false positives (the form accepts something the server rejects, causing confusing errors) or false negatives (the form rejects something the server would accept, blocking legitimate submissions).


TypeScript / Zod — Frontend Validation

Install once:

npm install zod

Define your primitive types directly from the spec constraints:

import { z } from "zod";

// Max35Text: minLength 1, maxLength 35
export const max35Text = z
  .string()
  .min(1, "Required")
  .max(35, "Maximum 35 characters");

// IBAN2007Identifier: pattern from ISO 13616
// Note: structural pattern only — full checksum validation requires a library
export const iban2007Identifier = z
  .string()
  .regex(
    /^[A-Z]{2}[0-9]{2}[a-zA-Z0-9]{1,30}$/,
    "Must be a valid IBAN (e.g. GB29NWBK60161331926819)"
  );

// BICFIDec2014Identifier: pattern from ISO 9362:2014
export const bicFiDec2014Identifier = z
  .string()
  .regex(
    /^[A-Z0-9]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$/,
    "Must be a valid BIC (e.g. NWBKGB2L)"
  );

// ActiveOrHistoricCurrencyAndAmount: decimal >= 0, up to 5 fraction digits, ISO 4217 currency
export const currencyAndAmount = z.object({
  amount: z
    .number({ invalid_type_error: "Must be a number" })
    .nonnegative("Amount must be zero or greater")
    .multipleOf(0.00001, "Maximum 5 decimal places"),
  currency: z
    .string()
    .length(3, "Currency must be a 3-letter ISO 4217 code")
    .regex(/^[A-Z]{3}$/, "Currency code must be uppercase letters"),
});

// ChargeBearerType1Code: closed code set — use a dropdown, not free text
export const chargeBearerType1Code = z.enum(["DEBT", "CRED", "SHAR", "SLEV"], {
  errorMap: () => ({ message: "Select a valid charge bearer" }),
});

Now compose these into your payment initiation form schema:

// Schema for the credit transfer initiation form — pain.001 subset
export const creditTransferFormSchema = z.object({
  // GroupHeader fields
  messageIdentification: max35Text,
  numberOfTransactions: z
    .number({ invalid_type_error: "Must be a number" })
    .int()
    .positive(),

  // Payment information
  debtorName: max35Text,
  debtorAccountIban: iban2007Identifier,
  debtorBic: bicFiDec2014Identifier,

  // Credit transfer transaction
  creditorName: max35Text,
  creditorAccountIban: iban2007Identifier,
  instructedAmount: currencyAndAmount,
  chargeBearer: chargeBearerType1Code,

  // Remittance info (free text, bounded)
  remittanceInformation: z.string().max(140).optional(),
});

export type CreditTransferForm = z.infer<typeof creditTransferFormSchema>;

In a React form (using react-hook-form with the Zod resolver):

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

export function PaymentForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<CreditTransferForm>({
    resolver: zodResolver(creditTransferFormSchema),
  });

  const onSubmit = async (data: CreditTransferForm) => {
    // data is fully typed and validated — safe to POST
    await fetch("/api/payments/initiate", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("debtorAccountIban")} placeholder="IBAN" />
      {errors.debtorAccountIban && (
        <span>{errors.debtorAccountIban.message}</span>
      )}
      {/* ... remaining fields ... */}
    </form>
  );
}

Python / Pydantic — Server-Side Validation

The same spec constraints, expressed in Pydantic v2:

import re
from decimal import Decimal
from typing import Annotated, Literal
from pydantic import BaseModel, Field, field_validator

# ── Primitive type aliases ──────────────────────────────────────────────────

# Max35Text: minLength 1, maxLength 35
Max35Text = Annotated[str, Field(min_length=1, max_length=35)]

# IBAN2007Identifier: structural pattern (ISO 13616)
_IBAN_PATTERN = re.compile(r'^[A-Z]{2}[0-9]{2}[a-zA-Z0-9]{1,30}$')

# BICFIDec2014Identifier: ISO 9362:2014
_BIC_PATTERN = re.compile(r'^[A-Z0-9]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$')

# ActiveOrHistoricCurrencyAndAmount: totalDigits 18, fractionDigits 5, minInclusive 0
CurrencyCode = Annotated[str, Field(pattern=r'^[A-Z]{3}$', min_length=3, max_length=3)]

# ChargeBearerType1Code: closed code set
ChargeBearerType1Code = Literal["DEBT", "CRED", "SHAR", "SLEV"]


# ── Structured types ────────────────────────────────────────────────────────

class ActiveOrHistoricCurrencyAndAmount(BaseModel):
    amount: Decimal = Field(ge=0, decimal_places=5, max_digits=18)
    currency: CurrencyCode


# ── Message building blocks ─────────────────────────────────────────────────

class CreditTransferInitiationRequest(BaseModel):
    message_identification: Max35Text
    number_of_transactions: int = Field(gt=0)

    debtor_name: Max35Text
    debtor_account_iban: str
    debtor_bic: str

    creditor_name: Max35Text
    creditor_account_iban: str
    instructed_amount: ActiveOrHistoricCurrencyAndAmount
    charge_bearer: ChargeBearerType1Code

    remittance_information: Annotated[str, Field(max_length=140)] | None = None

    @field_validator("debtor_account_iban", "creditor_account_iban")
    @classmethod
    def validate_iban(cls, v: str) -> str:
        if not _IBAN_PATTERN.match(v):
            raise ValueError(
                "Must match IBAN pattern: 2 uppercase letters, 2 digits, "
                "1–30 alphanumeric characters"
            )
        return v

    @field_validator("debtor_bic")
    @classmethod
    def validate_bic(cls, v: str) -> str:
        if not _BIC_PATTERN.match(v):
            raise ValueError(
                "Must match BIC pattern per ISO 9362:2014"
            )
        return v

Your FastAPI endpoint receives and auto-validates in one step:

from fastapi import FastAPI

app = FastAPI()

@app.post("/api/payments/initiate")
async def initiate_payment(request: CreditTransferInitiationRequest):
    # If execution reaches here, every field is valid per the ISO 20022 spec.
    # Pydantic has already rejected the request with a 422 if anything is wrong.
    return await process_payment(request)

Keeping the Two Layers in Sync

The risk over time is that the Zod schema and the Pydantic model diverge as the codebase evolves. Two practices help:

1. Comment each constraint with its spec source. When a constraint changes in the spec (rare, but it happens), you know exactly what to update in both places:

// IBAN2007Identifier — ISO 13616, pattern from ISO 20022 spec ID _YYxm0dp-Ed-ak6NoX_4Aeg_1226525818
export const iban2007Identifier = z.string().regex(/^[A-Z]{2}[0-9]{2}[a-zA-Z0-9]{1,30}$/, ...);

2. Test against the same payloads. A small shared fixture file of valid and invalid examples (in JSON) can be run against both layers. The Zod schema and the Pydantic model should accept and reject identically. Any divergence is a bug.


Four universal_lookup calls returned the exact patterns, length bounds, and digit constraints ISO 20022 defines for every primitive type in the message. Translating those into Zod and Pydantic is mechanical — not interpretive. Both layers now reject exactly the same payloads, and the spec itself is the paper trail for every validation rule in the codebase.

If you want the full constraint set for PaymentInformation or CreditTransferTransaction — the blocks that carry the actual beneficiary and amount data — the MCP Server can walk those structures the same way.