{"record":{"id":"684434990ff8a772","repo":"we-promise/sure","slug":"unauthorized-684434","errorCode":"unauthorized","errorMessage":"Invalid API token","messagePattern":"Invalid API token","errorType":"exception","errorClass":"Provider::Mercury::MercuryError","httpStatus":401,"severity":"error","filePath":"app/models/provider/mercury.rb","lineNumber":120,"sourceCode":"    def auth_headers\n      {\n        \"Authorization\" => \"Bearer #{token}\",\n        \"Content-Type\" => \"application/json\",\n        \"Accept\" => \"application/json\"\n      }\n    end\n\n    def handle_response(response)\n      case response.code\n      when 200\n        JSON.parse(response.body, symbolize_names: true)\n      when 400\n        Rails.logger.error \"Mercury API: Bad request - #{response.body}\"\n        raise MercuryError.new(\"Bad request to Mercury API: #{response.body}\", :bad_request)\n      when 401\n        # Parse the error response for more specific messages\n        error_message = parse_error_message(response.body)\n        raise MercuryError.new(error_message, :unauthorized)\n      when 403\n        raise MercuryError.new(\"Access forbidden - check your API token permissions\", :access_forbidden)\n      when 404\n        raise MercuryError.new(\"Resource not found\", :not_found)\n      when 429\n        raise MercuryError.new(\"Rate limit exceeded. Please try again later.\", :rate_limited)\n      else\n        Rails.logger.error \"Mercury API: Unexpected response - Code: #{response.code}, Body: #{response.body}\"\n        raise MercuryError.new(\"Failed to fetch data: #{response.code} #{response.message} - #{response.body}\", :fetch_failed)\n      end\n    end\n\n    def parse_error_message(body)\n      parsed = JSON.parse(body, symbolize_names: true)\n      errors = parsed[:errors] || {}\n\n      case errors[:errorCode]\n      when \"ipNotWhitelisted\"","sourceCodeStart":102,"sourceCodeEnd":138,"githubUrl":"https://github.com/we-promise/sure/blob/e69894adb92547273377398c15f45c979cd9416a/app/models/provider/mercury.rb#L102-L138","documentation":"Raised by Provider::Mercury#handle_response when the Mercury banking API returns HTTP 401. The body is passed to parse_error_message, and \"Invalid API token\" is the fallback when the body is not parseable JSON or carries no recognized errorCode/message — i.e. Mercury rejected the Bearer token without a specific explanation. The token sent is the one given to Provider::Mercury.new(token) via the Authorization header.","triggerScenarios":"Any HTTParty GET (get_accounts, get_account, get_account_transactions) with a token that is empty/nil ('Bearer ' header), revoked or deleted in the Mercury dashboard, copied with whitespace or a wrong value, or a token whose 'secret-token:' prefix/format is wrong but in a way Mercury reports without a structured errorCode. Also hit when base_url points at a different Mercury environment that does not know the token.","commonSituations":"Token rotated in the Mercury dashboard but the app still stores the old one in the provider settings/credentials; ENV var not loaded in a new deploy so token is nil; token pasted with quotes or trailing newline; test suite hitting production API with a fixture token; Mercury token created for a different organization.","solutions":["Verify the token stored for the Mercury item is present and identical to the active token in the Mercury dashboard (Settings > API tokens), then re-save it.","Check the token format: Mercury expects the full token string; parse_error_message's sibling case warns the 'secret-token:' prefix must be kept, so do not strip or truncate it.","Confirm the token was not revoked — if it was, create a new token and update the stored credential.","If the token is IP-whitelisted, confirm the outbound IP matches; otherwise you would normally see the ipNotWhitelisted message instead, which also arrives as 401.","Reproduce with curl: curl -H 'Authorization: Bearer <token>' https://api.mercury.com/api/v1/accounts to see the raw 401 body."],"exampleFix":"# before\ntoken = ENV[\"MERCURY_TOKEN\"]&.strip # may be nil, 401 at runtime\nprovider = Provider::Mercury.new(token)\nprovider.get_accounts\n\n# after\nraise ArgumentError, \"MERCURY_TOKEN is blank\" if ENV[\"MERCURY_TOKEN\"].blank?\ntoken = ENV[\"MERCURY_TOKEN\"].strip\nprovider = Provider::Mercury.new(token)\nbegin\n  provider.get_accounts\nrescue Provider::Mercury::MercuryError => e\n  raise if e.error_type != :unauthorized\n  # flag the stored token for re-entry instead of retrying\n  Item.mark_token_invalid!(e)\nend","handlingStrategy":"try-catch","validationCode":"# before constructing the client\ntoken = item.settings[\"token\"].to_s.strip\nif token.blank?\n  raise ArgumentError, \"Mercury token missing for item #{item.id}\"\nend\nprovider = Provider::Mercury.new(token)","typeGuard":null,"tryCatchPattern":"begin\n  provider.get_accounts\nrescue Provider::Mercury::MercuryError => e\n  if e.error_type == :unauthorized\n    # mark credential for re-entry; never auto-retry with the same token\n    item.credentials.mark_invalid!\n  else\n    raise\n  end\nend","preventionTips":["Presence/format-check the token before building the provider instead of learning about a blank token from a 401.","Store the token exactly as Mercury issues it, including the 'secret-token:' prefix shown in the sibling error branch.","When rotating tokens in the Mercury dashboard, update the stored credential in the same change that removes the old one."],"tags":["mercury","http-401","authentication","api-token","banking-api"],"backgroundTag":"invalid-api-key","analyzedSha":"e69894adb92547273377398c15f45c979cd9416a","analyzedAt":"2026-08-21T18:22:41.165Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}