Webhook Testing & Local Development: Verifying Integrations End to End
Webhook integrations fail in ways that ordinary request-response APIs do not: the caller is a third party you cannot step through, deliveries arrive at unpredictable times, signatures must match byte-for-byte, and a single missed 2xx can trigger a retry storm hours later. This part of the wider webhook engineering library — which you can explore from the home page — covers how to test and develop those integrations end to end, from the first delivery hitting your laptop to load profiles that mirror Black Friday traffic. The goal is to make webhook behavior observable and reproducible before it reaches production, where the only feedback loop is an incident.
Testing webhooks spans five distinct disciplines, each with its own failure surface. You expose a local handler through local webhook development with tunnels so a provider can actually reach code running on your machine. You pin the payload shape through webhook contract testing so an upstream schema change fails a build instead of corrupting state. You remove the provider from the loop entirely with webhook mocking and sandbox environments so CI can run offline and deterministically. You validate capacity through load testing webhook endpoints so a traffic spike degrades gracefully rather than dropping events. And you build a feedback loop through inspecting and replaying webhook deliveries so a failed delivery can be diagnosed and re-run deterministically. These disciplines interlock with the webhook architecture fundamentals that define the contracts, the webhook security and signing controls that every local run must honor, and the resilient delivery and retry strategies whose retry semantics your tests must reproduce.
Local Development with Tunnels
The first obstacle in webhook development is that providers dispatch to public URLs, while your handler runs on localhost behind NAT. A tunnel bridges that gap by allocating a public hostname and forwarding inbound requests to a local port. Tools such as ngrok and cloudflared terminate TLS at the edge, preserve the raw request body, and forward the exact headers a signature check depends on. Working through local webhook development with tunnels lets you set breakpoints in the same handler the provider hits, replay the provider’s own test events, and iterate in seconds instead of redeploying to a cloud sandbox.
The non-negotiable rule is that local runs must enforce the same security posture as production. A tunnel that forwards a request but discards the X-Signature header, or a handler that skips verification because “it’s just local,” trains you to write code that fails the moment it ships. Run verification against a development secret, reject unsigned and stale payloads, and treat a signature mismatch on your laptop as a real bug — usually a body that was re-serialized before hashing.
That budget is the detail that surprises people most. A provider typically allows five to ten seconds for an acknowledgment, and the tunnel adds two round trips plus TLS termination — usually 50–150 ms, but far more if the tunnel edge is on another continent from your laptop. Sit on a breakpoint for thirty seconds and the provider records a timeout, marks the delivery failed, and schedules a retry; when you finally continue, the handler processes an event the provider already gave up on, and a duplicate arrives minutes later. The habit that avoids this is to break after acknowledgment wherever possible: acknowledge on the raw request, then breakpoint inside the worker that does the real processing.
Choosing between tunnel tools comes down to three properties. URL stability matters most: an ephemeral hostname means re-registering the endpoint with the provider on every restart, which is tedious for one developer and unworkable for a team, so a reserved subdomain (or a named tunnel bound to a domain you own) pays for itself within a day. Request inspection — a local UI that shows each forwarded request and lets you re-send it — collapses the debugging loop from minutes to seconds and is the single biggest productivity difference between tools. Traffic policy matters for shared environments: the ability to route one path to one developer’s machine lets a team share a single registered endpoint instead of fighting over it.
Two constraints regularly rule tunnels out entirely, and it is better to know before you spend a morning. Providers that require mutual TLS usually cannot be satisfied by a tunnel that terminates TLS at its own edge, because the client certificate never reaches you. Providers that let you allowlist their egress IPs are fine, but providers who require you to present a fixed source IP or a certificate pinned to your domain are not. In both cases the practical answer is a shared development environment with a real ingress rather than a laptop, and the local loop moves to recorded fixtures instead.
Finally, remember that a tunnel is a public, unauthenticated route into a process running on your machine. Give it a hard-to-guess hostname, enable whatever edge authentication the tool offers for anything longer-lived than a single session, and never point one at a service holding production credentials. Scanners find tunnel hostnames within minutes; the handler behind them should reject anything unsigned exactly as production would.
Contract Testing for Payload Shape
A webhook payload is a contract between an upstream you do not control and a consumer you do. When the provider adds a required field, renames an enum value, or changes a timestamp format, your handler may keep returning 200 OK while silently corrupting downstream state. Consumer-driven webhook contract testing makes that contract explicit: you record the exact payload shape your handler depends on, express it as a versioned schema or Pact-style contract, and run it in CI so a drift fails the build instead of an invoice.
The hardest part is deciding what the contract actually covers. Assert every field your handler reads, and deliberately leave transport metadata — attempt counters, delivery ids, timing headers — out of the contract so a provider’s retry bookkeeping never fails your build. The annotated payload below marks the boundary.
Contract tests are cheap and deterministic — they run without a network, a tunnel, or the provider’s sandbox. They pair naturally with event schema design on the producer side and with the payload versioning strategy that lets both sides evolve. Where the producer is internal, a shared schema registry can verify both directions of the contract on every change.
The strictness setting is where most contracts go wrong in one direction or the other. A schema with additionalProperties: false fails the build the first time the provider adds an optional field — a change they are entitled to make without notice, and one that breaks nothing in your handler. A schema that validates nothing but the presence of an id passes forever and catches nothing. The rule that survives contact with real providers is strict on what you read, permissive on what you ignore: require the fields your handler consumes, assert their types and formats, pin the enum values you branch on, and stay silent about everything else. Applied to a typical payment or order event, that usually means eight to fifteen assertions out of a payload with sixty fields.
Enum handling deserves its own decision, because it is the one case where strictness and resilience conflict. If you fail validation on an unrecognised status, a provider adding status: "partially_refunded" takes your integration down at the moment they ship it. If you silently ignore unknown values, you process a refund as if nothing happened. The correct behaviour is a third path: accept the delivery, route the unknown value to a quarantine or manual-review path, and emit a metric that alerts. Your contract test then asserts the known values still parse and that an unknown value takes the quarantine branch rather than throwing — which is a far more useful test than either extreme.
Wire the contract into CI so failures are informative rather than merely red. A failing contract test should print the field path, the expected shape, and the actual value from the fixture that failed — an engineer who sees data.total_cents: expected integer, got "48200" fixes it in a minute, while one who sees schema validation failed opens the payload by hand. Run the contract against two sources on every build: your curated fixtures, which are stable, and a rolling sample of yesterday’s captured production deliveries, which are not. The second source is what actually detects provider drift, and it is the reason a contract suite keeps earning its place months after it was written.
When the contract does break, the response depends on which direction it broke. An additive change — a new optional field, a new event type you do not subscribe to — should update the fixture and move on. A breaking change — a removed field, a tightened type, a renamed enum — needs the versioned handler split described in the payload versioning guidance, not a patch to the existing parser, because the provider will keep sending the old shape to some traffic for a transition period and you must handle both. Treat the version identifier in the payload as the routing key rather than trying to write one parser that tolerates every historical shape; the tolerant parser is where silent data corruption lives.
Mocking and Sandbox Environments
Contract tests prove the shape of a payload; they do not prove your handler survives a real delivery sequence. That requires standing in for the provider, and webhook mocking and sandbox environments covers the three substitutes worth maintaining. A mock sender you control signs payloads with a test secret and can inject retries, duplicates, and out-of-order arrivals on demand — the only practical way to exercise your idempotency path in CI. Recorded fixtures captured from production deliveries give you real payload shapes offline, at the cost of going stale unless you re-record on a schedule. The provider’s own sandbox is the only substitute that exercises their real dispatcher, retry timing, and signature implementation, but it is slow, rate-limited, and often cannot reproduce the failure cases you most want to test.
Keep the three tiers on different schedules so cost tracks value: fixtures and the mock sender run on every commit, and the sandbox runs nightly or before release. Mocks also make it safe to test the ugly cases a provider will not generate on demand — a truncated body, a signature computed over re-serialized JSON, or the same event id arriving twice thirty seconds apart.
The failure case worth designing against is fixture rot, because it is silent and it makes your suite actively misleading. A fixture recorded eight months ago will keep passing forever while the provider’s real payload has gained three fields and changed a timestamp format, and the first time you learn about it is in production. Two mechanisms fix this cheaply. Stamp every fixture with the date and schema version it was recorded at, and fail the build — or at least warn loudly — when any fixture for an actively used event type is older than about ninety days. Then run a nightly job that captures a small sample of real deliveries, normalises them, and diffs their structure against the corresponding fixture; a structural difference opens a ticket rather than breaking a build, which is the right severity for a change that has not hurt you yet.
Sandboxes have the opposite problem: they are always current and almost never complete. Provider test modes typically cannot produce the states you most want to exercise — a chargeback, a dispute resolution, a subscription that lapses after a failed renewal — because those depend on real time passing or real money moving. They are also frequently rate-limited to a handful of events per minute and share infrastructure across every customer, which makes them the least reliable component in your CI if you put them on the critical path. Use the sandbox to verify the things only it can verify: that the provider’s real signature implementation matches your verifier, that their retry timing is what the documentation claims, and that your registered endpoint is reachable with the TLS configuration you deployed. Everything else belongs in the faster tiers.
A mock sender earns its keep by being deliberately hostile. The generator should be able to emit the same event twice with different delivery IDs, deliver events out of order, hold a response until the client times out, send a body whose Content-Length disagrees with the payload, and sign with a retired secret. Each of these corresponds to something a real provider does under load or during an incident, and each one exercises a different part of your defensive path — idempotency, ordering, timeout handling, and signature verification respectively. A handler that survives all five is far more likely to survive a bad afternoon than one that has only ever seen well-formed traffic.
Load Testing Ingestion Capacity
Webhook traffic is bursty by nature: a batch job upstream can emit ten thousand order.updated events in a minute, and your endpoint must absorb that without dropping deliveries or blocking the producer past its acknowledgment timeout. Load testing webhook endpoints drives synthetic traffic — with valid signatures — at and beyond expected peak to find the breaking point before a real spike does. The numbers it surfaces (sustained requests per second, p99 acknowledgment latency, queue depth under load) directly size your worker pool, connection limits, and the backoff and retry windows on the producer side.
Load tests must reproduce production semantics, not just volume. If your endpoint enqueues and returns 202 immediately, the test should measure how fast the queue drains and what happens when it saturates, not just how fast the HTTP layer accepts requests. A passing throughput number with an unbounded, silently overflowing queue is a false negative. For the concrete scripting of that profile — signed payloads, staged ramps, and thresholds that fail the run — work through benchmarking webhook throughput with k6.
A useful profile has four stages, and each answers a different question. A baseline at normal traffic establishes what healthy looks like and catches an environment that was already unhealthy before the test started. A ramp finds the knee: the rate at which acknowledgment latency starts climbing faster than throughput, which is your real capacity and is almost always lower than the rate at which errors begin. A spike — an instantaneous jump well above the knee, held for two or three minutes — tests the behaviour that matters during an upstream batch job: does the endpoint shed load cleanly with 429, does it queue, or does it accept everything and fall over four minutes later when the queue exhausts memory. A soak at moderate load for an hour or more finds the slow failures a short test never sees: connection pool leaks, unbounded in-memory caches, log volume filling a disk, and file descriptors that are never released.
Set thresholds that fail the run automatically, or the test becomes a chart nobody reads. Three thresholds cover most integrations: p99 acknowledgment latency below half the provider’s timeout — 3 s against a 10 s budget leaves room for a bad day — a non-2xx rate under 0.1 percent during the ramp and soak, and a queue that returns to its baseline depth within a defined window after the spike ends. That last one is the most valuable and the most often omitted: an endpoint that accepts a 2,000/s spike and then takes forty minutes to drain has not passed, because in production the next spike arrives before the queue is empty and the backlog compounds.
Sign the synthetic payloads with a real key and route them through the real verification path. An unsigned load test measures a program you do not run: HMAC over a 20 KB body is cheap but not free, and more importantly the signature path is where a body-buffering bug will show up under concurrency. Use realistic payload sizes drawn from your capture store rather than a minimal fixture, mix event types in the same proportion production sees, and include a realistic share of duplicate event IDs so the idempotency store is exercised at load too — deduplication lookups are frequently the first thing to saturate.
Inspecting and Replaying Deliveries
When a delivery fails in production, the worst outcome is having nothing to look at. A capture layer that persists every raw delivery — headers, body, signature, timestamp, and the handler’s response — turns an opaque failure into a reproducible test case. Inspecting and replaying webhook deliveries covers building that store and the replay path that re-runs a stored delivery against a fixed handler with the original idempotency key intact. Replay is also how you safely drain a dead-letter queue after deploying a fix.
This is the stage that closes the loop back to every other one. A capture store is where fixtures come from, so the corpus stops being a set of hand-written payloads and becomes a stratified sample of real traffic. It is where load-test payloads come from, so the size distribution and event mix in a benchmark match reality instead of a guess. It is where contract drift is detected, because yesterday’s captured deliveries validated against today’s schema is the only check that sees a provider change on the day it ships. Teams that build capture first usually find the rest of their testing gets cheaper, because every other technique stops needing invented data. When a specific delivery has already failed and you need a method rather than infrastructure, debugging failed webhook deliveries walks the triage end to end.
Two operational decisions belong here rather than later. First, retention: keep full raw bodies only as long as the realistic gap between a failure and someone noticing — a week covers most teams — then redact and finally reduce to metadata, because a table holding every payload your provider ever sent is a security liability that grows without bound. Second, replay throttling: any bulk replay must run at a fraction of normal throughput with an automatic halt when its error rate climbs, or a recovery from one incident becomes the cause of the next.
Matching the Test to the Risk
No single technique covers the surface, and running all of them everywhere is how a test suite becomes something engineers skip. The useful framing is coverage against feedback time: put the checks that run in seconds on every commit, the ones that need a real dispatcher on a nightly schedule, and the ones that consume real capacity before a release. What follows is the allocation that holds up for most integrations.
| Technique | Where it runs | Feedback time | What it catches | What it is blind to |
|---|---|---|---|---|
| Contract tests against fixtures | Every commit | Seconds | Renamed fields, tightened types, changed enums | Anything about runtime behaviour or timing |
| Mock sender scenarios | Every commit | Seconds | Duplicate, out-of-order and malformed deliveries | Drift in the provider’s real payloads |
| Replay of a captured corpus | Every pull request | Under a minute | Regressions against real historical traffic | Payload shapes the provider has not sent yet |
| Provider sandbox run | Nightly and pre-release | Minutes | Signature and retry behaviour of the real dispatcher | Failure states the sandbox cannot generate |
| Load and soak profile | Weekly and pre-release | Tens of minutes | Capacity knees, queue drain, resource leaks | Correctness of any individual payload |
| Synthetic delivery probe | Continuously in staging and production | Minutes | Expired secrets, broken routes, silent provider changes | Anything that only appears under load |
The synthetic probe in that table is the least common and often the highest value per line of code. It is a small job that, every few minutes, sends one signed delivery of a known event type at your staging endpoint (and, where side effects are safe, at production) and asserts the full path completed: signature accepted, event enqueued, worker processed it, expected row written. It catches the failures that no build-time test can — a secret that expired overnight, a route dropped by an infrastructure change, a provider that silently changed a header name — and it catches them in minutes rather than when a customer notices. Alert on two consecutive probe failures rather than one, so a single transient does not page anyone.
Environments, Secrets, and Test Data
The mechanics of testing are easier than the environment discipline around them, and most of the genuinely dangerous mistakes in this area are environment mistakes. Three rules prevent nearly all of them.
Use a distinct signing secret per environment, never a shared one. Sharing a secret between staging and production means a misconfigured staging endpoint can accept and process real production events — including refunds and account deletions — and it means rotating the production secret breaks staging at an unrelated moment. Separate secrets also make an entire class of incident diagnosable: if a delivery verifies against the staging secret in your logs, you immediately know traffic was misrouted rather than tampered with. Store them in the same secrets manager production uses, and load them the same way, so the loading path itself is tested. This connects directly to the key rotation procedure, which should be exercised in staging on the same schedule as production so the dual-accept window is proven before it matters.
Never point a test at real customer data, and be specific about what that means. Recorded fixtures derived from production payloads contain customer names, addresses, and amounts; scrub them at recording time, not at use time, and check the scrubbed fixtures into the repository only after that scrubbing is verified. The realistic failure here is not malice but convenience: someone captures a live payload to reproduce a bug, commits it to the test directory, and a customer’s address is in the repository history forever. A recorder that scrubs by default and requires an explicit flag to keep raw fields makes the safe path the easy one.
Give tests their own tenant, account, or workspace in every external system rather than sharing one with real usage, and reset state between runs. A CI job that reuses a shared sandbox account accumulates state — subscriptions, orders, webhooks registered by a previous run — until tests begin failing for reasons unrelated to the change under test, and the team’s response is invariably to add retries and sleeps rather than to fix the isolation. Seeding a fresh scratch database per run costs seconds and removes an entire genre of flakiness, including the specific one where an idempotency store left populated by a previous run makes a replay test pass while doing nothing at all.
Production Implementation Checklist
Validate a webhook integration against this sequence before promoting it past staging:
- Expose the endpoint locally — run a tunnel (
ngrokorcloudflared) to forward provider deliveries to your localhost handler, and register the public URL with the provider’s test environment. - Reproduce signatures locally — verify provider signatures against the development secret so local runs reject tampered payloads exactly as production does.
- Pin the payload contract — capture the provider’s event schema as a versioned contract and assert against it in CI to catch breaking changes before deploy.
- Stand up mocks and sandboxes — run a mock sender and recorded fixtures in CI, and reserve the provider’s sandbox for the behaviours only real dispatch can exercise.
- Load test ingestion — drive synthetic traffic at and beyond peak event volume to size queues, connection pools, and acknowledgment timeouts.
- Inspect and replay deliveries — persist raw deliveries with headers so failed events can be inspected, diffed, and replayed against fixed handlers.
Failure Modes & Mitigations
| Failure Mode | Impact | Mitigation |
|---|---|---|
| Tunnel re-serializes or alters the body | Local signature check fails on payloads that are valid in production | Forward the raw byte stream; hash the exact bytes received, never a re-encoded object |
| Tests skip signature verification | Code that passes locally rejects real deliveries, or accepts forged ones | Run the production verification path against a development secret in every test |
| Contract drift goes undetected | Handler returns 200 while writing corrupt state from a renamed field |
Assert payloads against a versioned schema in CI; fail the build on any unexpected change |
| Fixtures drift from live payloads | CI stays green against a payload shape the provider stopped sending months ago | Re-record fixtures on a schedule and diff them against a nightly sandbox capture |
| Load test ignores async drain | Throughput looks healthy while the queue silently overflows | Measure queue depth and drain rate under load, not just HTTP accept latency |
| No raw delivery capture | Production failures are unreproducible; no test case can be built | Persist headers, body, and signature for every delivery before processing |
| Shared signing secret across environments | Staging can process real production events; rotation breaks two systems at once | Issue a distinct secret per environment and load it from the same secrets manager |
| Unscrubbed production payloads in fixtures | Customer data lands in the repository and stays in its history | Scrub at recording time by default; require an explicit flag to retain raw fields |
| Provider sandbox on the critical CI path | Builds fail on their rate limits and outages, and teams start ignoring red | Run the sandbox nightly and pre-release; keep commit-time checks hermetic |
| Contract pins fields the handler never reads | Every additive provider change breaks the build until someone loosens the schema | Assert only consumed fields, their types, and the enums you branch on |
A Runnable Local Test Harness
The harness below stands up a FastAPI endpoint that mirrors a production handler — it verifies the signature on the raw body, enforces a timestamp window, persists the delivery, and acknowledges. A pytest case posts a correctly signed delivery and asserts it is accepted, giving you a deterministic test you can run without a provider or a tunnel.
# app.py — a webhook endpoint that mirrors production verification
import hmac, hashlib, time, json
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
SECRET = b"dev-secret" # load from env in real code
TOLERANCE_SEC = 300
DELIVERIES: list[dict] = [] # stand-in for a durable capture store
def verify(raw: bytes, header: str) -> bool:
"""Header format: t=<epoch>,v1=<hex>. Hash the RAW bytes, never a re-dump."""
try:
parts = dict(p.split("=", 1) for p in header.split(","))
ts, sig = int(parts["t"]), parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - ts) > TOLERANCE_SEC:
return False # stale: reject replays outside the window
expected = hmac.new(SECRET, f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)
@app.post("/webhook")
async def webhook(request: Request):
raw = await request.body() # capture exact bytes first
header = request.headers.get("x-signature", "")
if not verify(raw, header):
raise HTTPException(status_code=403, detail="invalid signature")
DELIVERIES.append({"headers": dict(request.headers), "body": raw})
return {"status": "accepted"}
# test_webhook.py — deterministic test, no provider or tunnel required
import hmac, hashlib, time
from fastapi.testclient import TestClient
from app import app, SECRET
client = TestClient(app)
def sign(raw: bytes) -> str:
ts = int(time.time())
sig = hmac.new(SECRET, f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
return f"t={ts},v1={sig}"
def test_signed_delivery_is_accepted():
body = b'{"type":"order.created","id":"evt_1"}'
resp = client.post("/webhook", data=body, headers={"x-signature": sign(body)})
assert resp.status_code == 200
def test_tampered_body_is_rejected():
body = b'{"type":"order.created","id":"evt_1"}'
header = sign(body)
resp = client.post("/webhook", data=b'{"type":"order.created","id":"evt_2"}',
headers={"x-signature": header})
assert resp.status_code == 403
Operational Considerations
Testing does not end at merge. Keep a small suite of synthetic deliveries running against staging on a schedule, so a provider’s silent schema change or an expired secret surfaces as a failed probe rather than a customer report. Wire the capture store into your observability stack so each delivery carries a trace ID from receipt through processing, and alert on the same signals your load tests taught you to watch: rising acknowledgment latency, growing queue depth, and signature failure rate. The disciplines below each go deeper into one stage of this pipeline.
Set those alert thresholds from measurements rather than intuition. Acknowledgment latency should page when p99 crosses roughly half the provider’s timeout, because that is the point at which normal variance starts producing real timeouts; alert earlier, at a third, as a warning. Signature failure rate should page on any sustained non-zero value where the baseline is zero — a handful of failures a day is background noise from scanners hitting a public endpoint, but a rate that tracks your delivery volume means a rotation or middleware problem. Queue depth is best expressed as time rather than count: page when the estimated drain time at current worker throughput exceeds the window in which the provider will give up retrying, since that is the moment a backlog becomes permanent data loss rather than a delay.
Sequence changes to an integration so that the reversible step comes first. Deploy handler changes behind a flag that routes a small share of an event type to the new path, watch the capture store for failures on that share, then widen. When a change touches verification — a new signature scheme, a rotated secret, a different header — run both the old and new paths in parallel and compare results before cutting over, because a verification change that is wrong rejects everything and there is no partial failure to warn you first. Keep the rollback to a configuration change rather than a redeploy: during an incident caused by a bad handler, the difference between a flag flip and a full build-and-deploy cycle is typically fifteen minutes of continued failures, and the provider will retry every one of them.
Finally, treat the test suite itself as something that decays. Fixtures age, sandboxes change, load profiles stop matching a business that grew. Put a recurring calendar item against three specific tasks: re-record the fixture corpus, re-run the load profile against current production volumes rather than last year’s, and confirm the synthetic probe still asserts something meaningful rather than passing because its assertion was quietly weakened during an unrelated fix. A suite nobody has revisited in a year is usually still green and no longer protecting anything.
Frequently Asked Questions
Do I need a tunnel at all if I have a staging environment?
Not necessarily, and for providers requiring mutual TLS or a fixed source IP you cannot use one anyway. The thing a tunnel buys is the debugger: a breakpoint inside the handler while a real provider delivery is in flight. If your staging deployment loop is under a minute and you can attach a remote debugger to it, staging plus a replayed capture covers most of the same ground.
How do I test retry behaviour when the provider controls the retries?
You cannot make their dispatcher retry on demand, so split the question. Use a mock sender to reproduce the retry pattern — same event delivered three times with increasing gaps and an incrementing attempt header — and assert your idempotency path holds. Use the sandbox once to confirm the provider's real intervals match their documentation, because that number is what your retention and alerting thresholds depend on.
What belongs in CI versus what should run on a schedule?
Anything hermetic and fast belongs in CI: contract checks, mock scenarios, and a replay of the captured corpus, all of which should finish inside a minute. Anything that depends on a third party or consumes real capacity belongs on a schedule: the sandbox run nightly, the load and soak profile weekly or before a release. Putting a rate-limited external sandbox on the commit path is the reliable way to teach a team to ignore a red build.
How do I load test without hammering a provider or a downstream API?
Generate the load yourself rather than asking the provider to send it, and stub the downstream calls your handler makes with a local fake that has a configurable latency distribution. The point of the test is your ingestion path — queueing, verification, worker throughput — not a third party's capacity. Reserve one small run against real downstreams to confirm your timeouts and connection pooling behave, and keep it well below any rate limit.
My integration passes every test and still breaks in production. What is missing?
Almost always one of three things: the tests use payloads that no longer resemble live traffic, they skip the verification path that production runs, or they never exercise concurrency. Replaying a stratified sample of yesterday's real deliveries through the full signed path, with several workers running at once, catches the large majority of what a fixture-only suite misses.
Is it worth testing against more than one schema version at a time?
Yes, whenever the provider is mid-transition, because they will keep sending the old shape to a share of traffic for weeks. Keep fixtures for both versions and route them to the handler by the payload's version identifier, then assert each version produces the correct outcome. A single tolerant parser that accepts both shapes looks simpler and is where silent data corruption tends to live.
How much of this is worth doing for a small integration with one event type?
Three things, and they take an afternoon: capture every raw delivery, verify signatures the same way locally as in production, and keep a handful of real captured payloads as a replayable test. Contract tests, sandboxes, and load profiles earn their cost as the number of event types and the delivery volume grow; capture and replay pay for themselves the first time something fails.
Related
- Local webhook development with tunnels — expose localhost to providers safely.
- Webhook contract testing — pin payload shape and gate CI.
- Webhook mocking and sandbox environments — mock senders, recorded fixtures, and provider test modes.
- Load testing webhook endpoints — size capacity for traffic spikes.
- Inspecting and replaying webhook deliveries — build a reproducible feedback loop.
- Webhook Security & Signing — the verification every test must honor.
- Resilient Delivery & Retry Strategies — the retry semantics your tests must reproduce.