An API contract can look perfectly fine during a review.
The endpoint is documented.
The request and response schemas are available in Swagger.
Every field has a type.
The API returns 200, 400, and 500.
And yet, the integration can still become painful as soon as it reaches production.
The reason is simple:
An API contract is not just a JSON schema. It is an agreement about system behavior.
As a system analyst, I try not to approve an API contract only by checking whether the OpenAPI specification is valid.
I want to know what happens with invalid data, repeated requests, unclear field names, business errors, and future changes.
Here are five things I usually check before considering an API contract ready for development.
1. Validation: Is invalid input clearly defined?
A schema may tell us that a field is a string.
That does not tell us whether the value makes sense.
Consider this request:
{
"orderId": "ORD-10291",
"deliveryType": "PICKUP",
"pickupPointId": null,
"quantity": 0
}
Technically, this JSON is perfectly valid.
But from a business perspective, there are at least two problems:
-
pickupPointIdis missing even though the customer selected pickup; -
quantityis zero.
A weak contract may document the fields like this:
deliveryType:
type: string
pickupPointId:
type: string
quantity:
type: integer
This describes the structure, but not the rules.
A better specification makes validation explicit.
For example:
deliveryType:
Allowed values:
- COURIER
- PICKUP
pickupPointId:
Required when deliveryType = PICKUP.
quantity:
Minimum value: 1.
Now the consumer knows exactly what is accepted.
A more realistic example
Imagine an endpoint for creating a transfer:
POST /api/v1/transfers
Request:
{
"sourceAccountId": "ACC-1001",
"destinationAccountId": "ACC-1002",
"amount": 5000,
"currency": "RUB"
}
Possible validation rules may include:
sourceAccountId != destinationAccountId
amount > 0
currency must match the currency of sourceAccountId
destinationAccountId must exist
source account must be active
Notice that only one of these rules can be expressed easily using basic JSON Schema.
The rest are business validation rules.
That distinction matters.
I usually separate validation into three levels:
Structural validation
Examples:
amount is required
amount must be a number
currency must be a string
Format validation
Examples:
email must match an email format
countryCode must follow ISO 3166-1 alpha-2
timestamp must use ISO 8601
Business validation
Examples:
pickupPointId is required when deliveryType = PICKUP
endDate must be later than startDate
a cancelled order cannot be paid
transfer amount cannot exceed available balance
If business validation is not documented, the actual API contract ends up hidden inside implementation code.
Consumers then learn the rules through failed requests.
That is not a great integration experience.
2. Errors: Can another system understand what actually went wrong?
This is one of the first things I look at.
A weak error response often looks like this:
{
"message": "Unable to process request"
}
Or even worse:
{
"error": "Something went wrong"
}
This might be enough for a human reading logs.
It is almost useless for another system.
Imagine a delivery service receives this error after attempting to create a shipment:
409 Conflict
{
"message": "Unable to create delivery"
}
Why?
Maybe:
- the order was cancelled;
- the delivery already exists;
- the address is invalid;
- the order is not ready for delivery;
- a temporary lock exists.
The consumer cannot reliably decide what to do next.
A better response contains a stable machine-readable code.
For example:
{
"code": "ORDER_ALREADY_CANCELLED",
"message": "Delivery cannot be created for a cancelled order",
"details": {
"orderId": "ORD-10291"
}
}
Now the consuming system can implement deterministic logic.
For example:
ORDER_ALREADY_CANCELLED
→ do not retry
DELIVERY_ALREADY_EXISTS
→ request existing delivery
DELIVERY_SERVICE_TEMPORARILY_UNAVAILABLE
→ retry later
INVALID_ADDRESS
→ request corrected customer data
That is much more useful.
Validation errors should also be structured
Instead of this:
{
"message": "Invalid request"
}
I prefer something like:
{
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"errors": [
{
"field": "pickupPointId",
"code": "FIELD_REQUIRED",
"message": "pickupPointId is required when deliveryType is PICKUP"
},
{
"field": "quantity",
"code": "INVALID_VALUE",
"message": "quantity must be greater than 0"
}
]
}
Now both humans and machines can understand the problem.
I also check HTTP status code consistency
For example:
400 Bad Request
Malformed or structurally invalid request.
401 Unauthorized
Authentication is missing or invalid.
403 Forbidden
Authentication succeeded, but the caller does not have permission.
404 Not Found
Requested resource does not exist.
409 Conflict
Request conflicts with the current resource state.
422 Unprocessable Entity
Request is structurally valid but violates business rules.
500 Internal Server Error
Unexpected server-side failure.
503 Service Unavailable
Temporary service failure.
There is no single universal mapping that every organization must use.
Consistency is more important than the exact convention.
The same business situation should not return 400 in one endpoint, 409 in another, and 500 somewhere else.
3. Idempotency: What happens if the same request is sent twice?
This is one of my favorite questions during API reviews:
What happens if I send exactly the same request twice?
It sounds simple.
But it reveals a lot about the contract.
Imagine an API for creating a payment:
POST /api/v1/payments
Request:
{
"orderId": "ORD-10291",
"amount": 4990,
"currency": "RUB"
}
The payment service processes the request successfully.
But before the response reaches the caller, the network connection is interrupted.
The caller sees:
Timeout
From its perspective, the payment may or may not have happened.
So it retries the request.
Without idempotency, this can produce:
Payment #1: 4990 RUB
Payment #2: 4990 RUB
The API worked exactly as implemented.
The business process failed.
One common solution is an idempotency key.
POST /api/v1/payments
Idempotency-Key: 708a8122-c4ea-48f8-b95c-e660618252ef
First request:
{
"orderId": "ORD-10291",
"amount": 4990,
"currency": "RUB"
}
Response:
{
"paymentId": "PAY-72001",
"status": "COMPLETED"
}
The same request arrives again with the same key.
Instead of creating another payment, the API returns the result of the original operation:
{
"paymentId": "PAY-72001",
"status": "COMPLETED"
}
No duplicate transaction is created.
But idempotency itself needs rules
Just adding an Idempotency-Key header is not enough.
I also want to know:
- How long is the key stored?
- Is the key unique globally or per consumer?
- What happens if the same key is used with a different payload?
- What HTTP response is returned for a duplicate request?
- Can a failed request be retried with the same key?
For example:
First request:
Idempotency-Key: ABC-123
{
"amount": 5000
}
Later:
Idempotency-Key: ABC-123
{
"amount": 7000
}
This should probably not silently reuse the first result.
The API might return:
409 Conflict
{
"code": "IDEMPOTENCY_KEY_REUSED",
"message": "The idempotency key has already been used with a different request payload"
}
That behavior should be part of the contract.
Not an implementation detail discovered later.
4. Naming: Does the contract communicate business meaning?
Naming problems often look harmless during development.
Later, they become permanent.
Consider this response:
{
"id": "12345",
"status": 1,
"date": "2026-09-22",
"price": 1200
}
Every field is technically valid.
But I immediately have questions.
What is id?
Order ID?
Payment ID?
Delivery ID?
What does status = 1 mean?
What kind of date is this?
And what exactly is included in price?
Compare it with:
{
"orderId": "ORD-12345",
"orderStatus": "PAID",
"createdAt": "2026-09-22T10:15:00Z",
"finalPrice": {
"amount": 1200,
"currency": "RUB"
}
}
The second contract is slightly longer.
But it communicates much more.
Boolean fields are another good example
This:
{
"delivery": true
}
is ambiguous.
Does it mean:
- delivery is available?
- delivery is required?
- delivery already exists?
- delivery is completed?
Better:
{
"deliveryAvailable": true
}
or:
{
"requiresDelivery": true
}
depending on the intended meaning.
Avoid names that expose implementation details
Another example:
{
"customerTableId": 918273
}
The consumer should usually not care that the data comes from a database table.
A contract closer to the business domain would use:
{
"customerId": "CUS-918273"
}
API contracts tend to live longer than internal implementations.
Database tables may change.
Services may be rewritten.
Storage technologies may be replaced.
Business concepts usually survive much longer.
That is why I prefer contract names based on domain meaning rather than implementation structure.
Consistency matters too
This is unnecessarily difficult:
GET /api/v1/customer/{id}
GET /api/v1/orders/{orderId}
GET /api/v1/payment/{payment_id}
Three endpoints use three different naming conventions.
A consistent API might use:
GET /api/v1/customers/{customerId}
GET /api/v1/orders/{orderId}
GET /api/v1/payments/{paymentId}
Small naming decisions accumulate.
A predictable API is much easier to understand and integrate with.
5. Backwards compatibility: What happens when the contract changes?
The first version of an API is usually easy.
The interesting part starts when somebody asks:
Can we just add one more field?
Sometimes yes.
Sometimes that "small change" breaks three consumers.
Suppose the original response is:
{
"orderId": "ORD-10291",
"status": "NEW"
}
Later we add an optional field:
{
"orderId": "ORD-10291",
"status": "NEW",
"deliveryType": "COURIER"
}
In most well-designed consumers, this is a backwards-compatible change.
Old consumers simply ignore the new field.
Now consider changing:
{
"status": "NEW"
}
into:
{
"status": {
"code": "NEW",
"label": "New order"
}
}
The information may be better structured.
But every consumer expecting status to be a string can break immediately.
That is a breaking change.
Renaming fields is also a breaking change
Original:
{
"price": 1200
}
New:
{
"finalPrice": 1200
}
From a business perspective, the new name may be much clearer.
From the consumer's perspective, the field disappeared.
A safer migration could temporarily support both:
{
"price": 1200,
"finalPrice": 1200
}
while marking price as deprecated.
Then consumers can migrate before the old field is removed in a new major version.
Enum changes are especially dangerous
Imagine this contract:
status:
NEW
PAID
CANCELLED
A consumer implements:
if NEW → show "New"
if PAID → show "Paid"
if CANCELLED → show "Cancelled"
Later the API adds:
PAYMENT_PENDING
Even though no existing value changed, the new enum value may break consumers that assume the list is exhaustive.
This is why I always think about how consumers handle unknown enum values.
In some integrations, adding an enum value should be treated as a potentially breaking change.
Changing required fields is another common problem
Version 1:
{
"orderId": "ORD-10291"
}
Version 2 suddenly requires:
{
"orderId": "ORD-10291",
"sourceSystem": "MOBILE_APP"
}
Existing consumers do not know that they need to send sourceSystem.
Their previously valid requests now fail.
Making a new field mandatory is therefore usually a breaking change.
A safer approach may be:
- Introduce the field as optional.
- Update consumers.
- Monitor adoption.
- Make it mandatory only in a new API version.
Before approving a contract, I ask one more question
Not only:
Does this API work today?
But also:
Can we evolve this API tomorrow without breaking everyone using it?
That question often changes how the initial contract is designed.
My API Contract Review Checklist
Before approving an API contract, I usually check at least these five areas:
Validation
Are both structural and business validation rules documented?
Errors
Can consumers react to errors programmatically?
Idempotency
Is repeated request behavior defined?
Naming
Do field and endpoint names represent clear business concepts?
Backwards compatibility
Can the contract evolve without unexpectedly breaking existing consumers?
None of these things are particularly complicated individually.
The problem is that they are easy to ignore when everyone is focused on getting the first successful request working.
And the happy path is usually not where integration problems happen.
The difficult bugs appear around retries, invalid states, ambiguous meanings, old consumers, and unexpected failures.
That is why, for me, reviewing an API contract is not just checking whether the JSON looks correct.
It is asking:
Do both systems have the same understanding of how this interaction is supposed to behave?
If the answer is yes, the API is usually in a much better shape before the first line of integration code is written.
Top comments (0)