Skip to main content

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

  1. The payload object (e.g., transfer or confirmation) is serialized into a canonical JSON string using stable stringify
  2. An HMAC-SHA256 digest is computed using the shared secret key
  3. 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