April 15, 2026 10 min read

API Contract Testing for Payment Microservices — Why Integration Tests Weren't Enough

We had 400+ integration tests across our payment services. They passed in CI. They failed in production. Here's how consumer-driven contract testing with Pact changed the way we ship payment code.

The Problem With Integration Tests at Scale

About two years ago, our payment platform had grown to around a dozen microservices: a checkout orchestrator, a card tokenization service, a settlement engine, a refund processor, and several provider adapters for Stripe, Adyen, and a local acquirer. We had integration tests for all of them. Hundreds of them. And they were lying to us.

The issue wasn't that the tests were wrong. Each one correctly verified behavior against a running instance of the downstream service. The issue was timing. Our checkout service would test against version 2.3 of the tokenization service in CI, but by the time we deployed on Thursday, tokenization had shipped 2.4 with a renamed field in the response body. The integration test suite was green. The 3 AM PagerDuty alert was very much red.

This happened three times in two months. Each time, a provider-side change slipped through because the consumer's integration tests were pinned to a stale version. We needed something that could tell us, before deployment, whether the services we depend on still speak the same language we expect.

Warning

Integration tests verify behavior at a point in time. They don't guarantee that the contract between two services will hold when either side deploys independently. In payment systems, that gap can mean failed charges and lost revenue.

Consumer-Driven Contracts: The Core Idea

Consumer-driven contract testing flips the usual model. Instead of the provider defining the API and consumers hoping it doesn't change, each consumer publishes a contract describing exactly what it needs from the provider. The provider then verifies that it still satisfies every consumer's expectations. If it doesn't, the build breaks — on the provider side, before it ships.

The flow looks like this:

Consumer
writes test
Pact File
generated
Pact Broker
stores contract
Provider
verifies contract
Can I Deploy?
yes / no

The consumer test doesn't hit the real provider. It runs against a mock that Pact spins up, and the interactions get recorded into a JSON contract file (the "pact"). That file gets published to a Pact Broker. On the provider side, a verification test pulls down every consumer's pact and replays the requests against the real provider, checking that the responses match what the consumer expects.

Setting It Up in Go

We run most of our payment services in Go, so here's roughly what the consumer side looks like. Say our checkout service calls the tokenization service to exchange a card number for a token:

// checkout_consumer_test.go
func TestCheckoutTokenizationPact(t *testing.T) {
    mockProvider, err := consumer.NewV4Pact(consumer.MockHTTPProviderConfig{
        Consumer: "CheckoutService",
        Provider: "TokenizationService",
    })
    require.NoError(t, err)

    err = mockProvider.
        AddInteraction().
        Given("a valid card number").
        UponReceiving("a request to tokenize a card").
        WithCompleteRequest(consumer.Request{
            Method: "POST",
            Path:   "/v1/tokenize",
            Headers: map[string][]string{
                "Content-Type": {"application/json"},
            },
            Body: map[string]interface{}{
                "card_number": "4111111111111111",
                "exp_month":   12,
                "exp_year":    2027,
            },
        }).
        WithCompleteResponse(consumer.Response{
            Status: 200,
            Headers: map[string][]string{
                "Content-Type": {"application/json"},
            },
            Body: matchers.Map{
                "token":      matchers.Like("tok_abc123"),
                "card_brand": matchers.Like("visa"),
                "last_four":  matchers.Like("1111"),
            },
        }).
        ExecuteTest(t, func(config consumer.MockServerConfig) error {
            // Call your real client code against the mock
            client := tokenization.NewClient(config.URL)
            resp, err := client.Tokenize(context.Background(), &tokenization.Request{
                CardNumber: "4111111111111111",
                ExpMonth:   12,
                ExpYear:    2027,
            })
            require.NoError(t, err)
            assert.Equal(t, "visa", resp.CardBrand)
            return nil
        })
    require.NoError(t, err)
}

When this test runs, Pact generates a JSON contract file. The key detail: we're using matchers.Like() instead of exact values. This means the contract says "I need a token field that's a string" rather than "I need the exact string tok_abc123." That flexibility is critical — you want to verify structure, not specific test data.

Provider-Side Verification

On the tokenization service side, verification pulls the pact from the broker and replays it:

// tokenization_provider_test.go
func TestPactProvider(t *testing.T) {
    verifier := provider.NewVerifier()

    err := verifier.VerifyProvider(t, provider.VerifyRequest{
        ProviderBaseURL:            "http://localhost:8080",
        Provider:                   "TokenizationService",
        BrokerURL:                  os.Getenv("PACT_BROKER_URL"),
        BrokerToken:                os.Getenv("PACT_BROKER_TOKEN"),
        PublishVerificationResults: true,
        ProviderVersion:            os.Getenv("GIT_COMMIT"),
        StateHandlers: map[string]models.StateHandler{
            "a valid card number": func(setup bool, s models.ProviderState) (models.ProviderStateResponse, error) {
                // Seed test data or configure mocks for this state
                return nil, nil
            },
        },
    })
    require.NoError(t, err)
}

