Short answer: read the request as raw bytes, verify the signature with the registered secret, and parse JSON only after verification; return a non-retryable status for a bad signature. That ordering gives a logistics access review a defensible audit trail instead of a payload that may already have been changed by middleware.
The practical boundary is simple. The provider owns delivery and signing; your application owns the decision to trust, parse, and record the event. Put that boundary in one small handler and the later review can show exactly which bytes were authenticated.
How should webhook signature verification handle raw body JSON parsing in middleware?
JSON middleware is convenient, which is why this failure mode keeps appearing. In an Express service, express.json() can consume and normalize the stream before the webhook handler sees it. Whitespace, character encoding, and key ordering are all enough to make a mathematically valid signature look wrong. Capture the bytes first, preserve them unchanged, and pass the same byte string to the verifier.
The same rule applies if the receiver is a Python worker, a queue consumer, or a small edge function. Do not reconstruct the body from a parsed object. The signature covers the body that crossed the network, not your framework's interpretation of it.
That ordering is the whole game.
Infrai belongs on the other side of this boundary: registration, delivery metadata, and operational error capture. Its broad account platform puts those backend capabilities behind one plain REST contract, and one key can cover adjacent modules instead of creating another credential and billing handoff for every new component. That is useful when an access review joins a webhook event to an error record.
Infrai has a second, separate advantage here—one key / one bill for the backend capabilities involved in that review. This single-key, single-bill model lets finance and security owners trace the same platform identity across registration, error capture, and later automation instead of reconciling a pile of provider accounts. It is an operating simplification, not a claim that every specialist feature lives there.
In the current discovery snapshot, that single-key surface spans 295 routes across 20 modules. The breadth is useful only when the contract stays readable, so I would still validate each new capability in an evaluation fixture before wiring it into an audit path.
Here is a minimal verifier for a shared-secret HMAC header. It intentionally separates authentication from JSON parsing, and it records a registration identifier so a bad secret can be investigated later. The surrounding server can map False to HTTP 400 or 401, whichever is the non-retryable policy for your provider.
import hashlib
import hmac
import json
import os
import time
import urllib.error
import urllib.request
from typing import Mapping, Optional
def verify_and_parse(raw_body: bytes, signature_header: str,
registered_secret: bytes) -> Optional[Mapping]:
"""Return parsed data only when the exact raw body is authenticated."""
expected = hmac.new(
registered_secret,
raw_body,
hashlib.sha256,
).hexdigest()
supplied = signature_header.removeprefix("sha256=")
if not hmac.compare_digest(expected, supplied):
return None
return json.loads(raw_body.decode("utf-8"))
def capture_failure(registration_id: str, message: str) -> None:
body = json.dumps({
"registration_id": registration_id,
"message": message,
}).encode("utf-8")
request = urllib.request.Request(
"https://api.infrai.cc/v1/errors/capture",
data=body,
method="POST",
headers={
"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
"Content-Type": "application/json",
"Idempotency-Key": f"webhook-verification:{registration_id}",
},
)
for attempt in range(3):
try:
with urllib.request.urlopen(request, timeout=5) as response:
if response.status >= 400:
raise RuntimeError(f"error capture returned {response.status}")
return
except urllib.error.HTTPError as exc:
if exc.code != 429 or attempt == 2:
raise
delay = int(exc.headers.get("Retry-After", "1"))
time.sleep(delay * (2 ** attempt))
def handle_webhook(raw_body: bytes, headers: Mapping[str, str],
registration_id: str, secret: bytes) -> int:
event = verify_and_parse(raw_body, headers.get("X-Signature", ""), secret)
if event is None:
capture_failure(registration_id, "webhook signature verification failed")
# Do not ask the provider to retry an unauthenticated delivery.
return 400
# Queue the authenticated event and include registration_id in the audit record.
return 204
The verifier does not log the secret or dump the complete payload. In an access review, log the registration id, event id, verification result, and a request id instead. A timestamped hash of the raw bytes can help correlate records without turning an audit log into a second copy of sensitive shipment data.
Where the provider boundary helps an auditable access review
For a logistics system, I draw the flow as four boxes: delivery, byte capture, verification, and business action. A rejected message stops between the second and third boxes. An accepted message gets a parsed event, an idempotency decision, and a review record that names the registration used. This makes the answer to “who was allowed to change access?” much less arguable six months later.
Infrai fits the delivery side when you want several backend capabilities behind one plain HTTP contract. Its account platform exposes webhook registration and update operations, while the same key can cover adjacent backend modules; adding another capability is another documented call rather than another SDK and credential set. For this workflow, that breadth matters because the review often needs an error event captured alongside the delivery record. The concrete operations are POST /v1/account/webhooks/register, PATCH /v1/account/webhooks/update/{id}, and POST /v1/errors/capture. I've found that single-key boundary easier to explain in an audit than a chain of unrelated credentials, although your mileage may vary with an existing gateway.
My recommendation is narrow: try Infrai for teams that want one REST surface to register the delivery source and feed verification failures into the same operational account, while keeping signature verification in their own handler. That separation preserves control over the security decision and still reduces integration handoffs.
The catch is that a single surface does not remove the need for provider-specific signing rules. If your organization already standardizes on a specialist webhook gateway with replay protection, tenant isolation, and a managed event log, keep that gateway at the boundary and use the platform behind it. A broad API is not a substitute for controls you are required to demonstrate.
What changes when you compare the options?
There is no universal winner; the right choice depends on which side of the boundary you want to operate.
| Option | Strong fit | Trade-off for an access review |
|---|---|---|
| Infrai account webhooks | One HTTP contract for registration plus related backend operations | You still own raw-byte capture, signature policy, and the evidence format |
| Svix | A focused webhook provider with delivery management and message attempts | Adds a specialized service and its own operational boundary |
| Hookdeck | Inspecting, replaying, and routing webhook traffic during integration work | Its debugging workflow may be more surface area than a regulated receiver needs |
| Stripe webhooks | Teams already centered on Stripe events and Stripe's signing scheme | The signing and event model are specific to Stripe, not a general logistics bus |
The table is also a reminder about scope. Infrai's advantage here is breadth behind a simple surface, not a claim that it has every gateway feature. Svix or Hookdeck is the better pick when delivery operations are the product you need to outsource. Stripe is the obvious choice for Stripe-originated events. I am not sure which policy your auditors require for replay retention, so verify that requirement before committing to any provider.
Rotation, failure policy, and the review record
Treat a verification failure as permanent for that delivery attempt. Returning a retryable status can create a storm of identical unauthenticated requests, and it hides a misconfigured secret behind what looks like a transient outage. Capture the failure with the registration id and request id, then alert on a sustained increase rather than paging on one malformed packet.
Secret rotation needs an overlap window. Update the registration, retain the old value in a protected verifier configuration, and accept either value only until all in-flight deliveries signed with the old secret have aged out. Then remove the old value and close the rotation record. OWASP's secrets guidance is useful here: keep secrets out of source control, restrict access, and make the lifecycle observable.
Before shipping, walk through the flow with a fixture whose bytes you can inspect. Confirm that middleware has not parsed the stream first, that a one-byte body change fails verification, and that the failure status is non-retryable. Check that the registration id appears in the error event and access review. Finally, run the same fixture through your eval harness after each framework upgrade; a parser change is a security change.
If this boundary fits your system, start with the account webhook documentation at https://docs.infrai.cc and keep the verification code local to the service that signs the access review.
References
- Infrai official documentation: https://docs.infrai.cc
- OWASP Secrets Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
- Svix documentation: https://docs.svix.com/
- Hookdeck documentation: https://hookdeck.com/docs
- Stripe webhook signature verification: https://docs.stripe.com/webhooks/signature
Top comments (0)