Signature Verification
All communication between Bankroll and your platform is signed with HMAC-SHA256 using a shared secret key. You must verify signatures on incoming webhooks and sign your outgoing confirmation callbacks.
How Signing Works
- The payload object (e.g.,
transfer or confirmation) is serialized into a canonical JSON string using stable stringify
- An HMAC-SHA256 digest is computed using the shared secret key
- The digest is Base64-encoded to produce the signature string
Stable Stringify
To ensure both sides produce identical signatures regardless of JSON key ordering, Bankroll uses a deterministic serialization called stable stringify:
- Object keys are sorted alphabetically
- No whitespace between tokens
- Standard JSON encoding for all value types
For example, given:
{ "name": "jane", "amount": 500, "id": 1 }
Stable stringify produces:
{"amount":500,"id":1,"name":"jane"}
Do not use your language’s default JSON.stringify or json.dumps for signature computation. Key ordering is not guaranteed and will produce invalid signatures. You must implement stable stringify or use one of the reference implementations below.
Reference Implementations
def stable_stringify(value)
case value
when nil, Numeric, String, TrueClass, FalseClass
value.to_json
when Array
"[#{value.map { |entry| stable_stringify(entry) }.join(",")}]"
when Hash
entries = value.stringify_keys.sort_by { |key, _| key }
"{#{entries.map { |key, entry| "#{key.to_json}:#{stable_stringify(entry)}" }.join(",")}}"
else
stable_stringify(value.as_json)
end
end
def sign(payload, secret_key)
message = stable_stringify(payload)
Base64.strict_encode64(OpenSSL::HMAC.digest("SHA256", secret_key, message))
end
def verify(payload, signature, secret_key)
expected = sign(payload, secret_key)
return false unless expected.bytesize == signature.bytesize
ActiveSupport::SecurityUtils.secure_compare(signature, expected)
end
Verifying Incoming Webhooks
When you receive a transfer.created webhook, verify the signature against the transfer object:
app.post("/webhooks/bankroll/transfers", (req, res) => {
const { transfer, signature } = req.body;
if (!verify(transfer, signature, process.env.BANKROLL_SECRET_KEY)) {
return res.sendStatus(401);
}
// Signature valid — process the transfer
});
Signing Outgoing Callbacks
When sending a confirmation callback, sign the confirmation object and include the signature:
const confirmation = {
partnerTransferId: 42,
status: "accepted",
metadata: { userId: 123 },
};
const payload = {
confirmation,
signature: sign(confirmation, process.env.BANKROLL_SECRET_KEY),
};
await fetch(`${BANKROLL_API_URL}/api/webhooks/partner-transfer-confirmations`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
Security Notes
- Use timing-safe comparison when verifying signatures to prevent timing attacks
- Never log or expose the shared secret key
- Reject requests immediately if signature verification fails — do not process the payload