diff --git a/CMakeLists.txt b/CMakeLists.txt
index 16c7b892483fac58727f26afe70a7165d88ac4bb..d7f814e68c3fe5a3d7bca81a557d5805630361d6 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -250,6 +250,7 @@ target_sources(matrix_client
 	lib/structs/events/messages/emote.cpp
 	lib/structs/events/messages/file.cpp
 	lib/structs/events/messages/image.cpp
+	lib/structs/events/messages/location.cpp
 	lib/structs/events/messages/notice.cpp
 	lib/structs/events/messages/text.cpp
 	lib/structs/events/messages/unknown.cpp
diff --git a/include/mtx.hpp b/include/mtx.hpp
index 73736c81ae2184ad9c3f34f4b848a437c10ff793..9e5161a4f30dc2af9e50259deea38590eb40c5e4 100644
--- a/include/mtx.hpp
+++ b/include/mtx.hpp
@@ -28,6 +28,7 @@
 #include "mtx/events/messages/emote.hpp"
 #include "mtx/events/messages/file.hpp"
 #include "mtx/events/messages/image.hpp"
+#include "mtx/events/messages/location.hpp"
 #include "mtx/events/messages/notice.hpp"
 #include "mtx/events/messages/text.hpp"
 #include "mtx/events/messages/unknown.hpp"
diff --git a/include/mtx/events/collections.hpp b/include/mtx/events/collections.hpp
index 5867ed433f4dc72f2677ceb2c7fc08851fa3680e..3cfcc6e7e9a84804ef7798c1ce66ac90c99dd691 100644
--- a/include/mtx/events/collections.hpp
+++ b/include/mtx/events/collections.hpp
@@ -45,6 +45,7 @@
 #include "mtx/events/messages/emote.hpp"
 #include "mtx/events/messages/file.hpp"
 #include "mtx/events/messages/image.hpp"
+#include "mtx/events/messages/location.hpp"
 #include "mtx/events/messages/notice.hpp"
 #include "mtx/events/messages/text.hpp"
 #include "mtx/events/messages/unknown.hpp"
@@ -191,7 +192,7 @@ struct TimelineEvents
                         mtx::events::RoomEvent<mtx::events::msg::Emote>,
                         mtx::events::RoomEvent<mtx::events::msg::File>,
                         mtx::events::RoomEvent<mtx::events::msg::Image>,
-                        // TODO: events::RoomEvent<mtx::events::msg::Location>,
+                        mtx::events::RoomEvent<mtx::events::msg::Location>,
                         mtx::events::RoomEvent<mtx::events::msg::Notice>,
                         mtx::events::RoomEvent<mtx::events::msg::Text>,
                         mtx::events::RoomEvent<mtx::events::msg::Unknown>,
@@ -256,6 +257,9 @@ template<>
 constexpr inline EventType message_content_to_type<mtx::events::msg::Image> =
   EventType::RoomMessage;
 template<>
+constexpr inline EventType message_content_to_type<mtx::events::msg::Location> =
+  EventType::RoomMessage;
+template<>
 constexpr inline EventType message_content_to_type<mtx::events::msg::Notice> =
   EventType::RoomMessage;
 template<>
diff --git a/include/mtx/events/common.hpp b/include/mtx/events/common.hpp
index 9156840ad908c12f4d50abfc21ab917b677cb0b0..f958630add9d30a7e669f62dd7e6ade68e2aff20 100644
--- a/include/mtx/events/common.hpp
+++ b/include/mtx/events/common.hpp
@@ -142,14 +142,12 @@ struct LocationInfo
     ThumbnailInfo thumbnail_info;
     //! Encryption members. If present, they replace thumbnail_url.
     std::optional<crypto::EncryptedFile> thumbnail_file;
-    //! experimental blurhash, see MSC2448
-    std::string blurhash;
 
     //! Deserialization method needed by @p nlohmann::json.
-    friend void from_json(const nlohmann::json &obj, ThumbnailInfo &info);
+    friend void from_json(const nlohmann::json &obj, LocationInfo &info);
 
     //! Serialization method needed by @p nlohmann::json.
-    friend void to_json(nlohmann::json &obj, const ThumbnailInfo &info);
+    friend void to_json(nlohmann::json &obj, const LocationInfo &info);
 };
 
 /// @brief Mentions metadata
