diff --git a/CMakeLists.txt b/CMakeLists.txt
index 044975b19b4fdcf313072b046341311cb0614f49..29c34c49bc688f705727d325dc295aa7fae2fe1a 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -265,6 +265,7 @@ target_sources(matrix_client
 	lib/structs/responses/register.cpp
 	lib/structs/responses/sync.cpp
 	lib/structs/responses/turn_server.cpp
+	lib/structs/responses/users.cpp
 	lib/structs/responses/version.cpp
 	lib/structs/responses/well-known.cpp
 	lib/structs/responses/public_rooms.cpp)
diff --git a/include/mtx/events_impl.hpp b/include/mtx/events_impl.hpp
index e8e3a6fe10fe51c18ddfff3f1ed81384886830e0..fca8e289b1d3c6fd8d8883c727b65af164b5ca36 100644
--- a/include/mtx/events_impl.hpp
+++ b/include/mtx/events_impl.hpp
@@ -10,12 +10,14 @@ namespace detail {
 
 template<typename, typename = void>
 struct can_edit : std::false_type
-{};
+{
+};
 
 template<typename Content>
 struct can_edit<Content, std::void_t<decltype(Content::relations)>>
   : std::is_same<decltype(Content::relations), mtx::common::Relations>
-{};
+{
+};
 }
 
 template<class Content>
diff --git a/include/mtx/requests.hpp b/include/mtx/requests.hpp
index cc19cd87f339dedc6b03f409a63e0102a63e45c0..20ebb783547afa214c6d0196da5daf75c99c08f5 100644
--- a/include/mtx/requests.hpp
+++ b/include/mtx/requests.hpp
@@ -439,5 +439,15 @@ struct SetPusher
 
     friend void to_json(nlohmann::json &obj, const SetPusher &req);
 };
+
+struct userDirectorySearch
+{
+    //! The maximum number of results to return. Defaults to 10.
+    int limit;
+    //! Required: The term to search for
+    std::string search_term;
+
+    friend void to_json(nlohmann::json &obj, const userDirectorySearch &data);
+};
 } // namespace requests
 } // namespace mtx
diff --git a/include/mtx/responses.hpp b/include/mtx/responses.hpp
index 7a672185e95a0693fa09b69d52dc8226c71213ad..238293bb01b09dc254728e6afd62309f57aaec6c 100644
--- a/include/mtx/responses.hpp
+++ b/include/mtx/responses.hpp
@@ -20,5 +20,6 @@
 #include "responses/register.hpp"
 #include "responses/sync.hpp"
 #include "responses/turn_server.hpp"
+#include "responses/users.hpp"
 #include "responses/version.hpp"
 #include "responses/well-known.hpp"
