Summary
Webhook events forwarded by polar listen fail Standard Webhooks signature verification at the receiving endpoint whenever the payload contains a whole-number float (e.g. "consumed_units": 1200.0). The signature is computed server-side over the original payload string, but the CLI forwards a re-serialized body, and the JSON round-trip is not byte-preserving — so the HMAC can never match.
In practice this makes polar listen unusable for customer.state_changed: those payloads contain meter values (active_meters[].consumed_units, balance, …) that are serialized as floats and are very often whole numbers (0.0, 25000.0, …), so effectively every such event is rejected by any receiver that verifies signatures:
>> 'customer.state_changed' >> 401 Unauthorized
>> 'customer.state_changed' >> 401 Unauthorized
Events whose payloads happen to contain no whole-number floats round-trip byte-identically and verify fine, which makes this failure look intermittent/mysterious.
Root cause
Server side (server/polar/cli/endpoints.py, transform_webhook_events in polarsource/polar):
# Sign the payload
wh = StandardWebhook(b64secret)
signature = wh.sign(webhook_event_id, ts, webhook_payload) # <-- signs the ORIGINAL payload string
# Add signed headers to the event
event["headers"] = { ... "webhook-signature": signature }
event["payload"]["payload"] = json.loads(webhook_payload) # <-- but ships the PARSED object
yield json.dumps(event)
CLI side (src/commands/listen.ts):
forward(forwardUrl, {
method: "POST",
headers: webhookEvent.right.headers,
body: JSON.stringify(webhookEvent.right.payload.payload) // <-- re-serializes the parsed object
})
So the bytes that are signed are the original Python/pydantic serialization, but the bytes delivered to the local endpoint are JSON.stringify(JSON.parse(original)). Key order and whitespace survive that round-trip, but number formatting does not: JavaScript serializes 1200.0 as 1200. One changed byte → signature verification fails → the local endpoint (correctly) returns 401.
Minimal repro
const orig = '{"consumed_units":1200.0}';
console.log(JSON.stringify(JSON.parse(orig)) === orig); // false: '{"consumed_units":1200}'
Full HMAC demonstration (secret = the session secret printed by polar listen):
import base64, hmac, hashlib
def sign(secret: str, msg_id: str, ts: str, body: str) -> str:
content = f"{msg_id}.{ts}.{body}".encode()
return base64.b64encode(hmac.new(secret.encode(), content, hashlib.sha256).digest()).decode()
original = '{"type":"customer.state_changed","data":{"consumed_units":1200.0}}'
roundtripped = '{"type":"customer.state_changed","data":{"consumed_units":1200}}' # what the CLI forwards
sig = sign("<listen session secret>", "we_1", "1753200000", original) # what the server puts in webhook-signature
print(sig == sign("<listen session secret>", "we_1", "1753200000", roundtripped)) # False -> 401
Verified against a real receiver: a request signed over the original bytes verifies fine; the same payload after the CLI's round-trip fails.
Suggested fix
Keep the signed bytes and the delivered bytes identical:
- Preferred: have the server put the raw payload string in the SSE event (don't
json.loads it), and have the CLI forward that string verbatim as the request body; or
- Alternatively: have the server sign the exact serialization that the CLI will forward (i.e. sign after the same canonical re-serialization).
Environment
- CLI v1.3.7 (latest at time of writing), macOS arm64
- Receiver verifies per Standard Webhooks (HMAC-SHA256 over
id.timestamp.body) using the session secret printed by polar listen
Production webhook deliveries are unaffected (original body is POSTed directly); this only breaks local development via polar listen.
Summary
Webhook events forwarded by
polar listenfail Standard Webhooks signature verification at the receiving endpoint whenever the payload contains a whole-number float (e.g."consumed_units": 1200.0). The signature is computed server-side over the original payload string, but the CLI forwards a re-serialized body, and the JSON round-trip is not byte-preserving — so the HMAC can never match.In practice this makes
polar listenunusable forcustomer.state_changed: those payloads contain meter values (active_meters[].consumed_units,balance, …) that are serialized as floats and are very often whole numbers (0.0,25000.0, …), so effectively every such event is rejected by any receiver that verifies signatures:Events whose payloads happen to contain no whole-number floats round-trip byte-identically and verify fine, which makes this failure look intermittent/mysterious.
Root cause
Server side (
server/polar/cli/endpoints.py,transform_webhook_eventsin polarsource/polar):CLI side (
src/commands/listen.ts):So the bytes that are signed are the original Python/pydantic serialization, but the bytes delivered to the local endpoint are
JSON.stringify(JSON.parse(original)). Key order and whitespace survive that round-trip, but number formatting does not: JavaScript serializes1200.0as1200. One changed byte → signature verification fails → the local endpoint (correctly) returns 401.Minimal repro
Full HMAC demonstration (secret = the session secret printed by
polar listen):Verified against a real receiver: a request signed over the original bytes verifies fine; the same payload after the CLI's round-trip fails.
Suggested fix
Keep the signed bytes and the delivered bytes identical:
json.loadsit), and have the CLI forward that string verbatim as the request body; orEnvironment
id.timestamp.body) using the session secret printed bypolar listenProduction webhook deliveries are unaffected (original body is POSTed directly); this only breaks local development via
polar listen.