diff --git a/database/2025_04_21_005231_kaomoji_table.php b/database/2025_04_21_005231_kaomoji_table.php
new file mode 100644
index 00000000..97aad775
--- /dev/null
+++ b/database/2025_04_21_005231_kaomoji_table.php
@@ -0,0 +1,15 @@
+<?php
+use Index\Db\DbConnection;
+use Index\Db\Migration\DbMigration;
+
+final class KaomojiTable_20250421_005231 implements DbMigration {
+    public function migrate(DbConnection $conn): void {
+        $conn->execute(<<<SQL
+            CREATE TABLE msz_kaomoji (
+                kao_id     INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+                kao_string VARCHAR(255)     NOT NULL COLLATE 'utf8mb4_bin',
+                PRIMARY KEY (kao_id)
+            ) COLLATE='utf8mb4_bin' ENGINE=InnoDB
+        SQL);
+    }
+}
diff --git a/src/Kaomoji/KaomojiApiRoutes.php b/src/Kaomoji/KaomojiApiRoutes.php
new file mode 100644
index 00000000..1b4a60fc
--- /dev/null
+++ b/src/Kaomoji/KaomojiApiRoutes.php
@@ -0,0 +1,76 @@
+<?php
+namespace Misuzu\Kaomoji;
+
+use RuntimeException;
+use Index\XArray;
+use Index\Http\{HttpRequest,HttpResponseBuilder};
+use Index\Http\Routing\AccessControl\AccessControl;
+use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
+use Misuzu\FieldTransformer;
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
+
+#[HandlerRoles('main')]
+final class KaomojiApiRoutes implements RouteHandler {
+    use RouteHandlerCommon;
+
+    public function __construct(
+        private KaomojiContext $kaomojiCtx
+    ) {}
+
+    /** @return FieldTransformer<KaomojiInfo> */
+    private function createKaomojiTransformer(): FieldTransformer {
+        return new FieldTransformer([
+            'id' => [
+                'transform' => fn($kaomoji) => $kaomoji->id,
+            ],
+            'string' => [
+                'default' => true,
+                'transform' => fn($kaomoji) => $kaomoji->string,
+            ],
+        ]);
+    }
+
+    /** @return int|mixed[] */
+    #[AccessControl]
+    #[ExactRoute('GET', '/api/v1/kaomoji')]
+    public function getEmotes(HttpResponseBuilder $response, HttpRequest $request): array|int {
+        $kaomoji = $this->kaomojiCtx->kaomoji->getAllKaomoji();
+
+        if($request->hasParam('as')) {
+            if($request->getParam('as') === 'array') {
+                $response->setCacheControl('max-age=3600', 'stale-if-error=86400', 'public', 'immutable');
+                return XArray::select($kaomoji, fn($kaomoji) => (string)$kaomoji);
+            } else return 400;
+        }
+
+        $transformer = $this->createKaomojiTransformer();
+        if(!$transformer->filter($request))
+            return 400;
+
+        $response->setCacheControl('max-age=3600', 'stale-if-error=86400', 'public', 'immutable');
+
+        return XArray::select($kaomoji, fn($kaomoji) => $transformer->convert($kaomoji));
+    }
+
+    /** @return int|mixed[] */
+    #[AccessControl]
+    #[PatternRoute('GET', '/api/v1/kaomoji/([0-9]+)')]
+    public function getEmote(HttpResponseBuilder $response, HttpRequest $request, string $id): array|int {
+        if(empty($id))
+            return 404;
+
+        $transformer = $this->createKaomojiTransformer();
+        if(!$transformer->filter($request))
+            return 400;
+
+        try {
+            $kaomoji = $this->kaomojiCtx->kaomoji->getKaomoji($id);
+        } catch(RuntimeException $ex) {
+            return 404;
+        }
+
+        $response->setCacheControl('max-age=3600', 'stale-if-error=86400', 'public', 'immutable');
+
+        return $transformer->convert($kaomoji);
+    }
+}
diff --git a/src/Kaomoji/KaomojiContext.php b/src/Kaomoji/KaomojiContext.php
new file mode 100644
index 00000000..df7937d9
--- /dev/null
+++ b/src/Kaomoji/KaomojiContext.php
@@ -0,0 +1,12 @@
+<?php
+namespace Misuzu\Kaomoji;
+
+use Index\Db\DbConnection;
+
+class KaomojiContext {
+    public private(set) KaomojiData $kaomoji;
+
+    public function __construct(DbConnection $dbConn) {
+        $this->kaomoji = new KaomojiData($dbConn);
+    }
+}
diff --git a/src/Kaomoji/KaomojiData.php b/src/Kaomoji/KaomojiData.php
new file mode 100644
index 00000000..27fa5da4
--- /dev/null
+++ b/src/Kaomoji/KaomojiData.php
@@ -0,0 +1,92 @@
+<?php
+namespace Misuzu\Kaomoji;
+
+use RuntimeException;
+use Index\Db\{DbConnection,DbStatementCache};
+
+class KaomojiData {
+    private DbStatementCache $cache;
+
+    public function __construct(DbConnection $dbConn) {
+        $this->cache = new DbStatementCache($dbConn);
+    }
+
+    public function getKaomoji(string $kaoId): KaomojiInfo {
+        $stmt = $this->cache->get(<<<SQL
+            SELECT kao_id, kao_string
+            FROM msz_kaomoji
+            WHERE kao_id = ?
+        SQL);
+        $stmt->nextParameter($kaoId);
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        if(!$result->next())
+            throw new RuntimeException('No kaomoji with that ID exists.');
+
+        return KaomojiInfo::fromResult($result);
+    }
+
+    /**
+     * @todo pagination
+     * @return \Iterator<int, KaomojiInfo>
+     */
+    public function getAllKaomoji(): iterable {
+        $stmt = $this->cache->get(<<<SQL
+            SELECT kao_id, kao_string
+            FROM msz_kaomoji
+        SQL);
+        $stmt->execute();
+
+        return $stmt->getResultIterator(KaomojiInfo::fromResult(...));
+    }
+
+    public function createKaomoji(string $string): KaomojiInfo {
+        $stmt = $this->cache->get(<<<SQL
+            INSERT INTO msz_kaomoji (kao_string) VALUES (?)
+        SQL);
+        $stmt->nextParameter($string);
+        $stmt->execute();
+
+        return $this->getKaomoji((string)$stmt->lastInsertId);
+    }
+
+    public function deleteKaomoji(KaomojiInfo|string $infoOrId): void {
+        if($infoOrId instanceof KaomojiInfo)
+            $infoOrId = $infoOrId->id;
+
+        $stmt = $this->cache->get(<<<SQL
+            DELETE FROM msz_kaomoji
+            WHERE kao_id = ?
+        SQL);
+        $stmt->nextParameter($infoOrId);
+        $stmt->execute();
+    }
+
+    public function updateKaomoji(
+        KaomojiInfo|string $infoOrId,
+        ?string $string = null
+    ): void {
+        $fields = [];
+        $values = [];
+
+        if($string !== null) {
+            $fields[] = 'kao_string = ?';
+            $values[] = $string;
+        }
+
+        if(empty($fields))
+            return;
+
+        $fields = implode(', ', $fields);
+        $stmt = $this->cache->get(<<<SQL
+            UPDATE msz_kaomoji
+            SET {$fields}
+            WHERE kao_id = ?
+        SQL);
+        foreach($values as $value)
+            $stmt->nextParameter($value);
+        $stmt->nextParameter($infoOrId instanceof KaomojiInfo ? $infoOrId->id : $infoOrId);
+        $stmt->execute();
+    }
+}
diff --git a/src/Kaomoji/KaomojiInfo.php b/src/Kaomoji/KaomojiInfo.php
new file mode 100644
index 00000000..b078f982
--- /dev/null
+++ b/src/Kaomoji/KaomojiInfo.php
@@ -0,0 +1,23 @@
+<?php
+namespace Misuzu\Kaomoji;
+
+use Stringable;
+use Index\Db\DbResult;
+
+class KaomojiInfo implements Stringable {
+    public function __construct(
+        public private(set) string $id,
+        public private(set) string $string,
+    ) {}
+
+    public static function fromResult(DbResult $result): KaomojiInfo {
+        return new KaomojiInfo(
+            id: $result->getString(0),
+            string: $result->getString(1),
+        );
+    }
+
+    public function __toString(): string {
+        return $this->string;
+    }
+}
diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php
index c01538cf..fc778240 100644
--- a/src/MisuzuContext.php
+++ b/src/MisuzuContext.php
@@ -42,6 +42,7 @@ class MisuzuContext implements RouteHandler {
     public private(set) Comments\CommentsContext $commentsCtx;
     public private(set) Emoticons\EmotesContext $emotesCtx;
     public private(set) Forum\ForumContext $forumCtx;
+    public private(set) Kaomoji\KaomojiContext $kaomojiCtx;
     public private(set) Logs\LogsContext $logsCtx;
     public private(set) Messages\MessagesContext $messagesCtx;
     public private(set) OAuth2\OAuth2Context $oauth2Ctx;
@@ -105,6 +106,7 @@ class MisuzuContext implements RouteHandler {
             Forum\ForumContext::class,
             config: $this->config->scopeTo('forum'),
         ));
+        $this->deps->register($this->kaomojiCtx = $this->deps->constructLazy(Kaomoji\KaomojiContext::class));
         $this->deps->register($this->logsCtx = $this->deps->constructLazy(Logs\LogsContext::class));
         $this->deps->register($this->messagesCtx = $this->deps->constructLazy(
             Messages\MessagesContext::class,
@@ -185,6 +187,7 @@ class MisuzuContext implements RouteHandler {
 
         $this->routingCtx->register($this->deps->constructLazy(Colours\ColoursApiRoutes::class), $roles);
         $this->routingCtx->register($this->deps->constructLazy(Emoticons\EmotesApiRoutes::class), $roles);
+        $this->routingCtx->register($this->deps->constructLazy(Kaomoji\KaomojiApiRoutes::class), $roles);
         $this->routingCtx->register($this->deps->constructLazy(Users\UsersApiRoutes::class), $roles);
 
         $this->routingCtx->register($this->deps->constructLazy(Home\HomeRoutes::class), $roles);