diff --git a/include/mtx/responses/users.hpp b/include/mtx/responses/users.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..da38ba778073c3cc6740648d3af64d770ffe75d4
--- /dev/null
+++ b/include/mtx/responses/users.hpp
@@ -0,0 +1,38 @@
+#pragma once
+
+/// @file
+/// @brief Response for the endpoint to search users
+
+#if __has_include(<nlohmann/json_fwd.hpp>)
+#include <nlohmann/json_fwd.hpp>
+#else
+#include <nlohmann/json.hpp>
+#endif
+
+namespace mtx {
+namespace responses {
+
+struct User
+{
+    //! The avatar url, as an MXC, if one exists.
+    std::string avatar_url;
+    //! The display name of the user, if one exists.
+    std::string display_name;
+    //! The user’s matrix user ID.
+    std::string user_id;
+
+    friend void from_json(const nlohmann::json &obj, User &res);
+};
+//! User directory search results.
+struct Users
+{
+    //! If the search was limited by the search limit.
+    bool limited;
+
+    //! A chunk of user events.
+    std::vector<User> results;
+
+    friend void from_json(const nlohmann::json &obj, Users &res);
+};
+}
+}
diff --git a/include/mtxclient/crypto/objects.hpp b/include/mtxclient/crypto/objects.hpp
index 2bb3ceca451b6f9d4ddca96de24e98f0969c7b0e..3babd189d30cee03a1a53e5b523e54609bb050bc 100644
--- a/include/mtxclient/crypto/objects.hpp
+++ b/include/mtxclient/crypto/objects.hpp
@@ -31,49 +31,49 @@ struct OlmDeleter
     void operator()(OlmAccount *ptr)
     {
         olm_clear_account(ptr);
-        delete[] (reinterpret_cast<uint8_t *>(ptr));
+        delete[](reinterpret_cast<uint8_t *>(ptr));
     }
     void operator()(OlmUtility *ptr)
     {
         olm_clear_utility(ptr);
-        delete[] (reinterpret_cast<uint8_t *>(ptr));
+        delete[](reinterpret_cast<uint8_t *>(ptr));
     }
 
     void operator()(OlmPkDecryption *ptr)
     {
         olm_clear_pk_decryption(ptr);
-        delete[] (reinterpret_cast<uint8_t *>(ptr));
+        delete[](reinterpret_cast<uint8_t *>(ptr));
     }
     void operator()(OlmPkEncryption *ptr)
     {
         olm_clear_pk_encryption(ptr);
-        delete[] (reinterpret_cast<uint8_t *>(ptr));
+        delete[](reinterpret_cast<uint8_t *>(ptr));
     }
     void operator()(OlmPkSigning *ptr)
     {
         olm_clear_pk_signing(ptr);
-        delete[] (reinterpret_cast<uint8_t *>(ptr));
+        delete[](reinterpret_cast<uint8_t *>(ptr));
     }
 
     void operator()(OlmSession *ptr)
     {
         olm_clear_session(ptr);
-        delete[] (reinterpret_cast<uint8_t *>(ptr));
+        delete[](reinterpret_cast<uint8_t *>(ptr));
     }
     void operator()(OlmOutboundGroupSession *ptr)
     {
         olm_clear_outbound_group_session(ptr);
-        delete[] (reinterpret_cast<uint8_t *>(ptr));
+        delete[](reinterpret_cast<uint8_t *>(ptr));
     }
     void operator()(OlmInboundGroupSession *ptr)
     {
         olm_clear_inbound_group_session(ptr);
-        delete[] (reinterpret_cast<uint8_t *>(ptr));
+        delete[](reinterpret_cast<uint8_t *>(ptr));
     }
     void operator()(OlmSAS *ptr)
     {
         olm_clear_sas(ptr);
-        delete[] (reinterpret_cast<uint8_t *>(ptr));
+        delete[](reinterpret_cast<uint8_t *>(ptr));
     }
 };
 
diff --git a/include/mtxclient/http/client.hpp b/include/mtxclient/http/client.hpp
index 8e0907d530307cb0aa7778fe6fd268eff4f59bb5..fed026a70fbfcf88c1047625a03f8182a4aab3fe 100644
--- a/include/mtxclient/http/client.hpp
+++ b/include/mtxclient/http/client.hpp
@@ -83,6 +83,7 @@ struct Sync;
 struct StateEvents;
 struct TurnServer;
 struct UploadKeys;
+struct Users;
 struct Version;
 struct Versions;
 struct WellKnown;
@@ -761,6 +762,11 @@ public:
     //! Sets, updates, or deletes a pusher
     void set_pusher(const mtx::requests::SetPusher &req, Callback<mtx::responses::Empty> cb);
 
+    //! Searches the user directory
+    void search_user_directory(const std::string &search_term,
+                               int limit,
+                               Callback<mtx::responses::Users> callback);
+
 private:
     template<class Request, class Response>
     void post(const std::string &endpoint,
diff --git a/lib/http/client.cpp b/lib/http/client.cpp
index f9847d55385c8cdc5d4dbcff4b24e01c795eddb4..4fc4b258186fe379d5ebec0c9750743e5ddf6a29 100644
--- a/lib/http/client.cpp
+++ b/lib/http/client.cpp
@@ -1660,6 +1660,18 @@ Client::set_pusher(const mtx::requests::SetPusher &req, Callback<mtx::responses:
       "/client/v3/pushers/set", req, std::move(cb));
 }
 
