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. Nothing you own changed, and nothing you own noticed.
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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A field-level contract | Any upstream, and you can name the fields you read | You maintain the list, and a field added to your code without the contract goes unchecked | The provider publishes a schema you can validate against directly |
| Consumer-driven contracts | The provider is another team who will run your contract in their build | A broker to operate, and a provider willing to take part | The upstream is a vendor who will never run your tests |
| Recorded fixtures alone | Fast, offline unit tests of your own logic | The recording ages, and nothing tells you when the live API stopped matching it | You need to know that the upstream still behaves this way |
| Schema validation | The provider publishes an OpenAPI or JSON Schema document you trust | Their document can disagree with their API, so you test the document rather than the service | No 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. 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, and nothing reports it, 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 and 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. 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, 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.
Related how-tos
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.