The Web & APIs pages explain how to design a good HTTP API: naming and consistency, error responses, OpenAPI descriptions, versioning styles and deprecation headers. This page does not repeat that. It is about what changes when the API is between your own services: many consumers you know by name, releases that never happen at the same moment, and events that are contracts too. The running example is Kestrel Energy's customer portal, which shows annual statements from the billing system KBS through GET /v1/statements/{customer}, and meter-api, which publishes a ReadingReceived event for every smart-meter reading.

Internal APIs are still APIs

An API contract between two teams in the same company is easy to underestimate. "We can just tell them" works until the other team is busy for a month, or turns out to be five teams. Two facts make internal contracts as serious as public ones:

  • Releases are independent. The point of separate services is that KBS can be released without the portal. So for a while, every new KBS version runs against the old portal, and every new portal version may run against the old KBS (after a rollback, for example). Both combinations must work.
  • Consumers multiply. The statements endpoint was built for the portal. Since then customer service's tools, a monthly report and the debt collection export have started using it, and nobody told the billing team.

The one exception is a modular monolith: its module interfaces change and deploy together in one commit, so the compiler and the tests catch a broken caller before release. That is a real advantage of keeping modules in one deployment unit.

API-first

API-first is a way of working in which the contract of an API or event, written in a description format such as OpenAPI or AsyncAPI, is designed and agreed with its consumers before the implementation is built, and then used to generate documentation, mock servers, client code and tests. The contract becomes the plan both sides build against, instead of a description written after the fact.

For the statements endpoint that means, in order:

  1. Joost Bakker's billing team drafts the OpenAPI description in a pull request, with example responses.
  2. The portal team reviews it, as consumers: are the fields we need there, named the way the business names them, with the types we expect?
  3. Once it is merged, the portal team builds against a mock server generated from the description, while the billing team implements the real endpoint. Neither waits for the other.
  4. A check in the billing team's pipeline compares the running service with the description, so the two cannot drift apart.

As of October 2026 the latest OpenAPI version is 3.2.1 (September 2026); 3.1 is still what most tools are built around. For messages and events the equivalent format is AsyncAPI, covered further down.

Backward and forward compatibility

Backward compatibility means that a new version of a provider still works for consumers written against the old one; the same page lists which changes are breaking changes. Between services there is a second direction. Forward compatibility is the ability of an existing consumer or reader to handle data produced by a newer version of the provider or schema, usually by ignoring fields and values it does not know. It matters most for events and stored data, where old and new readers and writers exist at the same time:

Situation Needs
KBS 4.8 is released; the portal still runs the old code Backward compatibility of the KBS API
A new KBS consumer replays last year's ReadingReceived events Backward compatibility of the event schema
meter-api starts publishing events with a new field; the portal's usage cache has not been updated Forward compatibility of the old consumer
meter-api is rolled back while newer events are still in the queue Both

Google's API Improvement Proposals (AIP-180) usefully split compatibility into three kinds. Source compatibility: code written against the old version still compiles. Wire compatibility: an old client still communicates correctly with a new server. Semantic compatibility: the old client "must continue to receive what most reasonable developers would expect." The third is the one that slips through reviews: a field that keeps its name and type but changes its meaning, such as total suddenly including VAT, breaks every consumer without breaking a single test.

The tolerant reader

A tolerant reader is a consumer that reads only the fields it needs from a message or response, ignores fields it does not know, and copes with optional fields being absent, so that additions by the provider do not break it. Martin Fowler's advice (2011) is "to be as tolerant as possible when reading data from a service". A strict reader that rejects anything unexpected turns every harmless addition into an outage:

import json

# Version 1 of the event, and version 2, in which meter-api added "quality".
v1 = '{"ean": "871687400000000017", "intervalStart": "2026-10-06T02:00", "kwh": 0.40}'
v2 = ('{"ean": "871687400000000017", "intervalStart": "2026-10-06T02:15",'
      ' "kwh": 0.42, "quality": "estimated"}')
EXPECTED = {"ean", "intervalStart", "kwh"}


def strict_reader(raw):
    data = json.loads(raw)
    unknown = data.keys() - EXPECTED
    if unknown:
        raise ValueError(f"unexpected field(s): {', '.join(sorted(unknown))}")
    return data


