BlogAPI design

An API version belongs with the code

Migrate an API integration with explicit version pins, consumer fixtures, staged rollout and a tested rollback. Includes an offline Carrier adapter example.

An API upgrade changes the code that reads its responses. It should be possible to test that change in staging and deploy it with the application, while production keeps using the earlier contract.

That is why we put the version in the request. The key identifies the team. The Parse-Version header declares what the application expects to receive. Keeping those choices separate lets two application releases use the same credentials while they ask for different supported contracts.

The useful unit of an upgrade is the application that consumes the response: its requests, field-reading code, tests, and deployment configuration. Changing a version setting is one part of that work.

Write down what the consumer expects

Start at the places your application uses the response. A form might display one field, an export might flatten several, and a background job might make a decision from a boolean. They can depend on different parts of the same lookup.

For a Carrier lookup, a small consumer inventory might look like this:

ConsumerAssumption to checkUseful test
Carrier labelcarrier is a string or nullUnknown carrier has an empty-state label
Burner indicatorburner is true, false, or nullFalse and unknown remain distinguishable
Issuing-state labelState detail exists at the expected pathRequested detail reaches the view
Request error displayAn HTTP error has a codeError bodies never become lookup results

The issuing state is context from the number's area code. It is not the subscriber's current location. A migration test should preserve that meaning as well as the field's type.

Read the release notes for every endpoint the application calls. Look for changed input rules, moved fields, null behavior, optional detail, and error handling. A TypeScript compile can catch a renamed property, but it cannot tell you that a default label now appears for every row because the request stopped asking for a field.

Pin the working contract first

Before selecting a new contract, establish which one the application uses now. On a successful lookup, the Parse-Version response header reports the effective version. A headerless application can inherit its team's default, so its age or package filename is not enough to determine that answer.

If the working application uses 1.0.0, make that dependency explicit in its HTTP client:

X-API-Key: YOUR_EXISTING_KEY
Parse-Version: 1.0.0

Keep this pin with the application release. Run the existing tests and check that the response header still says 1.0.0. This first change makes the existing behavior explicit before changing the behavior itself.

Use one exact supported version. latest, ^2.0.0, and a comma-separated list are not selectors. The request header takes precedence over the saved default for that request; it does not change the default, replace a key, or grant different permissions or allowances.

The dashboard's Default API version governs requests that omit the header. Leave it stable while any other application, script, or rollback release still relies on it. An owner or admin can review and change that fallback separately. An explicitly pinned application does not need a dashboard change to migrate.

Change the request and the reader together

Carrier makes the problem concrete. In API 1.0.0, issuing-place fields are top-level properties. In 2.0.0, they live inside deep and require ?deep=true.

Application releaseRequest contractDetail requestState field
Existing1.0.0No new parameterresult.state
Candidate2.0.0?deep=trueresult.deep?.state

The Carrier detail uses the same lookup unit, including a Free allowance unit. Requesting it does not introduce an extra lookup or a paid-plan gate. Omit the parameter when you only need core fields; on this operation, deep=false is not the way to request core.

These hand-written excerpts illustrate the field move. They are fixtures for consumer tests, not captured responses or claims about a real phone number:

{
  "valid": true,
  "carrier": "Example Carrier",
  "burner": false,
  "state": "CO"
}

The corresponding 2.0.0 excerpt with detail requested is:

{
  "valid": true,
  "carrier": "Example Carrier",
  "burner": false,
  "deep": { "state": "CO" }
}

An adapter at the edge of your application can turn either contract into the same application-owned view. The old release reads the top-level state; the new release reads the nested state. The rest of the application can keep displaying issuingState.

Be deliberate about an expression such as result.deep?.state ?? result.state. It accepts both paths, but it can also hide the fact that your new request is still receiving the old contract. During migration, a separate adapter for each version and a check of the response header make that mistake visible.

Missing and unknown detail also deserve different tests. In 2.0.0, an omitted deep object means the request did not ask for Carrier detail. A returned deep.state: null means the detail has no known state value. If the feature requires that field, a fixture with a known value should fail when the application forgets ?deep=true.

Run the consumer test without making a lookup

Download the offline contract rollout example and run it with Node.js:

node --test api-contract-rollout.mjs

The file has no dependencies to install, reads no API key, and makes no network requests. It contains both adapters, illustrative response excerpts, and tests for their application behavior. The central comparison is:

