Input Validation Best Practices for REST APIs
Input validation is the first line of defence for any API. Done well, it prevents bad data from reaching your business logic, improves error messages for API consumers, and stops entire categories of security vulnerabilities before they start.
Contents
1. Why input validation matters
Every piece of data that enters your API is an assumption — a claim made by an external system or user. Validation is how you verify that claim before acting on it.
curl or a malicious actor bypasses it entirely. All validation that affects data persistence or business logic must happen on the server.2. Where to validate: client, server, or both?
| Layer | Purpose | Trust level |
|---|---|---|
| Client (browser / mobile) | Immediate feedback, reduce round-trips for obvious errors | Zero trust — never authoritative |
| API gateway / middleware | Schema validation, rate limiting, auth token checks | Partial — can be bypassed if misconfigured |
| Application layer | Business rule validation, semantic checks, cross-field consistency | Full trust — this is the authoritative gate |
| Database layer | NOT NULL, UNIQUE, FK constraints as a final safety net | Full trust — but errors here are too late |
The answer is both, with the server being authoritative. Client-side validation improves UX; server-side validation ensures correctness.
3. Two layers: format vs semantic validation
Most validation guides focus on format validation, but production APIs need both layers:
Format validation
Is the shape of the data correct?
- ✓ Required fields present
- ✓ Correct data type (string, number, boolean)
- ✓ Length constraints
- ✓ Regex pattern match
- ✓ Enum values
Semantic validation
Does the value actually mean something valid?
- ✓ Checksum verification (Luhn, MOD-97)
- ✓ Registry lookup (VAT, IBAN bank, ABN)
- ✓ Cross-field consistency (IBAN country ↔ BIC country)
- ✓ Domain-specific rules (SEPA eligibility, GSTIN state)
- ✓ Real-world existence (ORCID registered, DOI resolved)
GB29NWBK60161331926819 passes regex validation (correct length, correct country prefix). But is it real? Does the bank code match a real institution? Is it SEPA-eligible? These are semantic questions — format validation cannot answer them.4. Identifiers that need semantic validation
These common identifier types all have internal checksums or registry backing that a simple regex cannot verify:
| Identifier | Common use case | What regex misses |
|---|---|---|
| IBAN | Bank transfers, fintech onboarding | MOD-97 checksum, SEPA eligibility, bank lookup |
| VAT / GST | B2B invoicing, tax compliance | Country-specific checksum algorithms, VIES registry |
| Credit card | Payments, PCI-DSS | Luhn checksum, network detection, card type |
| GSTIN | India GST compliance | State code validity, mod-36 check char, embedded PAN |
| ABN | Australian supplier onboarding | MOD-89 checksum, ABR registry, GST status |
| BTC/ETH address | Crypto withdrawals | Base58Check, Bech32, EIP-55 checksum |
| IMEI | Device registration, IoT | Luhn checksum, TAC validity |
| EORI | EU customs / trade | Country prefix rules, EC registry lookup |
| DOI | Academic publishing | Prefix/suffix structure, CrossRef registration |
| LEI | Financial compliance | MOD-97 checksum, GLEIF entity status |
5. Parallel validation for performance
The most common mistake in API validation is running checks sequentially when they are independent. If a request contains both an IBAN and an email address, there is no reason to validate them one after the other.
// ❌ Sequential — total latency = sum of all calls (~300ms) const ibanResult = await iv.iban(body.iban) const emailResult = await iv.email(body.email) const vatResult = await iv.vat(body.vat) // ✅ Parallel — total latency = slowest single call (~100ms) const [ibanResult, emailResult, vatResult] = await Promise.all([ iv.iban(body.iban), iv.email(body.email), iv.vat(body.vat), ])
Use Promise.allSettled (Node.js) or asyncio.gather(return_exceptions=True) (Python) when you want to collect all validation errors in a single pass rather than failing on the first one.
// Collect all errors in one round-trip const results = await Promise.allSettled([ iv.iban(body.iban), iv.email(body.email), iv.vat(body.vat), ]) const errors: Record<string, string> = {} const fields = ['iban', 'email', 'vat'] results.forEach((r, i) => { if (r.status === 'fulfilled' && !r.value.valid) { errors[fields[i]] = 'Invalid value' } }) if (Object.keys(errors).length > 0) { return res.status(422).json({ errors }) }
6. Error response format (RFC 9457)
Use HTTP 422 Unprocessable Content for validation failures — not 400 (which is for malformed syntax) and not 500 (which signals your fault, not theirs). RFC 9457 (Problem Details) provides a standard structure:
{ "type": "https://isvalid.dev/errors/validation-failed", "title": "Validation Failed", "status": 422, "detail": "One or more fields failed validation", "errors": { "iban": "Invalid IBAN — MOD-97 checksum failed", "vat": "VAT number not found in VIES registry", "email": "Domain does not have MX records" } }
| Status code | When to use |
|---|---|
| 400 Bad Request | Malformed JSON, wrong Content-Type, missing required headers |
| 422 Unprocessable Content | Well-formed request but invalid field values — this is the right code for validation failures |
| 409 Conflict | Valid data but conflicts with existing state (duplicate email, already registered) |
| 500 Internal Server Error | Your bug — never return this for a validation failure |
7. Validation middleware patterns
Centralise validation in middleware so route handlers only run with clean, verified data.
Express.js — validation middleware
import { createClient } from '@isvalid-dev/sdk' const iv = createClient({ apiKey: process.env.ISVALID_API_KEY! }) export async function validatePaymentBody(req, res, next) { const { iban, bic } = req.body const errors: Record<string, string> = {} const [ibanR, bicR] = await Promise.all([ iv.iban(iban).catch(() => ({ valid: false })), iv.bic(bic).catch(() => ({ valid: false })), ]) if (!ibanR.valid) errors.iban = 'Invalid IBAN' if (!bicR.valid) errors.bic = 'Invalid BIC' if (Object.keys(errors).length > 0) { return res.status(422).json({ status: 422, title: 'Validation Failed', errors }) } next() }
FastAPI — dependency injection
from fastapi import Depends, HTTPException from isvalid_sdk import IsValidConfig, create_client import asyncio iv = create_client(IsValidConfig(api_key="YOUR_API_KEY")) async def validate_payment(iban: str, bic: str): iban_r, bic_r = await asyncio.gather(iv.iban(iban), iv.bic(bic)) errors = {} if not iban_r.valid: errors["iban"] = "Invalid IBAN" if not bic_r.valid: errors["bic"] = "Invalid BIC" if errors: raise HTTPException(status_code=422, detail={"errors": errors}) return {"iban": iban_r, "bic": bic_r} @app.post("/payments") async def create_payment(validated = Depends(validate_payment)): # validated is clean — route handler never sees invalid data ...
8. Checklist
Related guides
Start validating with one API
50+ identifier types. IBAN, VAT, GSTIN, ABN, credit card, IMEI, and more — one SDK, one key.
Get your API key →