def tolerant_reader(raw):
    data = json.loads(raw)
    # Take only what this consumer needs; ignore the rest.
    return {"ean": data["ean"], "interval": data["intervalStart"],
            "kwh": float(data["kwh"]),
            "quality": data.get("quality", "measured")}  # default for v1 events


print("tolerant v1:", tolerant_reader(v1))
print("tolerant v2:", tolerant_reader(v2))
print("strict v2:   ", end="")
try:
    strict_reader(v2)
except ValueError as error:
    print("rejected,", error)
tolerant v1: {'ean': '871687400000000017', 'interval': '2026-10-06T02:00', 'kwh': 0.4, 'quality': 'measured'}
tolerant v2: {'ean': '871687400000000017', 'interval': '2026-10-06T02:15', 'kwh': 0.42, 'quality': 'estimated'}
strict v2:   rejected, unexpected field(s): quality

The tolerant reader handles both versions and fills in a default for the older one; the strict reader rejects version 2 because of one new field. Tolerance has limits: a reader should still refuse data it cannot interpret safely, such as a missing kwh or a reading as text. In TypeScript, data from the network has the type unknown until it is checked, and the check picks out exactly the fields the consumer relies on:

interface Reading {
  ean: string;
  intervalStart: string;
  kwh: number;
}

// The message comes off the wire: its type is unknown until we check it.
function readReading(raw: unknown): Reading {
  if (typeof raw !== "object" || raw === null) {
    throw new Error("not an object");
  }
  const data = raw as Record<string, unknown>;
  const { ean, intervalStart, kwh } = data;
  if (typeof ean !== "string" || typeof intervalStart !== "string" ||
      typeof kwh !== "number") {
    throw new Error("missing or mistyped field");
  }
  return { ean, intervalStart, kwh }; // unknown extra fields are left behind
}

const v2: unknown = JSON.parse(
  '{"ean":"871687400000000017","intervalStart":"2026-10-06T02:15",' +
    '"kwh":0.42,"quality":"estimated"}',
);
console.log(readReading(v2));

try {
  readReading(JSON.parse('{"ean":"871687400000000017","kwh":"0.42"}'));
} catch (e) {
  console.log("rejected:", (e as Error).message);
}
{
  ean: '871687400000000017',
  intervalStart: '2026-10-06T02:15',
  kwh: 0.42
}
rejected: missing or mistyped field

The extra quality field is accepted and left behind; the message with a missing interval and a kwh that is a string is refused. Note that structural typing alone does not protect you: a type assertion such as JSON.parse(raw) as Reading checks nothing at run time.

Hyrum's law

Tolerance on the consumer's side has a mirror image on the provider's side. Hyrum's law, named after Hyrum Wright at Google, says: "With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody." The order of results, the exact wording of an error message, how long a call takes: if consumers can see it, someone relies on it. At Kestrel, the documentation of the statements endpoint never promised an order, but KBS happened to return the oldest statement first, and the portal took the last one as the current year:

# KBS returns a customer's annual statements. The API documentation promises
# nothing about their order, but until 4.7.0 they came out oldest first.
kbs_4_6 = [{"year": 2024, "total": 1388.40}, {"year": 2025, "total": 1432.18}]
kbs_4_7 = [{"year": 2025, "total": 1432.18}, {"year": 2024, "total": 1388.40}]


def latest_fragile(statements):        # relies on behavior nobody promised
    return statements[-1]


def latest_robust(statements):         # relies only on the contract
    return max(statements, key=lambda s: s["year"])


for version, statements in (("4.6.2", kbs_4_6), ("4.7.0", kbs_4_7)):
    print(f"KBS {version}: fragile shows {latest_fragile(statements)['year']},"
          f" robust shows {latest_robust(statements)['year']}")
KBS 4.6.2: fragile shows 2025, robust shows 2025
KBS 4.7.0: fragile shows 2024, robust shows 2025

KBS 4.7.0 changed a database query, the order flipped, and customers saw last year's statement as their current one. Who was wrong? The portal relied on something unpromised, but the outage was real either way. Providers defend themselves by keeping the observable surface small and explicit: document the order (or sort deliberately), return stable error type values and tell clients not to parse the human text, and occasionally vary what is not promised so nobody can come to depend on it.

Changing a contract safely

