JSON Schema for Payment Requests
Part of the JSON Toolkit{"oneOf":[{"properties":{"type":{"const":"card"}}},{"properties":{"type":{"const":"bank_transfer"}}}]}a tagged union — exactly one payment method shape can match, picked by the type field's const value
Explanation
A payment request has to accept more than one shape of payment method — a card and a bank transfer don't carry the same fields — while still rejecting anything that mixes the two together or leaves out which one was actually intended. That's a tagged union: a set of alternative shapes distinguished by one discriminator field, and oneOf plus const is the standard way to express it in JSON Schema.
{
"$defs": {
"money": {
"type": "object",
"properties": {
"amount": { "type": "integer", "minimum": 1 },
"currency": { "type": "string", "enum": ["USD", "EUR", "GBP"] }
},
"required": ["amount", "currency"],
"additionalProperties": false
}
},
"type": "object",
"properties": {
"amount": { "$ref": "#/$defs/money" },
"paymentMethod": {
"oneOf": [
{
"type": "object",
"properties": {
"type": { "const": "card" },
"cardNumber": { "type": "string", "pattern": "^[0-9]{13,19}$" },
"expiryMonth": { "type": "integer", "minimum": 1, "maximum": 12 },
"expiryYear": { "type": "integer", "minimum": 2024 }
},
"required": ["type", "cardNumber", "expiryMonth", "expiryYear"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": { "const": "bank_transfer" },
"accountNumber": { "type": "string" },
"routingNumber": { "type": "string", "pattern": "^[0-9]{9}$" }
},
"required": ["type", "accountNumber", "routingNumber"],
"additionalProperties": false
}
]
},
"description": { "type": "string" },
"idempotencyKey": { "type": "string", "minLength": 1 }
},
"required": ["amount", "paymentMethod", "idempotencyKey"],
"additionalProperties": false
}Each branch of oneOf pins type to a different const value, so a card payload and a bank-transfer payload can never both match the same branch. additionalProperties: false on each branch is what actually makes them mutually exclusive, though: without it, an object carrying both cardNumber and accountNumber together would still be rejected by the branch whose type doesn't match, but nothing would stop a more loosely-written branch from silently accepting fields that belong to the other payment method.
Given how tightly the branches are already pinned by const and additionalProperties: false, swapping oneOf for anyOf here would accept and reject exactly the same objects today — oneOf's "exactly one match" rule and anyOf's "at least one match" rule land on the same answer when the branches can't overlap. The reason to still write oneOf is what happens later: if a future edit ever loosens one branch — dropping additionalProperties: false, or making type optional — and the branches start overlapping by accident, oneOf immediately starts rejecting the now-ambiguous objects as matching more than one schema. anyOf would keep silently accepting them, with no signal that the union stopped being exclusive.
amount reuses the same money shape as a product catalog — integer cents plus a currency code, not a decimal — but with minimum: 1 instead of 0. A catalog price of zero is a valid free item; a payment request for zero currency units isn't a real charge, so the minimum here encodes a business rule, not just a type constraint.
idempotencyKey is required, not optional. Payment requests are the classic case for idempotency: a client that times out waiting for a response has no way to know whether the charge actually went through, and retrying a plain request risks charging the customer twice. A required, client-generated key lets the server recognize "I've already processed this exact request" and return the original result instead of creating a second charge.
For the vocabulary behind oneOf, const, and $ref, see What Is JSON Schema? and JSON Schema Examples. For an array-based request body instead of a tagged union, see JSON Schema for API Requests. For a case where the shape mostly stays the same and only a field's requiredness depends on another field, see JSON Schema for Blog Posts — the if/then/else alternative to a full oneOf split.
Valid examples
{"amount":{"amount":2500,"currency":"USD"},"paymentMethod":{"type":"card","cardNumber":"4242424242424242","expiryMonth":12,"expiryYear":2027},"idempotencyKey":"req_a1b2c3"}A card payment matching the first oneOf branch exactly, with every field the card branch requires.
{"amount":{"amount":10000,"currency":"EUR"},"paymentMethod":{"type":"bank_transfer","accountNumber":"12345678","routingNumber":"021000021"},"idempotencyKey":"req_d4e5f6"}A bank transfer matching the second branch — different required fields, same oneOf property.
{"amount":{"amount":500,"currency":"USD"},"paymentMethod":{"type":"card","cardNumber":"4000056655665556","expiryMonth":3,"expiryYear":2026},"idempotencyKey":"req_g7h8i9"}description left out entirely — it's not in required, so the object is still valid without it.
Invalid examples
{"amount":{"amount":2500,"currency":"USD"},"paymentMethod":{"type":"card","cardNumber":"4242424242424242","expiryMonth":12,"expiryYear":2027,"accountNumber":"12345678"},"idempotencyKey":"req_x1"}accountNumber doesn't belong on a card payment — fails additionalProperties: false on the card branch, and fails the bank_transfer branch's type const too, so it matches zero branches.
{"amount":{"amount":0,"currency":"USD"},"paymentMethod":{"type":"card","cardNumber":"4242424242424242","expiryMonth":12,"expiryYear":2027},"idempotencyKey":"req_x2"}amount.amount is 0 — fails the money definition's minimum: 1, which rejects a zero-value charge as not a real payment.
{"amount":{"amount":2500,"currency":"USD"},"paymentMethod":{"type":"card","cardNumber":"4242424242424242","expiryMonth":12,"expiryYear":2027}}No idempotencyKey — fails the top-level required array, even though the rest of the request is well-formed.