Pact contract testing for a checkout service and a payments API (pact-js 17.1.4)
Pact contract testing lets a checkout service record the payments application programming interface (API) responses it depends on and makes the payments API verify them before release. With pact-js 17.1.4, match money amounts with the integer matcher: in our run on 2026-09-22, the type matcher accepted a provider response of 100.5 where the contract expected 10050 minor units.
Tomas Reyes, Test automation architect, financial platformsPublished Updated
Versions and setup, checked on 2026-09-22
The current pact-js release is v17.1.4, published on 7 September 2026. Version 17.0.0 (25 June 2026) dropped Node.js 20, and the package now declares "node": ">=22". The Pact Broker command line tool ships on npm as @pact-foundation/pact-cli 18.1.2, published on 16 September 2026; it installs a pact binary with a broker subcommand.
Every block below ran on 2026-09-22 with these versions:
node v22.23.2
@pact-foundation/pact 17.1.4
@pact-foundation/pact-core 20.2.0 (resolved by npm for pact 17.1.4)
@pact-foundation/pact-cli 18.1.2
pact specification 4.0
test runner node:testThe example has two services. checkout-service is the consumer: before it captures a payment, it reads the payment from payments-api and checks that the payment is authorised and how much is capturable. payments-api is the provider. Amounts travel as integers in minor units, so EUR 100.50 is 10050.
The consumer test and the pact it writes
The consumer test starts a Pact mock server, declares the response the checkout service depends on, and runs the real client code against the mock. The matchers decide what the provider must honour later.
// test/payments.consumer.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { PactV4, MatchersV3 } from '@pact-foundation/pact';
const { integer, regex, string, iso8601DateTime } = MatchersV3;
// The checkout service's client for the payments API.
async function getPayment(baseUrl, id) {
const res = await fetch(`${baseUrl}/v1/payments/${id}`, {
headers: { Accept: 'application/json' },
});
if (!res.ok) throw new Error(`payments API returned ${res.status}`);
return res.json();
}
const pact = new PactV4({
consumer: 'checkout-service',
provider: 'payments-api',
dir: path.resolve('pacts'),
});
test('reads an authorised payment with its amount in minor units', async () => {
await pact
.addInteraction()
.given('payment pay_001 is authorised')
.uponReceiving('a request for payment pay_001')
.withRequest('GET', '/v1/payments/pay_001', (b) =>
b.headers({ Accept: 'application/json' }))
.willRespondWith(200, (b) =>
b.headers({ 'Content-Type': 'application/json' }).jsonBody({
id: 'pay_001',
status: 'authorised',
amount: integer(10050),
amount_capturable: integer(10050),
currency: regex(/^[A-Z]{3}$/, 'EUR'),
authorised_at: iso8601DateTime('2026-09-01T10:15:00Z'),
merchant_reference: string('order-7781'),
}))
.executeTest(async (mockServer) => {
const payment = await getPayment(mockServer.url, 'pay_001');
assert.equal(payment.status, 'authorised');
assert.ok(Number.isInteger(payment.amount));
});
});node --test test/payments.consumer.test.mjs passes and writes pacts/checkout-service-payments-api.json. The excerpt below shows the provider state and the matching rules for the money fields. status and id carry no rule, so the provider must return exactly "authorised" and "pay_001".
{
"providerStates": [{ "name": "payment pay_001 is authorised" }],
"response": {
"body": {
"content": {
"amount": 10050,
"amount_capturable": 10050,
"currency": "EUR",
"id": "pay_001",
"status": "authorised"
}
},
"matchingRules": {
"body": {
"$.amount": { "combine": "AND", "matchers": [{ "match": "integer" }] },
"$.amount_capturable": { "combine": "AND", "matchers": [{ "match": "integer" }] },
"$.currency": { "combine": "AND", "matchers": [{ "match": "regex", "regex": "^[A-Z]{3}$" }] }
}
},
"status": 200
},
"metadata": { "pact-js": { "version": "17.1.4" }, "pactSpecification": { "version": "4.0" } }
}Provider verification with a provider state
The Pact provider states page (last updated 5 July 2024) describes provider states as "the necessary preconditions for a particular test scenario to run" and says a provider state "is all about the state of the provider". The state handler below writes pay_001 into the provider's data store before the verifier replays the request. In our suites for real payments APIs, the handler inserts the authorised payment through the repository layer, so verification exercises the real serializer.
// test/payments.provider.test.mjs
import { test, after } from 'node:test';
import http from 'node:http';
import path from 'node:path';
import { Verifier } from '@pact-foundation/pact';
// Minimal payments API: an in-memory store stands in for the payments database.
const payments = new Map();
const server = http.createServer((req, res) => {
const match = req.url.match(/^\/v1\/payments\/([\w-]+)$/);
const payment = match && payments.get(match[1]);
if (req.method !== 'GET' || !payment) {
res.writeHead(404).end();
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payment));
});
await new Promise((resolve) => server.listen(8081, resolve));
after(() => server.close());
test('payments-api honours the checkout-service pact', () =>
new Verifier({
provider: 'payments-api',
providerBaseUrl: 'http://localhost:8081',
pactUrls: [path.resolve('pacts/checkout-service-payments-api.json')],
stateHandlers: {
'payment pay_001 is authorised': async () => {
payments.clear();
payments.set('pay_001', {
id: 'pay_001',
status: 'authorised',
amount: 10050, // EUR 100.50 in minor units (cents)
amount_capturable: 10050,
currency: 'EUR',
authorised_at: '2026-09-01T10:15:00Z',
merchant_reference: 'order-7781',
});
},
},
}).verifyProvider());The verifier output from that run:
Verifying a pact between checkout-service and payments-api
a request for payment pay_001 (0s loading, 228ms verification)
Given payment pay_001 is authorised
returns a response which
has status code 200 (OK)
includes headers
"Content-Type" with value "application/json" (OK)
has a matching body (OK)We then changed the state handler to store amount: 100.5, the shape a provider produces when a refactor switches the field to major units. Verification failed on the money field:
has a matching body (FAILED)
Failures:
1) Verifying a pact between checkout-service and payments-api Given payment pay_001 is authorised - a request for payment pay_001
1.1) has a matching body
$.amount -> Expected 100.5 (Decimal) to be an integerThe pitfall: like() on a minor-unit amount
The Pact JS matching table (last updated 22 July 2026) says like "requires values to have the same type as the template" and integer requires a value that "must be a number and have no decimal places". We regenerated the pact with amount: MatchersV3.like(10050), which writes { "match": "type" } for $.amount, and ran the same broken provider. On pact-core 20.2.0, the type matcher treated 10050 and 100.5 as the same type, and verification passed:
has a matching body (OK)
# pass 1
# fail 0The same like(10050) rule did reject a string, with Expected '100.50' (String) to be the same type as 10050 (Integer). So like() catches the string regression and misses the decimal one. A checkout service that reads 100.5 as minor units treats a EUR 100.50 order as EUR 1.005 and requests a capture for the wrong amount. Our rule is integer() on every amount field, including refunds, fees, and amount_capturable.
The integer matcher still accepts any integer. It cannot tell 10050 meant as EUR 100.50 from 10050 meant as JPY 10,050. The ISO 4217 list one (published 17 September 2026) gives EUR 2 minor units, JPY 0, and BHD 3. We add one provider state per exponent, for example payment pay_002 is authorised in JPY, and leave the amount without a matcher in those interactions, so the provider must return the exact value. Exponent handling across the ledger and the settlement file belongs to end-to-end tests; the contract testing vs end-to-end testing comparison sets that boundary.
Publishing, verifying from the broker, and can-i-deploy
In CI, the provider fetches pacts from the broker instead of a file. These Verifier options exist in pact-core 20.2.0; the consumer version selectors verify the consumer's main branch and every version recorded as deployed:
new Verifier({
provider: 'payments-api',
providerBaseUrl: 'http://localhost:8081',
pactBrokerUrl: process.env.PACT_BROKER_BASE_URL,
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
consumerVersionSelectors: [{ mainBranch: true }, { deployedOrReleased: true }],
enablePending: true,
publishVerificationResult: process.env.CI === 'true',
providerVersion: process.env.GIT_SHA,
providerVersionBranch: process.env.GIT_BRANCH,
stateHandlers, // the same handlers as above
}).verifyProvider();The broker commands, with flags taken from pact broker <command> --help in pact-cli 18.1.2. The base URL and token come from PACT_BROKER_BASE_URL and PACT_BROKER_TOKEN.
# checkout-service pipeline, after the consumer tests pass
pact broker publish ./pacts \
--consumer-app-version "$GIT_SHA" \
--branch "$GIT_BRANCH"
# before deploying checkout-service to production
pact broker can-i-deploy \
--pacticipant checkout-service --version "$GIT_SHA" \
--to-environment production \
--retry-while-unknown 12 --retry-interval 10
# after the deployment succeeds
pact broker record-deployment \
--pacticipant checkout-service --version "$GIT_SHA" \
--environment productionThe Can I Deploy page (last updated 5 October 2022) states "exit code 0 means yes!" and "exit code 1 means no". The retry flags make the checkout pipeline wait up to 120 seconds for a provider verification that a broker webhook triggered. Without record-deployment, the deployedOrReleased selector finds no checkout version, so the payments API never verifies the version that runs in production. The --dry-run flag always exits 0; the CLI help reserves it for pipeline setup and deliberate breaking deployments, and we keep it out of committed pipeline files.
What a Pact suite leaves to other tests
A pact covers the fields and states the checkout service reads. Two payments API behaviours sit outside it: a retried capture that posts twice, which the payment idempotency testing guide covers with request sequences and a side-effect count, and webhook delivery, which the webhook reliability testing guide covers for duplicates, retries, and ordering. For platforms whose integrators cannot be enumerated, such as acquirers with public APIs, consumer-driven pacts do not scale, and our API and webhook testing service builds the contract suite from the OpenAPI document instead.
Sources
- pact-foundation/pact-js, release v17.1.4 (GitHub),
- Pact JS documentation, Matching (V3 matchers table), last updated 22 July 2026,
- Pact documentation, Provider states, last updated 5 July 2024,
- Pact documentation, Can I Deploy, last updated 5 October 2022,
- @pact-foundation/pact-cli 18.1.2 (npm registry),
- SIX, ISO 4217 currency code list one (XML), published 17 September 2026,
Related
Scope your testing
Send FintechAssuranceLabs your product, segment, money flows, and the standards that need evidence, and get a proposed test scope with its deliverables.
Scope your testing