Most changes can be made without a new version, by never changing anything in place. A rename such as advance_paid to advance_payments_total becomes a sequence of compatible steps, often called expand and contract:

  1. Expand. KBS returns both fields, with the same value. Nothing breaks.
  2. Announce. KBS marks the old field as deprecated in the description and the changelog, and logs which clients still read it, if it can tell.
  3. Migrate. Each consumer switches to the new field in its own release, in its own time.
  4. Contract. When no consumer uses the old field any more, KBS removes it.

The same steps work for database columns behind a service and for event fields. They take longer than a rename in place, but every step can be released and rolled back on its own.

For changes that cannot be split like that, such as a completely different resource model, a new major version runs next to the old one. Follow the versioning style the Web & APIs pages describe; Google's AIP-185 puts only the major version in the path (/v1/) and says APIs "must not expose minor or patch version numbers" there. Number the description itself with semantic versioning so consumers can see at a glance whether a release only adds things. Do not create a new major version for additions: v1, v2, v3 for three new fields means three versions to run, secure and retire.

Retiring an old version or field is deprecation, with the same headers and announcements as for a public API. The internal advantage is that you can find every caller: give each consuming service its own credentials or client ID, log it with every request, and you have a list of whom to contact instead of a date to hope for.

Consumer-driven contracts

A provider cannot know which parts of its API matter unless the consumers say so. A consumer-driven contract is a description, written by or generated from the tests of one consumer, of the requests that consumer makes and the parts of the responses it depends on; the provider runs every consumer's contract against its own code before release. Ian Robinson described the pattern in 2006 ("Consumer-Driven Contracts: A Service Evolution Pattern", on martinfowler.com). The general idea of contract testing is on the Web & APIs pages; this section shows the consumer-driven flow.

The portal needs three fields from each statement, with these types. The billing team's pipeline checks every KBS build against that contract:

# The portal's contract for KBS: GET /v1/statements/{customer} must return
# at least these fields with these types. Anything else is the provider's business.
PORTAL_NEEDS = {"period": str, "total": float, "advance_paid": float}

# What two KBS versions actually return (recorded by the provider's tests).
responses = {
    "kbs 4.7.0": {"period": "2025-10/2026-09", "total": 1432.18,
                  "advance_paid": 1380.00, "currency": "EUR"},
    "kbs 4.8.0": {"period": "2025-10/2026-09", "total": 1432.18,
                  "advance_payments_total": 1380.00, "currency": "EUR",
                  "pdf_url": "/v1/statements/C-10442.pdf"},
}


def verify(contract, response):
    problems = []
    for field, kind in contract.items():
        if field not in response:
            problems.append(f"missing field '{field}'")
        elif not isinstance(response[field], kind):
            problems.append(f"'{field}' is {type(response[field]).__name__},"
                            f" expected {kind.__name__}")
    return problems


for version, response in responses.items():
    problems = verify(PORTAL_NEEDS, response)
    print(f"{version}: {'contract verified' if not problems else 'FAILED'}")
    for problem in problems:
        print("   ", problem)
kbs 4.7.0: contract verified
kbs 4.8.0: FAILED
    missing field 'advance_paid'

KBS 4.7.0 passes, including its extra currency field that the portal does not use. In 4.8.0 a developer renamed advance_paid, and the provider's own pipeline fails before the release, with a message that names the field the portal's contract needs. Adding pdf_url is fine, because no contract mentions it. That is what makes the approach precise: the provider learns not only that something breaks, but for whom.

Pact is the most widely used tool for this. Its documentation calls it "a code-first tool for testing HTTP and message integrations using contract tests", where "the contract is generated during the execution of the automated consumer tests". The flow:

The consumer-driven contract flow. The portal's tests record what the portal needs as a contract file. The contract is published to a contract broker. KBS's pipeline fetches the contract, verifies it against KBS and sends the result back to the broker. Before either side deploys, can-i-deploy asks the broker whether the versions that would run together have been verified. portal tests record needs contract JSON file contract broker KBS pipeline verifies contract consumer pipeline provider pipeline can-i-deploy deploy only versions verified against each other
Each side tests on its own, in its own pipeline; the broker connects the results. No shared test environment is needed.

The Pact Broker is "an application for sharing consumer driven contracts and verification results". Before a deployment, each pipeline asks it whether the version about to be released has been verified against the versions it will run with in that environment:

pact-broker can-i-deploy --pacticipant kbs --version 4.8.0 --to-environment production
Computer says no ¯\_(ツ)_/¯

