Skip to content

polar listen forwards a re-serialized body, breaking webhook signature verification for payloads with whole-number floats #26

Description

@ammar1x

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions