Compatibility

Does Obfuscation Break Webhooks and Signature Verification?

A webhook handler is a small program with unusually large consequences. It authenticates a request signed by somebody else, decides what kind of event arrived, checks whether it has already processed that event, and then does something that usually involves money. Every one of those steps except the first reads a property name that the sender chose.

What was measured

One sample file, driven through five protection profiles for the base column and through both member-renaming profiles for the rest. It computes an HMAC over the raw body exactly as a sender does, verifies that signature two ways, dispatches on the event type through a handler table, records the event id in a dedupe set, reads the amount and currency out of the nested payload, and finally builds the acknowledgement it sends back.

The event body is a string, never an object literal, and the signature is computed over those bytes. This matters for the same reason it matters in every one of these measurements: the keys are written by the sender -- a payment processor, a source-control host, a mail provider -- and that sender is not rebuilt when your bundle is. What the sample authors is the handler table, the dedupe store and the acknowledgement body.

The base column is clean on all five profiles: both signature checks, the routing decision, the dedupe verdicts, the money fields and the acknowledgement all reproduced exactly. Protection alone does not affect webhook handling, and it does not affect the HMAC, because a hash over a string sees only bytes.

The signature still verifies. That is the problem

With a pattern matching the sender's event-type key, the verification result was unchanged -- still verify-raw=true -- while the dispatch went from route=credit-account to route=IGNORED-unknown-event. The type read as undefined, the handler table has no entry for undefined, and the lookup fell through to the ignore branch.

The order of those two facts is the whole finding. The request is authenticated, so nothing in your security layer objects; every log line says a valid, signed event arrived from a trusted sender. It is then dropped on the floor. The acknowledgement returned to the sender changed to record the ignore decision, but it is still a success response, so the sender marks the delivery as accepted and never retries. A paid invoice that your system decided was an unknown event will not be re-sent tomorrow.

The second event in the same run went the same way, which rules out the reading that one event type happens to be missing from the table. Every event of every type routes to the final else branch, because undefined matches no key at all. This is the same shape measured for error codes in an earlier pass, arriving here at the point in the stack where the consequence is a customer's balance.

A different event is discarded as a duplicate of the first

This arm needed the sample to be extended before it could say anything. Delivering the same event twice only showed that the dedupe key had become undefined, which cannot distinguish a wrong key from a key that collapses everything onto one entry. Adding a second, entirely different event made it a measurement.

With a pattern matching the sender's id key, the dedupe key went from the event's real identifier to undefined for both events, and second-event-seen-as-duplicate went from false to true. The second event -- a different type, a different customer, a different id -- was recognised as something already processed and skipped.

Read at scale that is the entire feature inverted. Idempotency exists so that a redelivered event is processed once; here the first event to arrive claims the only key there is, and every subsequent event of any kind is treated as a replay of it. Nothing throws, nothing is logged as an error, and the acknowledgement is a success. Meanwhile the acknowledgement body itself lost its id field entirely, because a property whose value is undefined is omitted by JSON.stringify, so the sender's own delivery log records a reply with no event reference in it.

Where it fails loudly, and where it just gets the number wrong

One arm crashed, and it is the good outcome. A pattern matching the nested payload container produced TypeError: Cannot read properties of undefined at the first read inside it, on both targets. The handler died before it could act on the event, which means the sender receives an error response and retries, and the event is not lost. A container that something is read out of fails at the first access; the failure is immediate and unambiguous.

The arm matching the money fields directly is the quiet one. The amount and the currency both read as undefined, and nothing objected. Whether that becomes a credit of undefined, a NaN after arithmetic, or a null column depends entirely on what your handler does next -- but it does not become an exception on its own, and the event is marked processed either way.

The last arm is the acknowledgement, whose keys this sample owns at both ends. Renaming them changed nothing inside the program and rewrote the response body to {"_0x1":true,"id":"evt_0001","_0x2":"credit-account"}. Some senders read fields out of your acknowledgement; all of them log it. A reply that no longer contains the fields it is documented to contain is another published interface changed by a build setting.

What it looks like from the finance side

The reason this area is worth measuring rather than reasoning about is that its symptoms are financial before they are technical. A handler that ignores every event still returns success, so the application's dashboards are green: no error rate, no latency spike, no failed requests. What moves instead is a reconciliation. Payments succeed at the processor and never post in your system; subscriptions renew and the account stays on the old plan; a cancellation is charged for another month.

