All articles
169 articles · updated weekly See our Tools
All articles
Tutorials

JSON Schema for REST APIs: The Design Decisions That Make the Contract Work

How to design a JSON Schema to validate REST API payloads — from amount with multipleOf to enums, arrays, and $ref — with the design decisions explained.

JSON Schema for REST APIs: The Design Decisions That Make the Contract Work
COVER · Tutorials

Documenting an API endpoint with JSON Schema isn't hard. What's hard is making the right decisions along the way — what goes in required, when to use $ref, how to declare enums, how to handle fields that evolve. These decisions determine whether the schema will be useful six months from now or will be an obstacle worked around with additionalProperties: true everywhere.

This post starts from a real endpoint and walks through each design decision, with the reasoning behind each one.


The endpoint: POST /payments

Let's use a payment creation endpoint as an example — common enough to be recognizable, complex enough to cover the relevant cases.

The expected payload:

{
  "amount": 150.00,
  "currency": "USD",
  "payment_method": "credit_card",
  "customer": {
    "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "email": "customer@example.com"
  },
  "items": [
    {
      "product_id": "prod_123",
      "quantity": 2,
      "unit_price": 75.00
    }
  ],
  "metadata": {
    "order_ref": "ORD-2026-001"
  }
}

Starting at the root level

The schema starts simple and grows. Root level first:

{
  "$schema": "https://json-schema.org/draft-07/schema#",
  "type": "object",
  "properties": {
    "amount": {},
    "currency": {},
    "payment_method": {},
    "customer": {},
    "items": {},
    "metadata": {}
  },
  "required": ["amount", "currency", "payment_method", "customer", "items"],
  "additionalProperties": false
}

metadata is not in required — it's optional. additionalProperties: false rejects any field outside those listed in properties. This is the structure that will be filled in.

The decision to use additionalProperties: false at the root level is defensive: it prevents unexpected fields from entering the system without being declared in the schema. In public APIs, this is important — without this restriction, a client can send arbitrary fields that pass validation silently.


Validating numeric values: amount

"amount": {
  "type": "number",
  "minimum": 0.01,
  "multipleOf": 0.01
}

"type": "number" accepts integers and decimals. "minimum": 0.01 rejects zero and negatives — you don't want to process a $0.00 payment. "multipleOf": 0.01 ensures the value has at most two decimal places — $10.123 is not a valid monetary value.

A common alternative is representing monetary values in cents as an integer: "amount": 1000 for $10.00. This eliminates floating-point precision issues but requires the consumer to do the conversion. The decision depends on your API convention — the important thing is that the schema reflects what you actually accept.


Enums for status and type fields: currency and payment_method

"currency": {
  "type": "string",
  "enum": ["USD", "EUR", "GBP", "JPY"]
},
"payment_method": {
  "type": "string",
  "enum": ["credit_card", "debit_card", "paypal", "bank_transfer"]
}

enum defines the exact list of allowed values. Any value outside the list is rejected. For status, type, category, currency fields — enum is the right choice.

The pitfall: enums grow. Six months from now you'll want to add "crypto" to payment_method. Adding to the enum is a backward-compatible change — the schema accepts values it previously rejected. Removing is breaking — the schema starts rejecting values it previously accepted. Evolve enums carefully.


Nested objects: customer

"customer": {
  "type": "object",
  "properties": {
    "id": {
      "type": "string",
      "format": "uuid"
    },
    "email": {
      "type": "string",
      "format": "email"
    }
  },
  "required": ["id", "email"],
  "additionalProperties": false
}

Nested object with its own required and additionalProperties: false. Each level has its own restrictions — the root level's required doesn't propagate into the customer object.

"format": "uuid" and "format": "email" validate the semantic format of the value. Caution: in default AJV, format is not validated by default — you need the ajv-formats package and enable it with { formats: true }. Without this, format is silently ignored and any string passes. If format matters to you, confirm that the validator has support enabled.


Arrays of objects: items

"items": {
  "type": "array",
  "minItems": 1,
  "items": {
    "type": "object",
    "properties": {
      "product_id": { "type": "string", "minLength": 1 },
      "quantity": { "type": "integer", "minimum": 1 },
      "unit_price": { "type": "number", "minimum": 0 }
    },
    "required": ["product_id", "quantity", "unit_price"],
    "additionalProperties": false
  }
}