diff --git a/include/mtxclient/http/client.hpp b/include/mtxclient/http/client.hpp
index c169ba78cf2682312b41f0e195ec02b0366a527e..3a8f21c5d6f9e7a2326c80e44455a823e33f4143 100644
--- a/include/mtxclient/http/client.hpp
+++ b/include/mtxclient/http/client.hpp
@@ -908,6 +908,7 @@ MTXCLIENT_SEND_ROOM_MESSAGE_FWD(mtx::events::msg::Audio)
 MTXCLIENT_SEND_ROOM_MESSAGE_FWD(mtx::events::msg::Emote)
 MTXCLIENT_SEND_ROOM_MESSAGE_FWD(mtx::events::msg::File)
 MTXCLIENT_SEND_ROOM_MESSAGE_FWD(mtx::events::msg::Image)
+MTXCLIENT_SEND_ROOM_MESSAGE_FWD(mtx::events::msg::Location)
 MTXCLIENT_SEND_ROOM_MESSAGE_FWD(mtx::events::msg::Notice)
 MTXCLIENT_SEND_ROOM_MESSAGE_FWD(mtx::events::msg::Text)
 MTXCLIENT_SEND_ROOM_MESSAGE_FWD(mtx::events::msg::Video)
diff --git a/lib/http/client.cpp b/lib/http/client.cpp
index 8b849df5030021275951f8618ea8a70a37ef5c9f..b83aab1cf2de510002156ebca785e2a4df480ad9 100644
--- a/lib/http/client.cpp
+++ b/lib/http/client.cpp
@@ -1804,6 +1804,7 @@ MTXCLIENT_SEND_ROOM_MESSAGE(mtx::events::msg::Audio)
 MTXCLIENT_SEND_ROOM_MESSAGE(mtx::events::msg::Emote)
 MTXCLIENT_SEND_ROOM_MESSAGE(mtx::events::msg::File)
 MTXCLIENT_SEND_ROOM_MESSAGE(mtx::events::msg::Image)
+MTXCLIENT_SEND_ROOM_MESSAGE(mtx::events::msg::Location)
 MTXCLIENT_SEND_ROOM_MESSAGE(mtx::events::msg::Notice)
 MTXCLIENT_SEND_ROOM_MESSAGE(mtx::events::msg::Text)
 MTXCLIENT_SEND_ROOM_MESSAGE(mtx::events::msg::Unknown)
