How-to › Test and mock integrations

How to contract test an API you do not own#

Write down the fields your integration reads, check a recorded response against them, and run the same check against the live API on a schedule.

Audience
API consumer
Level
intermediate
Topic
Run contract and spec tests
Languages
TypeScript and JavaScript
Verified

A payment provider changes an amount from a number to a decimal string. Your tests pass, because they run against fixtures you recorded in March. Production breaks at the first webhook, and the first sign is a support ticket rather than a build failure. The change was entirely upstream, which is why nothing you own noticed it.

What you get

You will end up with a written contract for the fields your code reads, checked in your suite and runnable against the live API. This is for you if your integration tests only ever see fixtures.

Short answer

List the paths your code reads and the type each one must have, and check that list against a recorded response in your test suite. Run the same check against the live API in a scheduled job rather than in the build. Upstream changes to fields you never read pass, and a change to one you depend on names the field that moved.

You will need

Node 22 or later, and one recorded response from the API. A sandbox account is better than production, and the recording matters more than where it came from: it is the thing your contract is first written against.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A field-level contractAny upstream, and you can name the fields you readYou maintain the list, and a field added to your code without the contract goes uncheckedThe provider publishes a schema you can validate against directly
Consumer-driven contractsThe provider is another team who will run your contract in their buildA broker to operate, and a provider willing to take partThe upstream is a vendor who will never run your tests
Recorded fixtures aloneFast, offline unit tests of your own logicThe recording ages without telling you when the live API stopped matching itYou need to know that the upstream still behaves this way
Schema validationThe provider publishes an OpenAPI or JSON Schema document you trustTheir document can disagree with their API, so you test the document rather than the serviceNo published schema, or one that is out of date

The distinction that matters is between checking your assumptions and checking their whole API. Pact needs the provider’s cooperation, which a vendor will not give you. Schema validation needs a document that matches reality. A field-level contract needs neither, and its weakness is that it only covers what you thought to write down.

Write down what you read

The contract is a list of paths, not a copy of the response.

/** What this integration reads from the upstream invoice endpoint, and nothing else. */
export const INVOICE_CONTRACT = {
  id: 'string',
  'amount.value': 'number',
  'amount.currency': 'string',
  'customer.email': 'string',
  lines: 'array',
}

Keeping the list this short is the point of writing one at all. The recorded response has a dozen more fields, and every one you add to the contract is a field that can fail your build without breaking your code. A vendor adding auto_advance to every invoice should cost you nothing at all, and with a short contract it does.

Put the contract next to the code that reads those paths, and treat adding a read as a change to both. That is the discipline the approach depends on, and it is the one that decays: a new feature reads customer.name, nobody adds it, and the contract silently stops covering the integration.

Run it in two places

The same function runs against a fixture and against the live API, and the two runs answer different questions. Against the fixture it asks whether your parsing still matches the recording, and it runs in your build in milliseconds. Against the live API it asks whether the recording is still true, and it needs credentials, network and a tolerance for the upstream being down.

Keep the second one out of the build. A scheduled job that runs nightly and opens an issue tells you about drift within a day. A build that calls a vendor fails whenever that vendor has a bad afternoon.

Where the vendor publishes a sandbox, run the scheduled check there rather than against production. This usually takes one of two shapes. Stripe’s test mode is a parallel environment returning the same response shapes.1 GitHub’s REST API is a documented service you can call read-only with a low-privilege token. Either gives you a live answer without a live side effect.

Check it worked

The check has to pass on the recording and fail on a drifted one, naming what moved.

node demo.mjs
fixture.json: contract holds
drifted.json: 2 failures
  amount.value: expected number, found string
  customer.email: missing

Those two lines are the failure from the opening paragraph, caught by name. The drifted fixture also adds a field that the check ignores, which is the behavior a contract for one consumer should have.

node --test contract.test.mjs
1..3
# tests 3
# suites 0
# pass 3
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 113.137809

When it goes wrong

The contract passes, but production still breaks. Types are the cheap half of a contract, and meaning is the expensive half. An amount that stays a number while changing from cents to units passes every check on this page, and a currency code that starts arriving lowercase does too.2 Where a value has semantics, assert on the semantics: a range, a set of allowed values, or a known identifier.

The second failure is a fixture that nobody refreshes. A recording made once and never renewed drifts from the live API, and the scheduled run is what catches that. Re-record against the sandbox on the same schedule, and treat a difference between the new recording and the old one as the thing to read rather than as noise to overwrite.

When not to do this

Do not write a contract covering every field in the response. That is schema validation with extra steps, and it fails your build every time the vendor ships anything.

Do not run the live check on every pull request. A vendor’s rate limit, sandbox outage, or slow afternoon becomes your build failure,3 and the signal you wanted arrives just as noisily a few hours later from a scheduled run.

Do not put credentials for the live check in the same place as the build’s. The scheduled job needs a read-only sandbox credential, and giving the build a token that can reach production is a much larger change than adding a test.

Do not treat a passing contract as permission to skip error handling. It tells you the shape was right on the last run, and says nothing about the upstream returning a 500 at the moment you call it.

Last verified

Verified 2026-09-06 against Node 22.22.2. Both output blocks are what the preceding command printed, against two checked-in fixtures rather than a live vendor API.

Footnotes

  1. Test mode has moved house. The link on this page asks for Stripe’s test mode and arrives at a page about sandboxes, which describes them as the Stripe testing environment. They simulate creating real objects, the page says, without affecting actual transactions or moving real money. A general sandbox isolates settings and data from live mode, and the page suggests one each for local development, CI and staging. The response shapes are the same on either side, which is the property this page relies on, and the name that changed is the one no integration reads. ↩︎ Back to text

  2. Stripe’s currencies page is a catalog of exactly this failure, held off by paperwork. Every amount is an integer in the currency’s minor unit: 1000 charges 10 USD, and 10 charges 10 JPY, because the yen is a zero-decimal currency. Then the special cases. The Icelandic and Ugandan currencies both moved to zero decimals. Backward compatibility requires them to be sent as two-decimal values whose last two digits are always 00, so 500 charges 5 ISK. The forint and the New Taiwan dollar are two-decimal for charges and zero-decimal for payouts. A number that stays a number while the unit changes under it is not hypothetical. It is the documented state of at least four currencies. ↩︎ Back to text

  3. GitHub publishes the arithmetic. Its rate limit page allows 60 requests an hour without a token, and 5,000 an hour against a personal limit with one. A GitHub App owned by an organization on GitHub’s enterprise plan is allowed 15,000 an hour. A nightly check spends one of those. A check on every pull request spends as many as the team opens pull requests, plus the retries, which is how a build comes to be rate limited by its own diligence. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.