The examples so far have been simple — flat objects with basic types. Real APIs are messier. They have nested objects inside nested objects. Arrays of objects with their own nested objects. Optional fields that appear sometimes. Nullable fields. Pagination metadata wrapped around the actual data. Let us handle all of these.
Here is a realistic response from a banking API. Look at the nesting — account has a holder object, which has an address object. Transactions is an array of objects. This is what you will face in real projects.
{
"accountId": "ACC-10045",
"accountType": "savings",
"balance": 125000.50,
"currency": "INR",
"isActive": true,
"holder": {
"name": "Priya Patel",
"pan": "ABCDE1234F",
"phone": "9876543210",
"address": {
"line1": "42, MG Road",
"city": "Mumbai",
"state": "Maharashtra",
"pincode": "400001"
}
},
"transactions": [
{
"txnId": "TXN-001",
"type": "credit",
"amount": 50000,
"date": "2024-01-15",
"description": "Salary credit"
},
{
"txnId": "TXN-002",
"type": "debit",
"amount": 2500,
"date": "2024-01-16",
"description": "UPI transfer"
}
]
}{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "BankAccount",
"type": "object",
"required": ["accountId", "accountType", "balance", "currency", "isActive", "holder"],
"properties": {
"accountId": {
"type": "string",
"pattern": "^ACC-[0-9]+$"
},
"accountType": {
"type": "string",
"enum": ["savings", "current", "fixed_deposit"]
},
"balance": {
"type": "number",
"minimum": 0
},
"currency": {
"type": "string",
"enum": ["INR", "USD", "EUR"]
},
"isActive": {
"type": "boolean"
},
"holder": {
"type": "object",
"required": ["name", "pan", "phone", "address"],
"properties": {
"name": {
"type": "string",
"minLength": 1
},
"pan": {
"type": "string",
"pattern": "^[A-Z]{5}[0-9]{4}[A-Z]$"
},
"phone": {
"type": "string",
"pattern": "^[6-9][0-9]{9}$"
},
"address": {
"type": "object",
"required": ["line1", "city", "state", "pincode"],
"properties": {
"line1": { "type": "string" },
"city": { "type": "string" },
"state": { "type": "string" },
"pincode": {
"type": "string",
"pattern": "^[0-9]{6}$"
}
}
}
}
},
"transactions": {
"type": "array",
"items": {
"type": "object",
"required": ["txnId", "type", "amount", "date"],
"properties": {
"txnId": {
"type": "string",
"pattern": "^TXN-[0-9]+$"
},
"type": {
"type": "string",
"enum": ["credit", "debit"]
},
"amount": {
"type": "number",
"exclusiveMinimum": 0
},
"date": {
"type": "string",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
},
"description": {
"type": "string"
}
}
}
}
}
}When writing schemas for real APIs, start from the inside out. Write the deepest nested object first (address), then its parent (holder), then the root. This way you build the schema layer by layer without getting lost in the nesting.
Most real APIs do not return raw arrays. They wrap the data in a pagination envelope. You need a schema for the wrapper AND the items inside.
{
"data": [
{
"productId": "PROD-101",
"name": "Wireless Mouse",
"price": 599.00,
"category": "electronics",
"inStock": true,
"ratings": {
"average": 4.2,
"count": 156
}
}
],
"pagination": {
"page": 1,
"pageSize": 10,
"totalItems": 87,
"totalPages": 9
}
}{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PaginatedProducts",
"type": "object",
"required": ["data", "pagination"],
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": ["productId", "name", "price", "category", "inStock"],
"properties": {
"productId": { "type": "string", "pattern": "^PROD-[0-9]+$" },
"name": { "type": "string", "minLength": 1 },
"price": { "type": "number", "minimum": 0 },
"category": { "type": "string", "enum": ["electronics", "clothing", "books", "home", "sports"] },
"inStock": { "type": "boolean" },
"ratings": {
"type": "object",
"required": ["average", "count"],
"properties": {
"average": { "type": "number", "minimum": 0, "maximum": 5 },
"count": { "type": "integer", "minimum": 0 }
}
}
}
}
},
"pagination": {
"type": "object",
"required": ["page", "pageSize", "totalItems", "totalPages"],
"properties": {
"page": { "type": "integer", "minimum": 1 },
"pageSize": { "type": "integer", "minimum": 1 },
"totalItems": { "type": "integer", "minimum": 0 },
"totalPages": { "type": "integer", "minimum": 0 }
}
}
}
}Do not forget to validate error responses too. Many teams only validate happy paths. But if the error response structure changes, your error handling code breaks.
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ErrorResponse",
"type": "object",
"required": ["error"],
"properties": {
"error": {
"type": "object",
"required": ["code", "message"],
"properties": {
"code": {
"type": "string",
"enum": ["INVALID_INPUT", "NOT_FOUND", "UNAUTHORIZED", "FORBIDDEN", "SERVER_ERROR"]
},
"message": {
"type": "string",
"minLength": 1
},
"details": {
"type": "array",
"items": {
"type": "object",
"properties": {
"field": { "type": "string" },
"reason": { "type": "string" }
}
}
}
}
}
}
}Always write schemas for both success AND error responses. If the error format changes from { "error": "Not found" } to { "error": { "message": "Not found" } }, your frontend error-handling code breaks. Schema validation catches this.
Q: How would you write a JSON Schema for an API response that contains nested objects and arrays of objects?
A: Define each nesting level explicitly. For nested objects, use "type": "object" with its own "required" and "properties". For arrays of objects, use "type": "array" with "items" containing the object schema. Example: a user with an address object and a list of orders — the address is a nested object schema, orders is an array where "items" defines the order object schema with its own fields and types. I start from the deepest nested object and work outward to avoid getting lost in the structure.
Key Point: Real API schemas have nested objects, arrays of objects, and pagination wrappers. Write schemas for both success and error responses. Start from the innermost object and work outward.