Contract Testing Between Mobile Apps and Banking APIs with Pact
bankingNovember 12, 2026

Contract Testing Between Mobile Apps and Banking APIs with Pact

Catching breaking API changes before release when mobile, web and backend teams ship on different schedules

A banking backend team can deploy several times a week. The mobile team ships on an app-store cadence, with review windows and staged rollouts. And the customers who actually use the app update whenever they feel like it. The result is that at any moment, the production API is serving three, five or ten different versions of the mobile app, each with its own assumptions about the shape of every response. A field renamed on Tuesday, an enum value added on Wednesday or a date format "tidied up" on Thursday can break a version of the app that shipped four months ago and is still running on a quarter of your customers' phones. 


End-to-end tests don't catch this reliably. They run against the current app build, not against every version still in the wild. They're slow, they're flaky in exactly the environments where you'd want them most, and they need every system deployed together at once, which is the opposite of how these teams actually work. Contract testing addresses the problem directly. Each consumer records exactly what it relies on, each provider proves it still satisfies those expectations, and a broker answers one question before every release: is it safe to deploy this version to this environment, given everything else already there? 


This is how we set it up with Pact for a React Native banking app talking to Spring Boot APIs, including the mobile-specific details most tutorials skip. 


What a contract actually captures 


A Pact contract is generated by the consumer's own tests. The mobile app's API client runs against a Pact mock server and declares, interaction by interaction, the requests it sends and the parts of the response it depends on. Nothing more. That last point is where the value lies. The contract isn't the provider's full OpenAPI schema. It's the subset of that schema this consumer actually uses, which is exactly the subset a provider change can't touch without breaking someone. 

Here's a balance lookup from the React Native app, using Pact JS: 

 1 import { PactV3, MatchersV3 } from '@pact-foundation/pact'; 
 2 import { AccountsClient } from '../src/api/AccountsClient'; 
 3  
 4 const { like, regex, eachLike } = MatchersV3; 
 5  
 6 const provider = new PactV3({ 
 7   consumer: 'RetailMobileApp', 
 8   provider: 'AccountsAPI', 
 9 }); 
10  
11 describe('Accounts API contract', () => { 
12   it('returns the balance for an existing account', () => { 
13     provider 
14       .given('account ACC-123 exists with a positive balance') 
15       .uponReceiving('a request for the balance of ACC-123') 
16       .withRequest({ 
17         method: 'GET', 
18         path: '/v1/accounts/ACC-123/balance', 
19         headers: { Authorization: like('Bearer eyJhbGciOi...') }, 
20       }) 
21       .willRespondWith({ 
22         status: 200, 
23         headers: { 'Content-Type': 'application/json' }, 
24         body: { 
25           accountId: like('ACC-123'), 
26           available: { 
27             amount: regex(/^-?\d+\.\d{2}$/, '1250.40'), 
28             currency: regex(/^[A-Z]{3}$/, 'EUR'), 
29           }, 
30           asOf: regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/, 
31                       '2026-11-09T08:15:00Z'), 
32         }, 
33       }); 
34  
35     return provider.executeTest(async (mockServer) => { 
36       const client = new AccountsClient(mockServer.url); 
37       const balance = await client.getBalance('ACC-123'); 
38       expect(balance.available.amount).toBe('1250.40'); 
39     }); 
40   }); 
41 }); 

Three choices in that contract are deliberate, and they matter in banking specifically. 


Amounts are matched as decimal strings with exactly two places, not as numbers. If the backend team switches amount from "1250.40" to 1250.4 because a serialiser upgrade changed the default, a JavaScript client doing arithmetic on it is now handling floating-point money. The regex makes that change a contract failure instead of a rounding defect discovered by a customer. 


The timestamp format is pinned. A move from UTC Z to a local offset looks harmless in a pull request. It's anything but harmless to an app that groups transactions by day. 


Matchers describe shape, not values. like('ACC-123') says "a string goes here", so the provider can return any account ID its test data produces. Contracts that pin exact values make provider verification brittle, and teams soon stop trusting them. 


The provider proves it, on every build 


On the Spring Boot side, Pact JVM pulls every relevant contract from the broker and replays each interaction against the real application running in a test context: 


 1 @Provider("AccountsAPI") 
 2 @PactBroker 
 3 @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) 
 4 class AccountsApiContractVerificationTest { 
 5  
 6     @LocalServerPort int port; 
 7     @MockitoBean AccountRepository accounts; 
 8  
 9     @BeforeEach 
10     void setTarget(PactVerificationContext context) { 
11         context.setTarget(new HttpTestTarget("localhost", port)); 
12     } 
13  
14     @TestTemplate 
15     @ExtendWith(PactVerificationInvocationContextProvider.class) 
16     void verifyContracts(PactVerificationContext context) { 
17         context.verifyInteraction(); 
18     } 
19  
20     @State("account ACC-123 exists with a positive balance") 
21     void accountWithBalance() { 
22         when(accounts.findBalance("ACC-123")).thenReturn(Optional.of( 
23             new Balance("ACC-123", new BigDecimal("1250.40"), "EUR", 
24                         Instant.parse("2026-11-09T08:15:00Z")))); 
25     } 
26 } 

The @State methods are where contract testing meets test-data discipline. A provider state is a named precondition the consumer can rely on, and its implementation belongs to the provider team. Keep states business-meaningful ("account exists with a positive balance", "card is blocked", "payment requires step-up authentication") rather than technical, and keep them stubbed at the repository boundary. That way verification exercises your real controllers, serialisers and security filters without needing a database full of fixtures. 