The dedupe arm produces a different and more selective version of the same thing. Because the first event to arrive claims the only key there is, some events are processed normally -- the first one after each restart -- and everything after it is dropped. That is a partial failure, and a partial failure is much harder to attribute than a total one, because the feature demonstrably works when anyone tests it by hand.

There is one place the trouble is visible early, and it is not in your infrastructure. Your provider's delivery log records every event as accepted, with your acknowledgement body beside it. Comparing that list against the records your system created for the same window is the single check that separates this from every other explanation, and it can be done from the provider's dashboard without deploying anything.

Why the provider's retry does not save you

Webhook delivery is built around retries, and every provider documents them: a non-2xx response is redelivered on a schedule, often for a day or more, and failed deliveries sit in a dashboard where they can be replayed by hand. It is a good safety net, and the three measured outcomes line up against it in a way that is worth stating explicitly.

The arm that threw -- the renamed nested container -- produces an error response. That delivery is retried, appears in the failed-delivery list, and can be replayed after a fix. It is the only one of the three the safety net catches. The arm that routed every event to the ignore branch and the arm that treated a new event as a duplicate both return success, because from the handler's own point of view nothing failed. Those deliveries are recorded as accepted, are not retried, and do not appear in any failure list to replay.

So the two silent failures are also the two permanent ones, and recovering from either means reconstructing the missing work from the provider's event log through its API and replaying it yourself. That is a different and much larger job than clicking replay on a list of failures, and it has to be done for the whole window during which the affected build was live.

There is a design point hiding in this that is worth taking regardless of whether you use member renaming. A handler that answers success to an event type it does not recognise has disabled its own retry safety net for every future case where recognition breaks -- renaming, a provider adding a type, a typo in a table. Answering with an error for genuinely unrecognised events keeps the delivery in the retry queue and turns a silent loss into something visible in the provider's dashboard.

What this means in practice

Protection does not break webhooks. Five profiles, both signature checks included, reproduced the sample's behaviour exactly, and the HMAC is untouched by any of this because hashing sees bytes rather than identifiers.

With member renaming on, the names a sender writes -- id, type, data, amount, currency, created, object -- belong in the reserved list. They are unusually dangerous as a class because they are short, generic and shared across every provider, which makes them exactly the names a broad pattern catches. The standing advice on this site, to anchor member patterns to identifiers distinctive to your own application, avoids the whole family at once.

The verification is one delivery. Send a test event from your provider's dashboard to the protected build and check three things in order: that the signature verifies, that the event routed to the handler you expect rather than to the ignore branch, and that a second test event of a different type is not treated as a duplicate. The first will pass whatever happens, which is why the other two are the ones worth watching.

Frequently asked questions

Does obfuscation break webhook signature verification?

No. The signature is an HMAC over the raw request bytes, and hashing sees bytes rather than property names, so it is unaffected by renaming and by every protection profile measured. In the arms where the handler misbehaved, the signature check still returned true -- which is what makes the failure hard to notice.

Why does my webhook handler ignore valid events after obfuscation?

Because the event-type property was renamed while the sender still writes the original name. The type reads as undefined, the handler table has no entry for undefined, and every event falls through to the final else branch. The measured route went from credit-account to the ignore branch for both test events in the same run.

Can renaming break webhook idempotency?

Yes, and it was measured rather than inferred. With the sender's id property renamed, the dedupe key became undefined for every event, so the first event to arrive claims the only key there is. A second, entirely different event was then reported as already processed and skipped, with no error and a success acknowledgement.

Does the sender retry when this happens?

Usually not, and that is the worst part. The handler returns a success response because from its own point of view nothing failed, so the delivery is marked accepted and never retried. The exception is the arm where a nested container was renamed: that threw a TypeError, the request failed, and the sender would retry.

Why did my acknowledgement body lose a field?

Because JSON.stringify omits any property whose value is undefined. When the event id read as undefined, the id key was not written into the reply at all, so the sender's delivery log records an acknowledgement with no event reference. The same rule is why renamed fields disappear from any JSON document your code emits.

Which webhook property names should I reserve?

The ones the sender writes: id, type, data, amount, currency, created and object cover most providers. They are short and generic, which is exactly why a broad member pattern catches them. Anchoring the pattern to identifiers distinctive to your own application avoids the entire family without maintaining a list.

How do I test a webhook handler against a protected build?

Send two test events of different types from your provider's dashboard. Confirm the signature verifies, confirm each routed to the handler you expect rather than to an unknown-event branch, and confirm the second is not recorded as a duplicate of the first. The signature check passes in every failing case measured here, so it proves nothing on its own.

Related reading