+void
+Client::search_user_directory(const std::string &search_term,
+                              int limit,
+                              Callback<mtx::responses::Users> callback)
+{
+    mtx::requests::userDirectorySearch req;
+    req.search_term = search_term;
+    req.limit       = limit;
+    post<mtx::requests::userDirectorySearch, mtx::responses::Users>(
+      "/client/v3/user_directory/search", req, std::move(callback));
+}
+
 // Template instantiations for the various send functions
 
 #define MTXCLIENT_SEND_STATE_EVENT(Content)                                                        \
diff --git a/lib/structs/requests.cpp b/lib/structs/requests.cpp
index badc7709f0aaf1fa8877e51c23488c3af469d275..c7133c3c31023e5338fe532c4a51c7d4864340c0 100644
--- a/lib/structs/requests.cpp
+++ b/lib/structs/requests.cpp
@@ -287,5 +287,12 @@ to_json(json &obj, const SetPusher &req)
     obj["append"] = req.append;
 }
 
+void
+to_json(json &obj, const userDirectorySearch &request)
+{
+    obj["limit"]       = request.limit;
+    obj["search_term"] = request.search_term;
+}
+
 } // namespace requests
 } // namespace mtx
diff --git a/lib/structs/responses/users.cpp b/lib/structs/responses/users.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..fa64aeb13679e226353528914adde812c13cd7e4
--- /dev/null
+++ b/lib/structs/responses/users.cpp
@@ -0,0 +1,29 @@
+#include "mtx/responses/users.hpp"
+
+#include <nlohmann/json.hpp>
+
+using json = nlohmann::json;
+
+namespace mtx {
+namespace responses {
+
+void
+from_json(const json &obj, User &user)
+{
+    if (obj.count("avatar_url") != 0 && !obj.at("avatar_url").is_null())
+        user.avatar_url = obj.at("avatar_url").get<std::string>();
+
+    if (obj.count("display_name") != 0 && !obj.at("display_name").is_null())
+        user.display_name = obj.at("display_name").get<std::string>();
+
+    user.user_id = obj.at("user_id").get<std::string>();
+}
+
+void
+from_json(const json &obj, Users &users)
+{
+    users.limited = obj.at("limited").get<bool>();
+    users.results = obj.at("results").get<std::vector<User>>();
+}
+}
+}
diff --git a/meson.build b/meson.build
index 29feb3008b271fe7028fc278e047f9a116b9475b..4adcd29fb8dd247a8f090dd635f67f24d12967eb 100644
--- a/meson.build
+++ b/meson.build
@@ -122,6 +122,7 @@ src = [
 	'lib/structs/responses/register.cpp',
 	'lib/structs/responses/sync.cpp',
 	'lib/structs/responses/turn_server.cpp',
+	'lib/structs/responses/users.cpp',
 	'lib/structs/responses/version.cpp',
 	'lib/structs/responses/well-known.cpp',
 	'lib/structs/secret_storage.cpp',
diff --git a/tests/client_api.cpp b/tests/client_api.cpp
index c0e43ad0a3655c39a9b71e8967ed8db69b8555b4..6c2a94f7f9d99850db958e38f23cb0d9219dbe16 100644
--- a/tests/client_api.cpp
+++ b/tests/client_api.cpp
@@ -1845,6 +1845,70 @@ TEST(ClientAPI, PublicRooms)
     bob->close();
 }
 