Two broker settings change the provider team's daily experience. First, verify against the right consumer versions, not just the latest one: 

 1 pactbroker.url=https://pact-broker.internal.bank 
 2 pactbroker.consumerversionselectors.rawjson=[{"mainBranch":true},{"deployedOrReleased":true}] 
 3 pactbroker.enablePending=true 
 4 pact.verifier.publishResults=true 

deployedOrReleased is the selector that matters for mobile. It pulls the contract from every app version still recorded as live in production, not just whatever is on main today. Second, enablePending means a new expectation the consumer has published but the provider hasn't implemented yet won't fail the provider's build. The provider sees it as a pending pact. It becomes a hard gate only once it has passed verification for the first time. Without pending pacts, consumer teams can block provider pipelines just by publishing ideas, and the practice loses goodwill quickly. 


The mobile-specific part: releases, not deployments 


This is where mobile banking differs from a web frontend, and where we see contract testing either pay off or quietly fail. A web app deploys in place: when version 52 goes live, version 51 is gone. A mobile app is released: when 4.12 reaches the stores, 4.11, 4.10 and 4.8 are all still in use on real phones, sometimes for months. 

The Pact Broker models this distinction explicitly, and the pipeline has to use it correctly: 


# Backend: deployments replace the previous version 

pact-broker record-deployment --pacticipant AccountsAPI \ 

  --version "$GIT_SHA" --environment production 

 

# Mobile: releases accumulate alongside earlier ones 

pact-broker record-release --pacticipant RetailMobileApp \ 

  --version "4.12.0" --environment production 

 

# When an app version drops below the minimum supported version 

pact-broker record-support-ended --pacticipant RetailMobileApp \ 

  --version "4.8.0" --environment production 

Using record-deployment for the mobile app is the classic mistake. The broker would treat 4.12 as replacing 4.11, and from that moment the backend's compatibility checks would stop protecting every customer who hasn't updated. Using record-release, and calling record-support-ended when your forced-update policy retires a version, keeps the broker's view of production aligned with the phones actually calling your API. It also creates a useful link between engineering and product: the minimum supported app version becomes a decision with a visible effect on how freely the backend can evolve. 


can-i-deploy: the gate in every pipeline 


With contracts published, verified and environments recorded, every pipeline gets the same final step before release: 

 1 pact-broker can-i-deploy --pacticipant AccountsAPI \ 
 2   --version "$GIT_SHA" --to-environment production 

For the backend, this asks whether this build has been verified against every consumer version currently deployed or released in production. That includes all those older app versions. For the mobile app, the same command, run before submission to the stores, asks whether every provider version in production satisfies the new build's contracts. If the app depends on an endpoint that hasn't reached production yet, the answer is no, and you find out before the review queue rather than after a staged rollout. 


Close the loop with a broker webhook on the contract_requiring_verification_published event. It triggers the provider's verification job whenever a consumer publishes a changed contract, so feedback arrives in minutes and nobody waits for the next scheduled provider build. 


Where contracts break in banking APIs 

Over time, the failures contract testing catches in banking APIs fall into a few recurring categories. They make a useful review checklist for any API change: 


  • Enum additions. A new transaction type (INSTANT_SEPA_RETURN, say) is a backwards-compatible change on paper, until an older app's exhaustive switch hits a value it doesn't know and crashes the transaction list. Contracts help here, but the real fix is on the consumer: a tolerant reader that maps unknown values to a safe default. Make it a written rule for every consumer, and test it. 
  • Optional becoming absent. A field the backend considers optional, such as a merchant logo URL or a counterparty IBAN, is one the app renders unconditionally. If the contract declares it, removing it fails verification. That's exactly the conversation you want before release, not after. 
  • Error responses. Consumers depend on error shapes as much as on success shapes: the step-up authentication challenge, the insufficient-funds error code, the validation error structure the app maps to field messages. Write contracts for the 4xx responses your app handles explicitly. A changed error format is one of the most common sources of a "silent" mobile break. 


Money and time representation, covered above. These deserve matchers precise enough to fail on representation changes, not just on type changes. 


What contract testing is not 


Contract tests check that two systems agree on the interface. They don't check that the backend calculates the right balance, that a payment actually settles or that business rules are correct. Provider-side functional tests and a small, stable set of end-to-end journeys still own that. Contract testing earns its place by taking cross-version compatibility out of those slow suites, so the end-to-end layer can stay small and focused on genuine business flows. 


Pact also assumes both sides are yours, or at least both teams take part. For third-party APIs you don't control, such as a card processor or an open-banking aggregator, consumer-driven contracts are the wrong tool. There, a bi-directional approach that compares your consumer expectations against the provider's published OpenAPI specification, alongside the integration contract testing we've discussed for DORA third-party boundaries, is the better fit. 


Making it stick 

The technical setup is a few days' work. The practice is what lasts: contracts reviewed in pull requests like any other test, provider states named in business language that a tester and a product owner can both read, and can-i-deploy treated as a release gate that nobody bypasses on a Friday afternoon. When those habits are in place, mobile, web and backend teams can each ship on their own schedule, and the broker holds an accurate, continuously checked record of what is compatible with what. 


For a bank whose customers judge the whole institution by whether the app opens and the balance loads, that independence without risk is the point. From a testing perspective, it's also one of the clearest cases where quality comes from the delivery system's design, not from extra testing effort at the end.