B01 /jobs writes 401 because Hawk auth omits the request-body hash
Device: Roborock Q10 S5+ (roborock.vacuum.ss07, B01 protocol)
Source: empirical — captured the official app's HTTPS traffic and reproduced its Hawk MAC byte-for-byte, June 2026
Summary
For B01 devices, the schedule/clean REST API lives at /user/devices/{duid}/jobs. GET works, but every write (POST/PUT/DELETE) returns 401 auth.err.invalid.token. This is not a token-scope problem — the same credentials read fine. The cause is the Hawk signature: the library signs the request path only and leaves the body-hash slot empty, which is correct for GET but wrong for writes. The app signs md5(compact_json(body)) in that slot for writes. Once the body hash is included, the identical credentials write successfully.
This was a long-standing red herring (it looks exactly like a read-only token) — worth documenting so others don't chase token scope.
Where it is
web_api.py's Hawk helper (the _get_hawk_authentication/request-signing path used by e.g. execute_scene) builds the normalized request string with the hash/ext (body) field empty for all methods. Hawk's normalized string includes a payload-hash position; for a write, that position must contain the base64 (or, here, the hex md5) digest of the exact request body. GET legitimately uses an empty payload hash, which is why reads have always worked and writes have always 401'd.
How to reproduce / how we confirmed it
- GET
/user/devices/{duid}/jobs with a normal logged-in credential → 200.
- PUT/POST/DELETE the same path → 401
auth.err.invalid.token, regardless of token freshness or re-login.
- Captured the app performing the same write and recomputed its Hawk
mac. It reproduces exactly when, and only when, the signed payload slot = md5(compact_json(body)) (compact = json.dumps(body, separators=(",", ":"))). Path-only and k=v form-encoded payloads both fail to reproduce the MAC.
The fix (two halves, both required)
- Sign the body. Put
md5 of the compact JSON (separators=(",", ":"), no spaces) into the Hawk payload-hash position for POST/PUT/DELETE. Keep it empty for GET/HEAD.
- Send the exact same bytes you signed. Don't hand the dict to the HTTP client's
json= serializer — it re-serializes with spaces, so the server's hash of the received body won't match your signature. Send data=<the same compact bytes> with Content-Type: application/json.
Because the signed string and the wire body are byte-identical, the server's body-hash check passes regardless of key ordering.
What this unblocks for B01
- Scheduling writes: create / enable / disable / delete cron jobs at
/jobs.
- One-time room-targeted cleans: a one-time job (
repeated: false, dated cron a minute or two out) with param.rooms: [...] is effectively "clean these rooms now" — the practical room-clean path for B01, which has no working MQTT segment-clean command exposed to third-party clients.
Job body schema (captured):
{ cron, repeated, enabled,
param: { mapId, rooms:[...], roomCount, cleanMode, cleanRoute, fanLevel, waterLevel, cleanCount } }
(One gotcha: the REST fanLevel scale differs from the MQTT fan enum at the top tier — the max tier is contiguous on REST, not the MQTT enum's value.)
Happy to open a PR implementing the body-signing path (GET unchanged; POST/PUT/DELETE sign + send compact body) and a small test that reproduces the app's MAC.
B01
/jobswrites 401 because Hawk auth omits the request-body hashDevice: Roborock Q10 S5+ (
roborock.vacuum.ss07, B01 protocol)Source: empirical — captured the official app's HTTPS traffic and reproduced its Hawk MAC byte-for-byte, June 2026
Summary
For B01 devices, the schedule/clean REST API lives at
/user/devices/{duid}/jobs. GET works, but every write (POST/PUT/DELETE) returns401 auth.err.invalid.token. This is not a token-scope problem — the same credentials read fine. The cause is the Hawk signature: the library signs the request path only and leaves the body-hash slot empty, which is correct for GET but wrong for writes. The app signsmd5(compact_json(body))in that slot for writes. Once the body hash is included, the identical credentials write successfully.This was a long-standing red herring (it looks exactly like a read-only token) — worth documenting so others don't chase token scope.
Where it is
web_api.py's Hawk helper (the_get_hawk_authentication/request-signing path used by e.g.execute_scene) builds the normalized request string with thehash/ext(body) field empty for all methods. Hawk's normalized string includes a payload-hash position; for a write, that position must contain the base64 (or, here, the hexmd5) digest of the exact request body. GET legitimately uses an empty payload hash, which is why reads have always worked and writes have always 401'd.How to reproduce / how we confirmed it
/user/devices/{duid}/jobswith a normal logged-in credential → 200.auth.err.invalid.token, regardless of token freshness or re-login.mac. It reproduces exactly when, and only when, the signed payload slot =md5(compact_json(body))(compact =json.dumps(body, separators=(",", ":"))). Path-only andk=vform-encoded payloads both fail to reproduce the MAC.The fix (two halves, both required)
md5of the compact JSON (separators=(",", ":"), no spaces) into the Hawk payload-hash position for POST/PUT/DELETE. Keep it empty for GET/HEAD.json=serializer — it re-serializes with spaces, so the server's hash of the received body won't match your signature. Senddata=<the same compact bytes>withContent-Type: application/json.Because the signed string and the wire body are byte-identical, the server's body-hash check passes regardless of key ordering.
What this unblocks for B01
/jobs.repeated: false, dated cron a minute or two out) withparam.rooms: [...]is effectively "clean these rooms now" — the practical room-clean path for B01, which has no working MQTT segment-clean command exposed to third-party clients.Job body schema (captured):
(One gotcha: the REST
fanLevelscale differs from the MQTT fan enum at the top tier — the max tier is contiguous on REST, not the MQTT enum's value.)Happy to open a PR implementing the body-signing path (GET unchanged; POST/PUT/DELETE sign + send compact body) and a small test that reproduces the app's MAC.