The signed material is built by the client
A signing routine does not hash the JSON document you can read on screen. It hashes a string that the app constructs from the request, and the server rebuilds that string from the bytes it received before it verifies anything. Two documents that carry identical data produce different strings whenever field order, spacing or number formatting differs, which is why a reconstruction can be correct in every visible field and still be rejected.
The working rule is that you reproduce the exact byte sequence that entered the hash. That sequence is usually shorter and more strictly defined than the body on the wire.
What canonicalisation decides
Canonicalisation is the set of formatting rules a signing routine applies before hashing, and its purpose is to make both sides agree on one representation of the same data. Typical rules sort object keys, preserve declaration order, drop null fields, keep empty strings, encode a space as %20, and join values with a separator that cannot occur inside a value.
Two shapes are common in Android clients. In the first, the app hashes the serialised body directly. In the second, the app hashes the body, puts the digest in a header, and signs a string containing that digest together with the method, path and query. Identifying the shape changes what you have to reproduce, because in the second case the body you send and the material you sign are different objects.
Find the method that assembles the string
The method you want sits between the request model and the hash. With Retrofit and a converter library the serialised body exists as a byte array, and an OkHttp interceptor usually picks it up to compute the digest. Where the app signs by hand, one method builds the request and the signing input in the same block, and reading it answers every question about ordering and formatting at once.
Log that string instead of reasoning about the serialiser. A hook on the signing function that prints its argument gives you the canonical form for one real request, and that single sample settles more questions than a survey of library behaviour. Keep it beside your own reconstruction and diff the two byte by byte.
Formatting details that change the digest
Serialisers differ in ways that matter here, and each difference alters the input to the hash.
- Key order follows the serialiser. Some libraries walk fields in declaration order, others sort keys, and an app that builds a map may keep insertion order. JSON does not define an order, so the signing routine has to impose one.
- Escaping follows the library defaults. Gson escapes HTML-sensitive characters inside strings unless it is configured not to, so a value containing an ampersand leaves the client in a different form than the same value in a hand-written document.
- Numbers keep or lose their written form. A field written as 1.0 can serialise as 1, and a large identifier can pass through a floating point representation in a client that treats it as a double. Both change the byte sequence.
- Nulls and empty collections behave differently. One client omits a null field, another sends an explicit null, and a third sends an empty array. The server rebuilds the string from what arrived, so the difference surfaces as a signature failure on an otherwise correct payload.
- Form encoding carries its own rules. When the body is application/x-www-form-urlencoded, parameter order and the exact percent-encoding table become signed material, and a space encoded as + instead of %20 fails the check.
- Compression moves the hash boundary. If the client compresses the body, the signature covers either the compressed bytes or the plaintext, and mixing the two produces a mismatch that is invisible in a capture.
Values added after the signature is computed
Some apps sign the body and then append a field that the server excludes from verification. A client version, a channel name or a display locale can be added by an interceptor after the signing step, and the server strips it before rebuilding the string. If your client adds that field first, your signature covers one more value than the server expects.
The opposite arrangement also appears. A timestamp or nonce can be inserted into the body before signing so the server can reject stale requests, which means no captured body can be reused at all. Read the order of those two operations in the code before deciding which values are signed.
Prove the reconstruction
Build the canonical string in your own client, hash it with the recovered key and scheme, and compare the result with the digest the app produced. A byte-level diff separates a missing field from a formatting rule and from a wrong key, and each of those needs a different repair. Keep the printed canonical string as a fixture next to the captured one, because a serialiser change in your own stack can alter it silently months later.
Once the digest matches for one endpoint, the same routine usually covers the rest of the surface, and the remaining work moves to the values the app derives per request. Reproducing a signature outside the app is the service side of this problem, and turning an APK into a callable API is the delivery format that carries the finished client.
Related work
Reviewed 28 September 2026 · SReverse research desk