The StateHandlers map is where you set up the preconditions for each interaction. For payment services, this usually means seeding a test merchant account or configuring a mock payment processor to return specific responses.

The Pact Broker and "Can I Deploy?"

The Pact Broker is the central piece that makes this work in CI/CD. It stores every version of every contract, tracks which provider versions have verified which consumer contracts, and — most importantly — answers the question: "Can I deploy this version of my service right now?"

We added this to our deployment pipeline:

# In CI, after tests pass but before deploy
pact-broker can-i-deploy \
  --pacticipant CheckoutService \
  --version $(git rev-parse HEAD) \
  --to-environment production

If the tokenization service hasn't verified the latest checkout contract, the deploy stops. No guessing. No "it worked in staging." The broker knows exactly which combinations of service versions are compatible.

Tip

Use --to-environment rather than --to with a tag. Environment-based tracking in the Pact Broker gives you a clearer picture of what's actually running where, especially when you have multiple staging environments.

Integration Tests vs. Contract Tests

These aren't competing approaches — they test different things. Here's how they break down in practice:

Aspect Integration Tests Contract Tests
What it verifies End-to-end behavior across services API shape and expectations between pairs
Speed Slow — needs running services, databases, networks Fast — consumer tests run against a local mock
Flakiness High — network timeouts, test data drift, environment issues Low — deterministic, no real network calls
Catches breaking API changes Only if both sides are tested together at the same version Yes — provider must verify against all consumer contracts
Deploy confidence Point-in-time snapshot Continuous — broker tracks compatibility matrix
Setup cost High — docker-compose, test environments, seed data Moderate — Pact libraries + broker infrastructure
Best for Validating business flows end-to-end Preventing interface breakage between services

We still run integration tests, but far fewer of them. The contract tests handle the "does the API still look right?" question, and integration tests focus on "does the full payment flow actually work?" — things like verifying that a charge, capture, and settlement produce the correct ledger entries.

Versioning Contracts for Payment Provider Changes

Payment providers change their APIs more often than you'd think. Stripe ships breaking changes behind API version headers. Adyen deprecates fields across major versions. We needed our contract tests to handle this gracefully.

Our approach: each provider adapter service maintains contracts versioned by the upstream API version it targets. When Stripe ships a new API version, we create a new consumer contract that reflects the updated response shape, run it against our adapter, and only switch the API version header in production once the contract is green.

// In the consumer test for our Stripe adapter
mockProvider.
    AddInteraction().
    Given("Stripe API version 2025-12-01").
    UponReceiving("a payment intent creation").
    WithCompleteRequest(consumer.Request{
        Method: "POST",
        Path:   "/v1/payment_intents",
        Headers: map[string][]string{
            "Stripe-Version": {"2025-12-01"},
        },
        // ...
    })

This gives us a clear migration path. The old contract stays in the broker until we've fully migrated, and the can-i-deploy check ensures we never deploy a consumer that expects the new response shape against a provider still returning the old one.

Key Takeaway

Contract testing doesn't replace integration testing — it fills the gap between "these services work together right now" and "these services will still work together after independent deployments." For payment systems where a broken interface means failed transactions, that gap is too expensive to leave uncovered.

Lessons From Production

After running contract tests in our payment platform for over a year, a few things stand out:

  1. Start with your most painful integration point. For us, that was the checkout-to-tokenization boundary. Don't try to add contracts everywhere at once.
  2. Invest in good StateHandlers on the provider side. Flaky state setup is the number one reason teams abandon contract testing. Treat them like first-class test infrastructure.
  3. Run provider verification in CI on every commit, not just on release branches. You want to catch breakage as early as possible.
  4. Use the Pact Broker's network diagram. When you have 12 services, being able to visualize which consumers depend on which providers — and whether they're all compatible — is invaluable during incident response.
  5. Don't over-specify contracts. Test the fields your consumer actually uses, not every field the provider returns. Overly strict contracts create false failures and erode trust in the system.

Since adopting contract testing, we've had zero production incidents caused by API incompatibility between our services. The integration test suite that used to take 25 minutes now takes 8, because we removed the redundant "does the API shape match?" tests. And the deploy pipeline actually tells us when something is going to break, instead of letting us find out from customers.

References

Disclaimer: This article reflects the author's personal experience and opinions. Product names, logos, and brands are property of their respective owners. Pricing and features mentioned are subject to change — always verify with official documentation.