Back to Blog
testingcontract testingCI/CDreliability

Consumer-Driven Contract Testing for Webhooks

Breaking changes in webhook payloads are silent failures — your consumers crash, you find out from an angry customer. Contract testing catches these breaks in CI before they ever reach production.

J
Jordan Okafor
Senior Backend Engineer
March 30, 2026
9 min read

Webhook schema changes are easy to break and hard to detect. You rename a field from customer_id to customerId, redeploy, and everything looks fine — your own tests pass, your dashboards are green. Three hours later, a customer files a support ticket because their integration stopped working. Somewhere, a consumer was relying on customer_id, and you had no way to know.

This is the core problem contract testing solves: it makes your consumers' expectations explicit and verifiable, so breaking changes fail in CI instead of silently failing in production.


What Contract Testing Is (and Isn't)

Contract testing sits between unit tests and integration tests in the testing pyramid.

Test typeWhat it verifiesWhere it runs
Unit testLogic within a single serviceCI, fast
Contract testSchema compatibility between producer and consumerCI, fast
Integration testFull end-to-end behavior with real servicesCI/staging, slow
E2E testUser-visible behavior across the full stackStaging/prod, slow

The key insight: contract tests run without both services being alive at the same time. The consumer generates a "pact" — a JSON file describing what fields it reads and what shape they take. The producer then verifies that its real output satisfies the pact. No live network calls. No shared environments. Just fast, deterministic verification in CI.

This is especially valuable for webhooks because the producer (your API) and the consumers (your customers' systems) are owned by different teams, often different companies. You can't just run make test against their code.


The Consumer Side: Defining What You Use

Consumer-driven contract testing starts with the consumer declaring what it actually needs from a webhook payload — not what the producer documents, but what it reads.

Here's a consumer handler that processes an order.completed event:

go
// Consumer: order fulfillment service
func handleOrderCompleted(payload []byte) error {
    var event struct {
        EventType string `json:"event_type"`
        Data      struct {
            OrderID    string `json:"order_id"`
            CustomerID string `json:"customer_id"`
            TotalCents int64  `json:"total_cents"`
            LineItems  []struct {
                SKU      string `json:"sku"`
                Quantity int    `json:"quantity"`
            } `json:"line_items"`
        } `json:"data"`
    }

    if err := json.Unmarshal(payload, &event); err != nil {
        return fmt.Errorf("parse error: %w", err)
    }

    return fulfillOrder(event.Data.OrderID, event.Data.LineItems)
}

The contract captures exactly what this handler uses: event_type, data.order_id, data.customer_id, data.total_cents, and data.line_items[].sku / data.line_items[].quantity. Notice that this consumer doesn't use every field the producer sends — it only cares about a subset.

The pact file for this contract looks like:

json
{
  "consumer": { "name": "fulfillment-service" },
  "provider": { "name": "orders-api" },
  "interactions": [
    {
      "description": "order.completed event",
      "request": {
        "event_type": "order.completed"
      },
      "response": {
        "event_type": "order.completed",
        "data": {
          "order_id": "ord_abc123",
          "customer_id": "cust_xyz789",
          "total_cents": 4999,
          "line_items": [
            { "sku": "WIDGET-01", "quantity": 2 }
          ]
        }
      }
    }
  ]
}

This file is committed to your repository (or published to a Pact Broker). It is the legally binding agreement between producer and consumer.


The Producer Side: Verifying Against Contracts

Once the consumer publishes a pact, the producer runs it as part of its test suite. The producer's contract test:

  1. Loads the pact file
  2. Generates an actual order.completed event using real production code
  3. Verifies that the generated event satisfies every field constraint in the pact
go
// Producer: orders-api contract verification
func TestOrderCompletedContractFulfillmentService(t *testing.T) {
    // Load the consumer's pact
    pact, err := loadPact("contracts/fulfillment-service-orders-api.json")
    require.NoError(t, err)

    for _, interaction := range pact.Interactions {
        t.Run(interaction.Description, func(t *testing.T) {
            // Generate a real event using production code
            order := createTestOrder()
            event := buildOrderCompletedEvent(order)
            payload, _ := json.Marshal(event)

            // Verify all fields the consumer expects are present and typed correctly
            assertContractSatisfied(t, payload, interaction.Response)
        })
    }
}

func assertContractSatisfied(t *testing.T, actual []byte, contract map[string]interface{}) {
    t.Helper()
    var data map[string]interface{}
    require.NoError(t, json.Unmarshal(actual, &data))

    for field, expectedType := range contract {
        value, exists := data[field]
        require.True(t, exists, "missing required field: %s", field)
        require.IsType(t, expectedType, value, "type mismatch for field: %s", field)
    }
}

When you rename customer_id to customerId, this test fails immediately. The consumer's pact says customer_id must be a string. Your producer no longer emits it. CI blocks the merge.


Running Contracts in CI/CD

The typical contract testing workflow in a monorepo or multi-repo setup:

Consumer CI:
  1. Run unit tests
  2. Generate pact file from consumer contract tests
  3. Publish pact to Pact Broker (or commit to shared repo)

Producer CI:
  1. Run unit tests
  2. Pull latest pacts from Pact Broker
  3. Run contract verification tests
  4. Publish verification results
  5. Gate deployment on "can-i-deploy" check

In a CI pipeline (GitHub Actions example):

yaml
# .github/workflows/contract-verify.yml
name: Contract Verification

on:
  push:
    paths:
      - 'internal/events/**'
      - 'internal/api/handler/**'

jobs:
  verify-contracts:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Pull consumer contracts
        run: |
          curl -s https://pact-broker.internal/pacts/provider/orders-api/latest \
            -o contracts/

      - name: Run contract verification
        run: go test ./contracts/... -v -run TestContract

      - name: Publish verification results
        if: always()
        run: |
          curl -XPOST https://pact-broker.internal/verifications \
            -H "Content-Type: application/json" \
            -d @verification-results.json

The can-i-deploy check queries the Pact Broker: "Has the current version of orders-api been verified against all consumer contracts?" If any consumer hasn't verified against the new producer version, the deployment is blocked.


Pact Broker vs. Committed Contracts

You have two options for storing pact files:

Committed to the repository — pact files live in a contracts/ directory, updated via PRs. Simple, no infrastructure required, works well for small teams or when producer and consumers are in the same repo.

Pact Broker — a hosted service (open source or via PactFlow) that acts as a registry for pacts and verification results. Supports can-i-deploy checks, version tagging, and cross-team workflows where consumers are in external repos.

For internal microservices, committed pacts are usually sufficient. For external webhook consumers (your customers), a Pact Broker becomes necessary because you can't commit your customers' pact files to your repo.

This is where GetHook's event schema management helps: when customers register a destination, they can optionally upload a pact file describing their expectations. GetHook surfaces contract failures as part of the delivery attempt record, so you can see which consumer contracts a given payload version satisfies before deploying a schema change.


What Contract Testing Doesn't Cover

Contract testing is narrow by design. It doesn't replace:

  • Load testing — contracts say nothing about delivery latency or throughput
  • Authentication testing — HMAC signature verification isn't a schema concern
  • Retry behavior — whether you retry on 5xx is out of scope for contracts
  • End-to-end flows — contracts verify field presence, not business logic correctness

Think of contract tests as the automated equivalent of a human reviewing an API changelog before deploying. They're fast, cheap, and catch the most common class of integration failure — field renames, type changes, required field removals — without requiring a full integration environment.


Practical Starting Point

You don't need a Pact Broker to get started. Begin with committed contracts and a simple verification helper:

  1. Write a test in your consumer that calls the real handler with a fixture payload and asserts it doesn't error.
  2. Extract the fixture into a contracts/ JSON file.
  3. Write a test in your producer that generates an event and validates it satisfies the contract JSON.
  4. Add both tests to CI with required status checks on PRs.

That's it. No new infrastructure, no SDK, no framework. When you outgrow committed contracts (multiple teams, external consumers), graduate to a Pact Broker.

The goal isn't a perfect contract testing framework — it's catching the field rename that would have caused three hours of debugging at 2am on a Friday.

See how GetHook tracks event schema versions across deployments →

Stop losing webhook events.

GetHook gives you reliable delivery, automatic retry, and full observability — in minutes.