diff --git a/lib/structs/events/collections.cpp b/lib/structs/events/collections.cpp
index 96ccad2f836cbb49004ef6972f62e7b25c504e3b..0580b7112e38c2cc97d7a950275866b4bc7ffd7d 100644
--- a/lib/structs/events/collections.cpp
+++ b/lib/structs/events/collections.cpp
@@ -47,6 +47,7 @@ MTXCLIENT_INSTANTIATE_JSON_FUNCTIONS(events::RoomEvent, mtx::events::msg::Elemen
 MTXCLIENT_INSTANTIATE_JSON_FUNCTIONS(events::RoomEvent, mtx::events::msg::Emote)
 MTXCLIENT_INSTANTIATE_JSON_FUNCTIONS(events::RoomEvent, mtx::events::msg::File)
 MTXCLIENT_INSTANTIATE_JSON_FUNCTIONS(events::RoomEvent, mtx::events::msg::Image)
+MTXCLIENT_INSTANTIATE_JSON_FUNCTIONS(events::RoomEvent, mtx::events::msg::Location)
 MTXCLIENT_INSTANTIATE_JSON_FUNCTIONS(events::RoomEvent, mtx::events::msg::Notice)
 MTXCLIENT_INSTANTIATE_JSON_FUNCTIONS(events::RoomEvent, mtx::events::msg::Text)
 MTXCLIENT_INSTANTIATE_JSON_FUNCTIONS(events::RoomEvent, mtx::events::msg::Unknown)
@@ -322,8 +323,7 @@ from_json(const nlohmann::json &obj, TimelineEvents &e)
                 break;
             }
             case MsgType::Location: {
-                /* events::RoomEvent<events::msg::Location> location = e; */
-                /* container.emplace_back(location); */
+                e = events::RoomEvent<events::msg::Location>(obj);
                 break;
             }
             case MsgType::Notice: {
diff --git a/lib/structs/events/common.cpp b/lib/structs/events/common.cpp
index ef39c758156671e3213c06a837307a9a913d3836..6bf5e8ee467bf1872ae0addc6ae7d81f2c76bacc 100644
--- a/lib/structs/events/common.cpp
+++ b/lib/structs/events/common.cpp
@@ -169,6 +169,32 @@ to_json(json &obj, const VideoInfo &info)
         obj["xyz.amorgan.blurhash"] = info.blurhash;
 }
 
