Testing Webhooks Locally: Replay Signed Payloads, Not Just the Handshake
Last verified July 27, 2026 · 10 min read
Covered here:Strava
Your integration test POSTs a webhook body to your handler and asserts the response was 200. It passes. It will keep passing on the day you ship the bug that adds 8,431 steps to a day that already had 8,431 steps, because the handler answers 200 to the second delivery too. It is supposed to. A duplicate delivery is a success from the provider's point of view, so the status code cannot tell you whether the second one did nothing or did everything twice.
That is the asymmetry that makes health webhooks worth their own test suite. A duplicated e-commerce event usually collides with a unique constraint, or charges a card twice, and somebody notices within the hour. A duplicated health event lands in an aggregate. Nothing throws, no alert fires, and a user simply had an unusually good Tuesday. Months later a support ticket arrives and you spend an afternoon proving it was your ingest path and not their watch.
So the test to write first is not "does the endpoint respond". It is this:
it("a replayed delivery does not double the day", async () => {
const raw = fixture("fitbit/activities-2026-07-26.json"); // exact bytes
const headers = signStandardWebhook(raw, { id: "msg_01", ts: 1785024000 });
await post("/webhooks/fitbit", raw, headers);
await post("/webhooks/fitbit", raw, headers); // same id, byte-identical
expect(queue.jobs("fetch-window")).toHaveLength(1);
expect(await dayValue(userId, "steps", "2026-07-26")).toBe(8431);
});
Two assertions, neither of them about a status code. One counts what the handler enqueued; one reads the user's day back out. Everything below is about how to make the rest of the suite look like that.
The handshake is the easy part, and it is loud when it breaks#
The subscription validation dance is what everyone reaches for first, and it deserves about ten minutes. It is a one-time setup event, a pure function of a query string, and its failure mode is the loudest in this whole area: nothing ever arrives at all. You will not ship it broken for three months without noticing.
The hub.challenge GET echo that Strava and several others use is inherited from PubSubHubbub, whose Core 0.4 §5.3.1 requires the subscriber to answer with a 2xx and a body equal to the hub.challenge parameter — a 2xx whose body does not match is a failed verification. Two assertions fall out of that. Assert on the body, because asserting only that the route returned 2xx cannot fail: most frameworks return 200 for a route that returns nothing at all. And assert the mismatch case, because a handler that echoes any challenge it is handed lets somebody subscribe your endpoint to a topic that is not yours; per the same section, a topic you did not request must produce a 404.
Everything past that base pattern is a vendor extension. A verify_token, a JSON envelope with a specific key name, a content type, a response deadline in seconds — none of those are in the spec, and they differ per provider. We could not reach Strava's developer documentation during research on 2026-07-30, so read the current parameter names and the deadline off Strava's own docs rather than off any blog, including this one. If your subscription is failing right now, why a Strava webhook stops firing is the diagnostic page and it carries the provider-specific mechanics. This page assumes the subscription exists and asks what your handler does with the traffic.
One sentence on tunnels, which is all they warrant: you need one only for the handshake and for a first manual end-to-end smoke test, because the provider has to reach a public HTTPS URL, and ngrok, Cloudflare Tunnel and smee.io all do that job. A tunnel proves reachability. It proves nothing about correctness, it cannot be a CI dependency, and every test below runs faster and more deterministically without one.
Assert on the job, because the payload is not the data#
Most fitness webhooks are thin change pointers, not data carriers. The notification says something about this subject, in this window, changed, and the handler's actual job is to resolve the subject, enqueue a fetch, and get out of the way. The webhook ingestion design is where that shape is argued; the consequence for testing is narrower and worth stating on its own.
If the handler's output is a queued job, then the queue is your assertion surface. Use a real in-memory queue rather than a mock you can interrogate, and assert on job identity and count:
- exactly one job, not one call to a stub you wrote
- keyed on
(user_id, provider, metric, window), so eleven pings about the same Tuesday collapse into one fetch instead of eleven quota-burning calls - carrying a civil date, not a UTC instant, because whose midnight defines the day decides which day gets doubled when it goes wrong
The trap here is the same one that makes an OAuth revocation test worthless: an assertion that cannot fail reads as coverage. expect(providerClient.fetch).toHaveBeenCalled() against your own fake asserts that your fake is wired up. It will pass whether or not the fetch job was deduplicated, whether or not the window was correct, and whether or not the write that follows is a replace or an increment.
The replay suite#
Five cases. Each one is a byte-level fixture plus an assertion on observable state, and none of them needs the provider, a tunnel, or a network.
| Case | What you feed the handler | The assertion that catches the health bug |
|---|---|---|
| Same event twice | Identical raw bytes and identical delivery id, POSTed twice | One job enqueued, one delivery row, the day's value unchanged. Not "returned 200 twice" |
| Out of order | Two events for the same user and day, the older one delivered second | The stale event is a no-op and the day's stored value does not move |
| Replayed later | A valid, correctly signed delivery from days ago | Rejected on the timestamp tolerance, before any dedupe logic runs |
| Disconnected user | An event whose subject no longer maps to a linked account | Acknowledged and dropped, with no row written anywhere |
| Malformed and unsigned | Truncated JSON, a null where a number was, and a body whose signature does not verify | Dead-lettered with the window attached, and no partial write |
Two of those rows carry an argument that does not fit in a cell.
Out of order. Resolve on the provider's version of the record, never on arrival time. A phone that has been out of signal flushes its backlog the moment it reconnects, and the first thing that flush does is deliver two-day-old values after fresh ones. Resolve on arrival and the backlog overwrites a correct Thursday with a stale Tuesday, silently, on a schedule set by the user's train journey rather than by you. The nasty part is that the write itself looks perfectly healthy in every log you keep.
Disconnected user. This is the row teams skip and the one that produces the ugliest incident, because the failure is not a wrong number — it is data existing for somebody who asked you to delete it. The event arrives for a subject that no longer maps to a linked account, and the ingest path does exactly what it was built to do: it creates the account. Three separate things need asserting, not one. That the delivery is acknowledged rather than errored, because a provider that keeps retrying a 500 keeps re-attempting the resurrection. That no row is written anywhere — including the dead-letter queue, which is a store like any other and the one everybody forgets. And that the decision is driven by a tombstone rather than by the absence of a user row, because an ingest path that reads "no such user" as "create one" will pass happily against a merely-empty test database and fail against a real erased one. That last assertion belongs to the erasure test suite as much as to this page; run it in both, because each suite is the other's regression test.
Signature tests that are capable of failing#
Standard Webhooks (read 2026-07-30) signs msg_id.timestamp.payload and ships the result in a webhook-signature header, with webhook-id and webhook-timestamp alongside it. The spec is blunt about the failure mode that most unit tests are blind to:
it's important to make sure that the payload sent is the same as the payload signed. Cryptographic signatures are sensitive to even the smallest changes, and even a stray space can cause the signature to be invalid. This is a very common failure mode as many webhook consumers often accidentally parse the body as json, and then serialize it again.
A test that builds the signature from the same parsed object the handler receives cannot detect that. It re-serializes on both sides and the two agree. To make the test able to fail, sign a raw byte string on disk and push those exact bytes through the real request path, body-parsing middleware and all. If your framework has already consumed the stream by the time the verifier runs, the test will tell you, which is the point.
Key rotation is the other case worth automating, and the spec makes it cheap to test. The webhook-signature header is a space-delimited list of signatures, plural "to support zero downtime secret rotation", with v1 for the HMAC-SHA256 scheme and v1a for ed25519. So the rotation test is: sign one body with the old secret and one with the new, concatenate both into a single header, and assert the handler accepts it while both secrets are configured. Then drop the old secret and assert the same request is now rejected. A verifier written against a single signature string passes the happy path for months and fails silently on the morning you rotate.
One number you will not find: the spec defines no replay tolerance window. It says to check that webhook-timestamp is "within some allowable tolerance of the current timestamp" and leaves the value to you. Pick one, put it in config, and make the tolerance boundary a test parameter rather than a magic constant buried in the verifier.
What this suite does not cover#
Be honest about the ceiling, because the parts you cannot test locally are the parts that page you.
Provider retry and disable behaviour. How many times a provider retries, on what schedule, and whether it disables an endpoint that keeps failing, is provider documentation you have to read, and it was unreachable to us on 2026-07-30. Your replay suite proves your handler is safe given a duplicate; it says nothing about how many duplicates you will actually get.
Delivery itself. No local test can prove events will arrive. That is a production concern, and the answer is a freshness alert per provider plus a reconciliation sweep, not a green suite.
Signature schemes you assumed. Standard Webhooks is a well-specified reference, and we could not verify that any fitness provider actually implements it. Do not copy the header names into your verifier because they appear here; check them against your provider, and write the fixture from a real captured delivery rather than from a spec example.
Fault injection sits one layer down. Duplicates and bad signatures are payload problems. Timeouts, resets and a provider returning an HTML error page instead of JSON are transport problems, and they belong with the tooling covered in testing rate limits and outages rather than in the replay harness.
If webhooks themselves are new to you rather than the testing of them, what webhooks are and why fitness APIs use them is the concept page.
In short#
Spend ten minutes on the handshake, keep the tunnel out of CI, and put the rest of the effort into a replay suite whose assertions are about enqueued jobs and stored days rather than status codes. Sign fixtures as raw bytes so the re-serialization bug can actually surface, and add the two-signature rotation case now instead of discovering it during a rotation. The duplicate that doubles somebody's step count will not announce itself, so the assertion has to.
Frequently asked questions
- Do I need a tunnel like ngrok to test a webhook handler?
- Only for the subscription handshake and a first manual smoke test, because the provider has to reach a public HTTPS URL. Every test that matters afterwards runs faster and more deterministically without one, since you are POSTing fixture bytes straight at your own handler. A tunnel proves reachability, not correctness, and it should never be a CI dependency.
- How do I prove a replayed delivery did not double a user's step count?
- POST byte-identical bodies with the same delivery id twice, then assert two things that have nothing to do with the response: exactly one fetch job was enqueued, and the stored value for that civil day is unchanged. Asserting the endpoint returned 200 both times cannot fail, because a correct handler acknowledges duplicates so the provider stops retrying. The observable state is the only thing that distinguishes a no-op from doing the work twice.
- What should a webhook test assert on when the payload is only a change pointer?
- On the job that got enqueued. Most fitness notifications carry a subject, a metric and a window rather than values, so the handler resolves the subject and queues a fetch. Assert the job count, and assert the job key is the user, provider, metric and window, so that repeated notifications about the same day collapse into one API call. Asserting that a fake provider client was called only proves your fake is wired up.
- How do I test signing key rotation before the rotation happens?
- Standard Webhooks makes the signature header a space-delimited list of signatures precisely to allow zero-downtime secret rotation, so sign one body with the outgoing secret and one with the incoming secret, put both in a single header, and assert your verifier accepts it while both are configured. Then remove the old secret and assert the same request is rejected. A verifier written against one signature string passes for months and fails on the morning you rotate.
- Why does my signature check pass in tests but fail against the real provider?
- Almost always because the test signs the parsed object rather than the bytes. The Standard Webhooks spec calls this out as a very common failure mode: consumers parse the body as JSON and then serialize it again, and even a stray space breaks the signature. Sign a raw byte string held as a fixture and push those exact bytes through the real request path including body-parsing middleware, so the test is capable of catching it.
Keep reading
Elsewhere on the site
Pages that share this one’s concepts and sources, from other sections.
Next steps
Was this page useful?
Independent comparison, last reviewed July 27, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.
← All testing · by AIFitnessAPI