For requests without a body (
GET, DELETE), the signature is computed over
"{timestamp}.": the timestamp, a dot, and an empty string.
Minimum: check the API key
At the very least, reject any request whoseX-API-Key doesn’t equal your
stored key. This alone prevents anyone else from writing to your endpoints.
Recommended: verify the signature
The signature proves the request was produced by someone holding the key and that the body wasn’t modified in transit. Compute the HMAC over the raw request bytes (before JSON parsing) and compare with a constant-time check:Replay protection
Reject requests whoseX-Castro-Timestamp is more than a few minutes old. Note
the unit: Castro signs with Date.now(), so it’s milliseconds. A
seconds-based comparison makes every request look ~55,000 years in the future,
and the check silently passes everything.
The handshake challenge
When the user connects their site, Castro POSTs{ "challenge": "<hex>" } to
your /handshake. Respond with
challenge_response = HMAC-SHA256(challenge, api_key) (hex). This proves your
server holds the same key, completing the trust loop in both directions.
Note the formula: the challenge string on its own. No timestamp, no dot, no
body. That shape is only for request signatures.
The three mistakes everyone makes
Signing over re-serialized JSON
Signing over re-serialized JSON
The HMAC covers the exact bytes on the wire.
JSON.parse() followed by
JSON.stringify() can reorder keys and drop whitespace, so the signature
matches for some payloads and not others, which reads as an intermittent,
unreproducible bug.Capture the raw body and hash that.Comparing the timestamp in seconds
Comparing the timestamp in seconds
time() in PHP and time.time() in Python are seconds. Castro sends
milliseconds. Multiply before you compare. See above.Answering the handshake with the caller's key
Answering the handshake with the caller's key