+void
+from_json(const json &obj, LocationInfo &info)
+{
+    if (obj.contains("thumbnail_url"))
+        info.thumbnail_url = obj.at("thumbnail_url").get<std::string>();
+
+    if (obj.contains("thumbnail_info"))
+        info.thumbnail_info = obj.at("thumbnail_info").get<ThumbnailInfo>();
+
+    if (obj.contains("thumbnail_file"))
+        info.thumbnail_file = obj.at("thumbnail_file").get<crypto::EncryptedFile>();
+}
+
+void
+to_json(json &obj, const LocationInfo &info)
+{
+    if (!info.thumbnail_url.empty()) {
+        obj["thumbnail_url"]  = info.thumbnail_url;
+        obj["thumbnail_info"] = info.thumbnail_info;
+    }
+    if (info.thumbnail_file) {
+        obj["thumbnail_file"] = info.thumbnail_file.value();
+        obj["thumbnail_info"] = info.thumbnail_info;
+    }
+}
+
 void
 from_json(const json &obj, Mentions &info)
 {
diff --git a/lib/structs/events/messages/location.cpp b/lib/structs/events/messages/location.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..d86a9f752fa5e49e1e8fcdaecec1091825deaa41
--- /dev/null
+++ b/lib/structs/events/messages/location.cpp
@@ -0,0 +1,40 @@
+#include <nlohmann/json.hpp>
+#include <string>
+
+#include "mtx/events/common.hpp"
+#include "mtx/events/messages/location.hpp"
+
+using json = nlohmann::json;
+
+namespace mtx {
+namespace events {
+namespace msg {
+
+void
+from_json(const json &obj, Location &content)
+{
+    content.body    = obj.at("body").get<std::string>();
+    content.msgtype = obj.at("msgtype").get<std::string>();
+    if (obj.find("geo_uri") != obj.end())
+        content.geo_uri = obj.at("geo_uri").get<std::string>();
+
+    if (obj.find("info") != obj.end())
+        content.info = obj.at("info").get<common::LocationInfo>();
+
+    content.relations = common::parse_relations(obj);
+}
+
+void
+to_json(json &obj, const Location &content)
+{
+    obj["msgtype"] = "m.location";
+    obj["body"]    = content.body;
+
+    obj["geo_uri"] = content.geo_uri;
+    obj["info"]    = content.info;
+    common::apply_relations(obj, content.relations);
+}
+
+} // namespace msg
+} // namespace events
+} // namespace mtx
diff --git a/lib/structs/responses/common.cpp b/lib/structs/responses/common.cpp
index 911efe172c0ecf9add6780557bab16d113e54d96..08e31743a96e66339fffb6867499f01fc01d6805 100644
--- a/lib/structs/responses/common.cpp
+++ b/lib/structs/responses/common.cpp
@@ -424,8 +424,7 @@ parse_timeline_events(const json &events,
                     break;
                 }
                 case MsgType::Location: {
-                    /* events::RoomEvent<events::msg::Location> location = e; */
-                    /* container.emplace_back(location); */
+                    container.emplace_back(events::RoomEvent<events::msg::Location>(e));
                     break;
                 }
                 case MsgType::Notice: {
diff --git a/meson.build b/meson.build
index 0805c6aa8459845c24f1f1929d96d34b4a87ade1..746640b04dd6fe49c74d214512fca8705ac920a2 100644
--- a/meson.build
+++ b/meson.build
@@ -86,6 +86,7 @@ src = [
 	'lib/structs/events/messages/emote.cpp',
 	'lib/structs/events/messages/file.cpp',
 	'lib/structs/events/messages/image.cpp',
+	'lib/structs/events/messages/location.cpp',
 	'lib/structs/events/messages/notice.cpp',
 	'lib/structs/events/messages/text.cpp',
 	'lib/structs/events/messages/unknown.cpp',
diff --git a/tests/messages.cpp b/tests/messages.cpp
index e90ef7b513aeb98c2c6c07d35704f59e16fd02e7..2c21fe18588a439e9e333a5e2df05fce1a2e1f2d 100644
--- a/tests/messages.cpp
+++ b/tests/messages.cpp
@@ -503,7 +503,53 @@ TEST(RoomEvents, ImageMessage)
               mtx::common::RelationType::InReplyTo);
 }
 
-TEST(RoomEvents, LocationMessage) {}
+TEST(RoomEvents, LocationMessage)
+{
+    json data                      = R"({
+            "content": {
+              "body": "Big Ben, London, UK",
+              "geo_uri": "geo:51.5008,0.1247",
+              "info": {
+                "thumbnail_info": {
+                  "h": 300,
+                  "mimetype": "image/jpeg",
+                  "size": 46144,
+                  "w": 300
+                },
+                "thumbnail_url": "mxc://example.org/FHyPlCeYUSFFxlgbQYZmoEoe"
+              },
+              "msgtype": "m.location",
+              "m.relates_to": {
+                    "m.in_reply_to": {
+                                    "event_id": "$6GKhAfJOcwNd69lgSizdcTob8z2pWQgBOZPrnsWMA1E"
+                                }
+                            }
+            },
+            "event_id": "$143273582443PhrSn:example.org",
+            "origin_server_ts": 1432735824653,
+            "room_id": "!jEsUZKDJdhlrceRyVU:example.org",
+            "sender": "@example:example.org",
+            "type": "m.room.message",
+            "unsigned": {
+              "age": 69168455
+            }
+          }
+        )"_json;
+    RoomEvent<msg::Location> event = data.get<RoomEvent<msg::Location>>();
+
+    EXPECT_EQ(event.type, EventType::RoomMessage);
+    EXPECT_EQ(event.event_id, "$143273582443PhrSn:example.org");
+    EXPECT_EQ(event.room_id, "!jEsUZKDJdhlrceRyVU:example.org");
+    EXPECT_EQ(event.sender, "@example:example.org");
+    EXPECT_EQ(event.origin_server_ts, 1432735824653);
+    EXPECT_EQ(event.unsigned_data.age, 69168455);
+    EXPECT_EQ(event.content.body, "Big Ben, London, UK");
+    EXPECT_EQ(event.content.msgtype, "m.location");
+    EXPECT_EQ(event.content.relations.relations.at(0).event_id,
+              "$6GKhAfJOcwNd69lgSizdcTob8z2pWQgBOZPrnsWMA1E");
+    EXPECT_EQ(event.content.relations.relations.at(0).rel_type,
+              mtx::common::RelationType::InReplyTo);
+}
 
 TEST(RoomEvents, NoticeMessage)
 {