"minItems": 1 rejects empty arrays — a payment without items makes no sense. "items" defines the schema for each array element. All elements must pass the same schema.

"type": "integer" for quantity — quantity can't be fractional. "minimum": 1 rejects zero. "minimum": 0 for unit_price — allows free items but rejects negative prices.


Optional field with free structure: metadata

"metadata": {
  "type": "object",
  "additionalProperties": {
    "type": "string"
  },
  "maxProperties": 10
}

metadata is optional (not in the root level required). When present, accepts any key as long as the value is a string. additionalProperties with a schema (instead of false or true) defines the schema that all additional values must follow. "maxProperties": 10 limits the object size to prevent abuse.

This pattern is useful for free data fields where you don't want to enumerate all possible keys but still want restriction on the type of values.


Using $ref to avoid repetition

If customer appears in multiple endpoints, extract it to a reusable definition:

{
  "$schema": "https://json-schema.org/draft-07/schema#",
  "definitions": {
    "Customer": {
      "type": "object",
      "properties": {
        "id": { "type": "string", "format": "uuid" },
        "email": { "type": "string", "format": "email" }
      },
      "required": ["id", "email"],
      "additionalProperties": false
    }
  },
  "type": "object",
  "properties": {
    "customer": { "$ref": "#/definitions/Customer" }
  }
}

$ref references the schema by path. #/definitions/Customer points to definitions.Customer in the same document. In separate schemas, $ref can point to a file or URL — AJV resolves external references with ajv.addSchema().

When you have $ref in the schema and receive a validation error, the schemaPath in the error object points to where in the compiled schema the keyword failed, not necessarily where the $ref is. Use the JSON Schema Validator to visualize where the failure is in the schema before debugging in code.


The complete schema

{
  "$schema": "https://json-schema.org/draft-07/schema#",
  "type": "object",
  "properties": {
    "amount": {
      "type": "number",
      "minimum": 0.01,
      "multipleOf": 0.01
    },
    "currency": {
      "type": "string",
      "enum": ["USD", "EUR", "GBP", "JPY"]
    },
    "payment_method": {
      "type": "string",
      "enum": ["credit_card", "debit_card", "paypal", "bank_transfer"]
    },
    "customer": {
      "type": "object",
      "properties": {
        "id": { "type": "string", "format": "uuid" },
        "email": { "type": "string", "format": "email" }
      },
      "required": ["id", "email"],
      "additionalProperties": false
    },
    "items": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "object",
        "properties": {
          "product_id": { "type": "string", "minLength": 1 },
          "quantity": { "type": "integer", "minimum": 1 },
          "unit_price": { "type": "number", "minimum": 0 }
        },
        "required": ["product_id", "quantity", "unit_price"],
        "additionalProperties": false
      }
    },
    "metadata": {
      "type": "object",
      "additionalProperties": { "type": "string" },
      "maxProperties": 10
    }
  },
  "required": ["amount", "currency", "payment_method", "customer", "items"],
  "additionalProperties": false
}

How this schema evolves

JSON Schema is code. It will change along with the API. Some practical rules for when that happens:

Backward-compatible changes (don't break existing clients): add optional field, expand an enum, relax a minimum, make a required optional.

Breaking changes: remove field from properties, remove value from enum, add field to required, make constraint stricter.

Versioning schemas together with the API is the cleanest approach. If you use OpenAPI, the schema lives within the spec and is versioned together. If you use standalone schemas, treat them as you would a public interface — explicit changelog for breaking changes.

The input schema and output schema are different contracts. An endpoint can have a stricter request schema and a response schema that includes computed fields (id, created_at, status) that the client doesn't send but receives.


Note: the editorial content ends here. What follows is a recommendation for a tool related to the post's topic.


To validate the schema as you write it — before integrating in code — use the JSON Schema Validator. Paste the schema and a test payload, and see the errors with the exact path of each failure. Useful for testing edge cases: empty array, null field, enum with out-of-list value, nested object without required.

RD
Author
Rafael Duarte
Backend developer with a background in fintech and B2B SaaS — worked on teams that scaled APIs from zero to millions of requests. Carries enough production scars to hold strong opinions on tools, patterns and architecture decisions. Not an academic: he read the UUID RFC when he had to choose between v4 and v7 for a write-heavy table.
View profile