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 type | What it verifies | Where it runs |
|---|---|---|
| Unit test | Logic within a single service | CI, fast |
| Contract test | Schema compatibility between producer and consumer | CI, fast |
| Integration test | Full end-to-end behavior with real services | CI/staging, slow |
| E2E test | User-visible behavior across the full stack | Staging/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:
// 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:
{
"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:
- ›Loads the pact file
- ›Generates an actual
order.completedevent using real production code - ›Verifies that the generated event satisfies every field constraint in the pact
// 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" checkIn a CI pipeline (GitHub Actions example):
# .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.jsonThe 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:
- ›Write a test in your consumer that calls the real handler with a fixture payload and asserts it doesn't error.
- ›Extract the fixture into a
contracts/JSON file. - ›Write a test in your producer that generates an event and validates it satisfies the contract JSON.
- ›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 →