Express.jsFastAPIHow-to

How to Validate Form Data in Express.js and FastAPI

Practical patterns for validating form and API request data — combining schema validation (Zod / Pydantic) with semantic identifier validation (IsValid API) using parallel requests. Includes reusable middleware for Express.js and dependency injection for FastAPI.

1. Two-phase validation approach

Effective form validation uses two sequential phases — fail fast at the schema level before making any API calls:

Phase 1 — Format validation
Zod (Express) / Pydantic (FastAPI)
  • • Required fields present
  • • Correct types
  • • Length / pattern constraints
  • • Enum values
Phase 2 — Semantic validation
IsValid API (parallel)
  • • Checksum verification
  • • Registry lookup
  • • Cross-field consistency
  • • Domain-specific rules
💡Only run semantic validation (API calls) after format validation passes. This avoids unnecessary API calls when the input is obviously malformed.

2. Express.js — payment form validation

A middleware that validates IBAN + BIC with Zod for format and the IsValid SDK for semantics. The route handler only runs when both phases pass.

import { z } from 'zod'
import { createClient } from '@isvalid-dev/sdk'

const iv = createClient({ apiKey: process.env.ISVALID_API_KEY! })

// 1. Format schema (Zod)
const PaymentSchema = z.object({
  iban:     z.string().min(15).max(34),
  bic:      z.string().min(8).max(11),
  amount:   z.number().positive(),
  currency: z.enum(['EUR', 'GBP', 'USD']),
})

// 2. Semantic validation (IsValid API)
async function validatePaymentSemantics(data: z.infer<typeof PaymentSchema>) {
  const errors: Record<string, string> = {}
  const [ibanR, bicR] = await Promise.all([
    iv.iban(data.iban).catch(() => ({ valid: false, isSEPA: false })),
    iv.bic(data.bic).catch(() => ({ valid: false })),
  ])
  if (!ibanR.valid) errors.iban = 'Invalid IBAN — checksum failed'
  if (!ibanR.isSEPA) errors.iban = 'IBAN is not SEPA-eligible'
  if (!bicR.valid) errors.bic = 'Invalid BIC code'
  return errors
}

// 3. Middleware
export async function validatePayment(req, res, next) {
  const parsed = PaymentSchema.safeParse(req.body)
  if (!parsed.success) {
    return res.status(422).json({
      title: 'Validation Failed',
      status: 422,
      errors: parsed.error.flatten().fieldErrors,
    })
  }

  const semanticErrors = await validatePaymentSemantics(parsed.data)
  if (Object.keys(semanticErrors).length > 0) {
    return res.status(422).json({
      title: 'Validation Failed',
      status: 422,
      errors: semanticErrors,
    })
  }

  req.validatedBody = parsed.data
  next()
}

// 4. Route — only runs with clean data
app.post('/payments', validatePayment, async (req, res) => {
  const payment = req.validatedBody
  // ... process payment
  res.status(201).json({ id: newPayment.id })
})

3. Express.js — registration form validation

An adaptive pattern that conditionally validates the VAT number only when provided, running all checks in parallel with Promise.allSettled.

import { z } from 'zod'

const RegistrationSchema = z.object({
  email:     z.string().email(),
  phone:     z.string().min(7).max(20),
  vatNumber: z.string().optional(),
})

async function validateRegistration(req, res, next) {
  const parsed = RegistrationSchema.safeParse(req.body)
  if (!parsed.success) {
    return res.status(422).json({
      title: 'Validation Failed',
      status: 422,
      errors: parsed.error.flatten().fieldErrors,
    })
  }

  const { email, phone, vatNumber } = parsed.data
  const tasks: Promise<unknown>[] = [iv.email(email), iv.phone(phone)]
  const keys = ['email', 'phone']

  if (vatNumber) { tasks.push(iv.vat(vatNumber)); keys.push('vatNumber') }

  const results = await Promise.allSettled(tasks)
  const errors: Record<string, string> = {}
  results.forEach((r, i) => {
    if (r.status === 'fulfilled' && !(r.value as { valid: boolean }).valid) {
      errors[keys[i]] = `Invalid ${keys[i]}`
    }
  })

  if (Object.keys(errors).length > 0) {
    return res.status(422).json({ title: 'Validation Failed', status: 422, errors })
  }

  req.validatedBody = parsed.data
  next()
}

4. FastAPI — payment form validation

FastAPI's dependency injection system makes it clean to separate Pydantic format validation from async semantic validation — the route handler receives only validated data.

from fastapi import FastAPI, Depends, HTTPException
from pydantic import BaseModel, field_validator
from isvalid_sdk import IsValidConfig, create_client
from typing import Optional
import asyncio

app = FastAPI()
iv = create_client(IsValidConfig(api_key="YOUR_API_KEY"))

# 1. Pydantic model (format validation)
class PaymentRequest(BaseModel):
    iban: str
    bic: str
    amount: float
    currency: str

    @field_validator('amount')
    @classmethod
    def amount_must_be_positive(cls, v):
        if v <= 0:
            raise ValueError('Amount must be positive')
        return v

    @field_validator('currency')
    @classmethod
    def currency_must_be_valid(cls, v):
        if v not in ('EUR', 'GBP', 'USD'):
            raise ValueError('Unsupported currency')
        return v

# 2. Semantic validation dependency
async def validate_payment_semantics(data: PaymentRequest) -> PaymentRequest:
    iban_r, bic_r = await asyncio.gather(iv.iban(data.iban), iv.bic(data.bic))
    errors = {}
    if not iban_r.valid:    errors["iban"] = "Invalid IBAN — checksum failed"
    if not iban_r.is_sepa:  errors["iban"] = "IBAN is not SEPA-eligible"
    if not bic_r.valid:     errors["bic"]  = "Invalid BIC code"
    if errors:
        raise HTTPException(status_code=422, detail={"errors": errors})
    return data

# 3. Route with dependency injection
@app.post("/payments", status_code=201)
async def create_payment(data: PaymentRequest = Depends(validate_payment_semantics)):
    # data is clean — both format and semantic validation passed
    return {"status": "created"}

5. Consistent error response format

Both Express and FastAPI patterns return the same field-level error structure — easy to consume in any frontend:

HTTP/1.1 422 Unprocessable Content
Content-Type: application/json

{
  "title": "Validation Failed",
  "status": 422,
  "errors": {
    "iban": "IBAN is not SEPA-eligible",
    "bic": "Invalid BIC code"
  }
}
ℹ️Use HTTP 422 (Unprocessable Content) — not 400 — for validation errors. 400 signals a malformed request (wrong content-type, unparseable JSON); 422 means the request was understood but the values failed business rules.

Consuming errors in React

const res = await fetch('/payments', { method: 'POST', body: JSON.stringify(form) })
if (res.status === 422) {
  const { errors } = await res.json()
  setFieldErrors(errors)  // { iban: "...", bic: "..." } → display next to each field
}

6. Checklist

Run Zod/Pydantic format validation first
Only call IsValid API after format validation passes
Run all semantic checks in parallel (Promise.all / asyncio.gather)
Collect all errors before returning (not just the first)
Return 422 with field-level error messages
Keep validation in middleware, not route handlers
Use req.validatedBody / Depends() to pass clean data
Log validation failures for monitoring

Related guides

One API for all your validation needs

IBAN, VAT, credit card, GSTIN, phone, email, postal code, and 40+ more — one SDK, one key.

Get your API key →