CONSUMER        | C.VERSION | PROVIDER | P.VERSION | SUCCESS? | RESULT#
----------------|-----------|----------|-----------|----------|--------
customer-portal | 3.12.0    | kbs      | 4.8.0     | false    | 1

VERIFICATION RESULTS
--------------------
1. https://pact-broker.example.com/pacts/provider/kbs/consumer/customer-portal/pact-version/… (failure)

The verification for the pact between the version of customer-portal currently in production (3.12.0) and version 4.8.0 of kbs failed

Which field failed (advance_paid) is shown in KBS's own verification run, linked as result 1. The rename never reaches production. The billing team switches to expand and contract: 4.8.1 returns both fields, passes, and is released; the portal moves to the new field when it suits them.

Contracts for events

Events are harder to change than request-response APIs. A published event may sit in a queue for hours, be stored for years and be replayed by consumers that did not exist when it was written. You cannot run two versions of an event side by side as easily as /v1/ and /v2/, so the rules of compatibility apply to every single change.

Schema evolution is changing the schema of messages or stored data over time in a way that keeps producers and consumers on different versions working, by following compatibility rules such as adding only optional fields with defaults. A schema registry is a service that stores every version of the schemas used for messages on a broker and checks each new version against the earlier ones, refusing it if it breaks the compatibility mode chosen for that topic. The modes most registries offer are backward (new consumers can read old events), forward (old consumers can read new events) and full (both). This check does what such a registry does for four proposed changes to ReadingReceived:

# Each schema: field -> (type, has a default?). A field with a default may be
# missing in the data; a field without one must be present.
V1 = {"ean": ("string", False), "intervalStart": ("string", False),
      "kwh": ("number", False)}

proposals = {
    "add optional quality": {**V1, "quality": ("string", True)},
    "add required meterType": {**V1, "meterType": ("string", False)},
    "remove kwh": {k: v for k, v in V1.items() if k != "kwh"},
    "kwh as string": {**V1, "kwh": ("string", False)},
}


def can_read(reader, writer):
    """Can a consumer using `reader` read data produced with `writer`?"""
    for field, (kind, has_default) in reader.items():
        if field not in writer:
            if not has_default:
                return False
        elif writer[field][0] != kind:
            return False
    return True


print(f"{'change':24} {'backward':9} forward")
for name, new in proposals.items():
    backward = can_read(new, V1)    # new consumers read old events
    forward = can_read(V1, new)     # old consumers read new events
    print(f"{name:24} {'yes' if backward else 'NO':9} {'yes' if forward else 'NO'}")
change                   backward  forward
add optional quality     yes       yes
add required meterType   NO        yes
remove kwh               yes       NO
kwh as string            NO        NO

Only the optional field with a default is safe in both directions. A new required field breaks new consumers reading old events, removing kwh breaks every old consumer, and changing its type breaks everyone. Registries are common with Kafka, usually with schemas in Avro, Protocol Buffers or JSON Schema.

AsyncAPI is a specification format, similar to OpenAPI, for describing message-driven APIs: the channels a service publishes to or receives from, the operations, and the schema of every message, from which documentation, code and tests can be generated. As of October 2026 the latest version is 3.1.0 (January 2026). meter-api's description of its readings event:

asyncapi: 3.1.0
info:
  title: Kestrel meter-api events
  version: 1.1.0
  description: Events that meter-api publishes about smart-meter readings.
channels:
  readings:
    address: kestrel.readings
    messages:
      readingReceived:
        $ref: "#/components/messages/ReadingReceived"
operations:
  publishReadingReceived:
    action: send
    channel:
      $ref: "#/channels/readings"
    messages:
      - $ref: "#/channels/readings/messages/readingReceived"
components:
  messages:
    ReadingReceived:
      name: ReadingReceived
      title: A smart-meter reading was received
      contentType: application/json
      payload:
        type: object
        required: [ean, intervalStart, kwh]
        properties:
          ean:
            type: string
            description: EAN code of the meter.
          intervalStart:
            type: string
            format: date-time
          kwh:
            type: number
            minimum: 0
          quality:
            type: string
            enum: [measured, estimated]
            description: Added in 1.1.0. Absent in older events; treat as measured.

action: send means that meter-api sends on this channel; a consumer's own document would say receive. The description of quality tells consumers what the schema alone cannot: how to read events from before the field existed. The envelope around such events can follow CloudEvents, as on Event-Driven Architecture.