const oldView = adaptCarrier10(
  fixtureResponse('1.0.0', fixtures.legacy)
);
const newView = adaptCarrier20(
  fixtureResponse('2.0.0', fixtures.current)
);
assert.deepEqual(oldView, newView);

That equality says the two example consumers agree on the application's output. It does not claim that two live carrier lookups will always return identical facts.

The other cases check unknown values, unrequested detail, invalid input, HTTP errors, mismatched response versions, and unexpected field types. A false burner flag stays false. A null flag stays unknown. A field with an unexpected type causes the example adapter to fail instead of quietly replacing it with null.

The example's kind, issuingState, and stateDetail properties belong to its application view. They are not additional ParseAPI fields. Adapt that boundary to the decisions your own application makes, then add cases from your consumers. An application that exports CSV needs tests for its column values; one that displays a form needs tests for its labels and submission behavior.

Keep errors separate from unknown facts

A version-selection failure and an unavailable field need different responses from the application:

ResponseMeaningConsumer action
400 invalid_request for the version headerThe selector is invalid or unsupportedCorrect the request configuration
410 api_version_retiredThe selected contract has been retiredMigrate to a supported contract
200 with valid: falseCarrier could not parse a valid phone numberHandle the input result
200 with carrier: nullThere is no carrier answer to displayPreserve the unknown value

The 1.0.0 and 2.0.0 contracts are supported; the retirement row describes what a retired selection would return. Neither version error silently falls back to another shape. Retrying the same invalid selector does not fix it.

A successful HTTP response is not a promise that every fact is known. Conversely, a request failure should not become a synthetic carrier: null lookup in your application. Check HTTP status before parsing the success model. A version-selection error may not have an effective Parse-Version response header at all, so the downloadable adapter handles the error first.

Test transport failures separately in the application's HTTP layer. The offline fixture file does not simulate network timeouts, authentication, account allowances, or the full API. Those still need their own integration checks.

Deploy a pair, and keep a pair to roll back

Once the candidate passes consumer tests, exercise it in staging with the exact package versions, request configuration, and application build you intend to deploy. Check the response version as well as the body. A test of a hand-written fixture cannot prove that the real HTTP client sent its header or query parameter.

During a gradual rollout, the old build can send 1.0.0 while the new build sends 2.0.0. Both keep their existing credentials. Store the pin with each build's configuration; changing one shared setting underneath both builds defeats the separation.

Watch the behavior that the migration can change: error codes, unexpected field types, missing display values, and the decisions made by the consumer. Avoid treating every difference in a changing data value as a contract failure.

Rollback restores the previous application code together with its previous pin or SDK dependency. It depends on the earlier contract remaining supported. It also depends on any headerless rollback build still having the team default it expects.

Rolling back a request contract does not undo files already exported, messages already sent, or changes your application made to its own stored data. If the application changes its storage format during the upgrade, plan compatibility for that separately. The API pin only controls subsequent requests.

Package versions and API versions have different jobs

The official SDKs select the contract their response types describe. Starting with package version 1.0.0, they send Parse-Version: 2.0.0 automatically. A package version of 1.0.0 therefore does not mean API contract 1.0.0.

Pin the SDK dependency in the application's lockfile and deploy that lockfile with the code. Read the SDK release notes before an upgrade; a package update can change client behavior even when its supported API contract stays the same. The typed clients own their contract selection rather than exposing an arbitrary version switch that could disagree with their types.

Earlier packages retain their original behavior. Rolling back to a package that omits Parse-Version returns to the team's current default, not necessarily the response contract that package saw before the rollout. Preserve that default while such a rollback remains part of your plan.

A contract pin does not freeze the world

Versioning preserves the documented request and response contract. It does not freeze exchange rates, weather observations, carrier facts, or maintained reference data at the date you adopted the version.

Use fixed fixtures to test how your code handles known, unknown, and missing values. Use integration checks to confirm the selected contract and the behavior of the real request. Where your application records observations, retain the relevant observation date or period that the product provides rather than treating the API version as a data timestamp.

The versioning guide links the supported contracts and migration notes. Exact reference URLs such as https://api.parseapi.com/version/2.0.0/carrier/help select documentation for that contract; lookup requests still select their contract with Parse-Version. Keep the reference, the request, and the consumer test describing the same version.