+TEST(ClientAPI, Users)
+{
+    auto alice = make_test_client();
+    auto bob   = make_test_client();
+    auto carl  = make_test_client();
+
+    alice->login("alice", "secret", [alice](const mtx::responses::Login &, RequestErr err) {
+        check_error(err);
+    });
+
+    bob->login("bob", "secret", [bob](const mtx::responses::Login &, RequestErr err) {
+        check_error(err);
+        bob->set_displayname("Bob", [](RequestErr err) { check_error(err); });
+    });
+
+    carl->login("carl", "secret", [carl](const mtx::responses::Login &, RequestErr err) {
+        check_error(err);
+        carl->set_displayname("Bobby", [](RequestErr err) { check_error(err); });
+    });
+
+    while (alice->access_token().empty() || bob->access_token().empty() ||
+           carl->access_token().empty())
+        sleep();
+
+    mtx::requests::CreateRoom req;
+    req.name   = "Name";
+    req.topic  = "Topic";
+    req.invite = {"@bob:" + server_name(), "@carl:" + server_name()};
+    alice->create_room(req, [bob, carl](const mtx::responses::CreateRoom &res, RequestErr err) {
+        check_error(err);
+        auto room_id = res.room_id.to_string();
+
+        bob->join_room(room_id,
+                       [](const mtx::responses::RoomId &, RequestErr err) { check_error(err); });
+
+        carl->join_room(room_id,
+                        [](const mtx::responses::RoomId &, RequestErr err) { check_error(err); });
+    });
+
+    alice->search_user_directory(
+      "carl", 10, [alice](const mtx::responses::Users &users, RequestErr err) {
+          check_error(err);
+          EXPECT_EQ(users.results.size(), 1);
+          EXPECT_EQ(users.results[0].display_name, "Bobby");
+          EXPECT_EQ(users.limited, false);
+      });
+    alice->search_user_directory(
+      "Bob", 0, [alice](const mtx::responses::Users &users, RequestErr err) {
+          check_error(err);
+          EXPECT_EQ(users.results.size(), 1);
+          EXPECT_EQ(users.limited, true);
+      });
+    alice->search_user_directory(
+      "Bob", 10, [alice](const mtx::responses::Users &users, RequestErr err) {
+          check_error(err);
+          EXPECT_EQ(users.results.size(), 2);
+          EXPECT_EQ(users.limited, false);
+      });
+
+    alice->close();
+    bob->close();
+    carl->close();
+}
+
 TEST(ClientAPI, Summary)
 {
     // Setup : Create a new (public) room with some settings
diff --git a/tests/requests.cpp b/tests/requests.cpp
index 94fecf2bba3aa3eb5a02844afecbf8a1095e4843..17a71ea9dec6a889f81871551c615d64ded665b2 100644
--- a/tests/requests.cpp
+++ b/tests/requests.cpp
@@ -354,3 +354,16 @@ TEST(Requests, PublicRooms)
 
     EXPECT_THROW(json req = b3, std::invalid_argument);
 }
+
+TEST(Requests, userDirectorySearch)
+{
+    userDirectorySearch search;
+    search.search_term = "foo";
+    search.limit       = 10;
+
+    json j = search;
+    EXPECT_EQ(j, R"({
+    "limit": 10,
+    "search_term": "foo"
+  })"_json);
+}
diff --git a/tests/responses.cpp b/tests/responses.cpp
index d981fcd0578b2474a2580348ea2d9ce110bcae2c..db3feae51e6e75f7f1a7ec0080d6a944368b97e9 100644
--- a/tests/responses.cpp
+++ b/tests/responses.cpp
@@ -1608,3 +1608,23 @@ TEST(Responses, PublicRooms)
     EXPECT_EQ(publicRooms.prev_batch, "p1902");
     EXPECT_EQ(publicRooms.total_room_count_estimate, 115);
 }
+
+TEST(Response, Users)
+{
+    json data   = R"({
+          "limited": false,
+          "results": [
+            {
+              "avatar_url": "mxc://bar.com/foo",
+              "display_name": "Foo",
+              "user_id": "@foo:bar.com"
+            }
+          ]
+        })"_json;
+    Users users = data.get<Users>();
+    EXPECT_EQ(users.results.size(), 1);
+    EXPECT_EQ(users.results[0].avatar_url, "mxc://bar.com/foo");
+    EXPECT_EQ(users.results[0].display_name, "Foo");
+    EXPECT_EQ(users.results[0].user_id, "@foo:bar.com");
+    EXPECT_EQ(users.limited, false);
+}