diff --git a/CMakeLists.txt b/CMakeLists.txt
index eef8faee71c88caa9e69b8b897d1c60c7545800b..1ee4f1158ae0c257f898c47e0fcc3a06901c1b8b 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -266,6 +266,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/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 9c231263aeadf677d5d6e8120bc94c63be5145f9..b4c2325e74b4c13d481623fa51bb1d3bc99930b1 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;
@@ -764,6 +765,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,
+                               Callback<mtx::responses::Users> callback,
+                               int limit = -1);
+
 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 cf3914f98ad5e08d83d7abee6b36f0826967d960..1dd806d92fdc769701c37ecb5b9c34b9c1b32052 100644
--- a/lib/http/client.cpp
+++ b/lib/http/client.cpp
@@ -1666,6 +1666,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,
+                              Callback<mtx::responses::Users> callback,
+                              int limit)
+{
+    nlohmann::json req = {{"search_term", search_term}};
+    if (limit >= 0)
+        req["limit"] = limit;
+    post<nlohmann::json, 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/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 8ffd6b56c55990c7e8d539dcb9c5ff00bfda67a4..1718e4cfd1957a4cd1117a3d5d08d50591cbd425 100644
--- a/meson.build
+++ b/meson.build
@@ -123,6 +123,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..d62a27f5800ef54d137b0a18de7c88c8fb1c053d 100644
--- a/tests/client_api.cpp
+++ b/tests/client_api.cpp
@@ -1845,6 +1845,76 @@ 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",
+                                 [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);
+                                 });
+    // synapse appears to return limit+1 results, this does not seem to
+    // be spec compliant. To make the tests work, we pass 0 to get 1 result
+    alice->search_user_directory(
+      "Bob",
+      [alice](const mtx::responses::Users &users, RequestErr err) {
+          check_error(err);
+          EXPECT_LE(users.results.size(), 1);
+          EXPECT_EQ(users.limited, true);
+      },
+      0);
+    alice->search_user_directory(
+      "Bob",
+      [alice](const mtx::responses::Users &users, RequestErr err) {
+          check_error(err);
+          EXPECT_EQ(users.results.size(), 2);
+          EXPECT_EQ(users.limited, false);
+      },
+      -1);
+
+    alice->close();
+    bob->close();
+    carl->close();
+}
+
 TEST(ClientAPI, Summary)
 {
     // Setup : Create a new (public) room with some settings
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);
+}