diff --git a/README.md b/README.md
index 2546d20e4dd60044eef9349f9fc79d715d432807..64afc8f3f3edf8929cb810c539a03d1bae149df0 100644
--- a/README.md
+++ b/README.md
@@ -6,4 +6,163 @@
 [![pipeline status](https://gitlab.cs.ui.ac.id/bukan-macan-ternak/liongame-backend/badges/master/pipeline.svg)](https://gitlab.cs.ui.ac.id/bukan-macan-ternak/liongame-backend/-/commits/master)
 [![coverage report](https://gitlab.cs.ui.ac.id/bukan-macan-ternak/liongame-backend/badges/master/coverage.svg)](https://gitlab.cs.ui.ac.id/bukan-macan-ternak/liongame-backend/-/commits/master)
 
-TBD.
+## Authorization
+
+The API uses simple authentication by verifying `Authorization` header. The
+value must match with `API_KEY` environment variable loaded by the backend.
+Every request must contain `Authorization` header and its value.
+
+Example request:
+
+```bash
+curl "https://example.com/api/logs" \
+  -H "Authorization: 00ff11ee22dd"
+```
+
+## Play Session Logs
+
+### Append
+
+Submits a single play session log.
+
+```endpoint
+POST /logs
+```
+
+Example request:
+
+```bash
+curl -X POST https://liongame.adian.to/api/logs \
+  -H "Authorization: {{ API_KEY }}"
+```
+
+Example request body:
+
+> Note: `//` comments are embedded for clarity. The JSON schema corresponds to
+> the columns in the actual spreadsheet.
+> **Do not actually include the comments in the actual JSON!**
+
+```json
+[
+  "VSWM try out",     // project_name
+  "Try Out",          // organisation_name
+  "Minggu 1 Jo",      // group_name
+  "1",                // participant_case_number
+  "Par1",             // participant_name
+  "",                 // participant_symbol
+  "",                 // participant_color
+  "LvTLiO",           // participant_token
+  "",                 // taak (?)
+  "5",                // #levels
+  "4",                // #items
+  "IND",              // language
+  "default_theme",    // theme
+  "16",               // matrix_size
+  "",                 // id
+  "60",               // task_id
+  "",                 // participant_id
+  "0",                // level
+  "1",                // item
+  "1",                // question_number
+  "red",              // correct_answer
+  "8",                // correct_matrix_number
+  "NA",               // response
+  "12",               // response_matrix_number (index position in 1-dimensional array representation)
+  "131037",           // response_time (in milliseconds)
+  "0",                // score
+  "",                 // created_at (sent from the game)
+  "",                 // updated_at (sent from the game)
+  "",                 // deleted_at
+]
+```
+
+Example response:
+
+> `HTTP 200 OK`
+
+```json
+{
+    "status": 200,
+    "data": {
+        "participant_name": "{{ participant_name }}",
+        "created_at": "{{ created_at }}",
+    }
+}
+```
+
+### Batch Append
+
+Submits a list of play session logs.
+
+```endpoint
+POST /logs/batch
+```
+
+Example request:
+
+```bash
+curl -X POST https://liongame.adian.to/api/logs/batch \
+  -H "Authorization: {{ API_KEY }}"
+```
+
+Example request body:
+
+```json
+[
+  [
+    "VSWM try out", "Try Out", "Minggu 1 Jo", "1", "Par1", "", "", "LvTLiO",
+    "", "5", "4", "IND", "default_theme", "16", "", "60", "", "0", "1", "1",
+    "red", "8", "NA", "12", "131037", "0", "", "", ""
+  ],
+  [
+    "VSWM try out", "Try Out", "Minggu 1 Jo", "1", "Par1", "", "", "LvTLiO",
+    "", "5", "4", "IND", "default_theme", "16", "", "60", "", "0", "1", "2",
+    "blue", "3", "NA", "3", "62826", "1", "", "", ""
+  ],
+  [
+    "VSWM try out", "Try Out", "Minggu 1 Jo", "1", "Par1", "", "", "LvTLiO",
+    "", "5", "4", "IND", "default_theme", "16", "", "60", "", "0", "2", "1",
+    "red", "9", "NA", "13", "32213", "0", "", "", ""
+  ]
+]
+```
+
+Example response:
+
+> HTTP 200
+
+```json
+{
+    "participant_name": "{{ participant_name }}",
+    "created_at": "{{ created_at }}",               // Latest created_at timestamp
+}
+```
+
+## Errors Response
+
+The possible errors and their example responses are as follows:
+
+> `HTTP 400 Bad Request` caused by incorrect request body.
+
+```json
+{
+    "status": 400
+}
+```
+
+> `HTTP 403 Forbidden` caused by failed authorization.
+
+```json
+{
+    "status": 403
+}
+```
+
+> `HTTP 503 Service Unavailable` is possible due to connection issues to Google
+> Sheets API.
+
+```json
+{
+    "status": 503
+}
+```
diff --git a/src/.env.example b/src/.env.example
index 79d0e6f3e2e008ce3118146bc0f3f47645c7f542..e2893575b1181d48e79110b63caa1fe230797a70 100644
--- a/src/.env.example
+++ b/src/.env.example
@@ -1,4 +1,4 @@
-API_KEY=sha512(bangtoyib-cepatpulang)
+API_KEY=sha256(bangtoyib-cepatpulang)
 DEBUG=true
 GOOGLE_APPLICATION_CREDENTIALS=/opt/liongame/service-account.json
 SPREADSHEET_ID=1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms
diff --git a/src/App.php b/src/App.php
index 7f698ce14174635f488ab76301206148b073e148..575a7c0e38782aef5f6c16437275b9dbdaf52bef 100644
--- a/src/App.php
+++ b/src/App.php
@@ -1,5 +1,4 @@
-<?php
-declare(strict_types=1);
+<?php declare(strict_types=1);
 
 namespace addianto\LionGame\Backend;
 
@@ -9,9 +8,12 @@ use Slim\Factory\AppFactory;
 use Slim\App as SlimApp;
 use addianto\LionGame\Backend\Config\Config;
 use addianto\LionGame\Backend\Service\SheetsService;
+use count;
+use json_encode;
 
 class App
 {
+    const VALID_LENGTH = 29;
     private string $apiKey;
     private SlimApp $app;
     private SheetsService $sheets;
@@ -38,16 +40,110 @@ class App
 
     private function setupMiddleware(): void
     {
+        $this->app->addBodyParsingMiddleware();
         $this->app->addErrorMiddleware($this->debug, true, true);
     }
 
     private function setupController(): void
     {
-        $this->app->post("/logs", function (Request $request, Response $response, $args) {
-            $response->getBody()->write(json_encode(array(
-                "message" => "TODO: Implement me!"
-            )));
-            return $response;
+        $this->app->post("/api/logs", function (Request $request, Response $response, $args): Response {
+            if (!self::isAuthorized($request, $this->apiKey)) {
+                self::buildErrorResponse($response, 403);
+                return $response;
+            }
+
+            $data = $request->getParsedBody();
+
+            if (!self::isValidData($data)) {
+                self::buildErrorResponse($response, 400);
+                return $response;
+            }
+
+            if ($this->sheets->appendValues(
+                "'Play Session Analytics (Example)'!A:AC",
+                array($data)
+            )) {
+                $response
+                    ->withStatus(200)
+                    ->getBody()->write(json_encode(array(
+                        "status" => 200,
+                        "data" => array(
+                            "participant_name" => $data[0][4],
+                            "created_at" => $data[0][26],
+                        ),
+                    )));
+
+                return $response;
+            } else {
+                self::buildErrorResponse($response, 503);
+                return $response;
+            }
         });
+
+        $this->app->post("/api/logs/batch", function (Request $request, Response $response, $args): Response {
+            if (!self::isAuthorized($request, $this->apiKey)) {
+                self::buildErrorResponse($response, 403);
+                return $response;
+            }
+
+            $data = $request->getParsedBody();
+
+            if (!self::isValidData($data)) {
+                self::buildErrorResponse($response, 400);
+                return $response;
+            }
+
+            if ($this->sheets->appendValues(
+                "'Play Session Analytics (Example)'!A:AC",
+                $data
+            )) {
+                $response
+                    ->withStatus(200)
+                    ->getBody()->write(json_encode(array(
+                        "status" => 200,
+                        "data" => array(
+                            "participant_name" => $data[count($data)-1][4],
+                            "created_at" => $data[count($data)-1][26],
+                        ),
+                    )));
+
+                return $response;
+            } else {
+                self::buildErrorResponse($response, 503);
+                return $response;
+            }
+        });
+    }
+
+    private static function buildErrorResponse(Response $response, int $code): void
+    {
+        $response->withStatus($code)->getBody()->write(json_encode(array(
+            "status" => $code,
+        )));
+    }
+
+    private static function isAuthorized(Request $request, string $apiKey): bool
+    {
+        if (!$request->hasHeader("Authorization")) {
+            return false;
+        }
+
+        $headerValue = $request->getHeader("Authorization");
+
+        return $headerValue[0] == $apiKey;
+    }
+
+    private static function isValidData(array $data): bool
+    {
+        if (is_array($data[0])) {
+            foreach ($data as $entry) {
+                if (count($entry) != self::VALID_LENGTH) {
+                    return false;
+                }
+            }
+            return true;
+        }
+
+        return count($data) == self::VALID_LENGTH;
     }
 }
diff --git a/src/index.php b/src/index.php
index 6f99f836e806dcc7afa00439233a70ad428f0dad..884e1b9ae4ac427d2ff0f8963bebc04b2beb2c96 100644
--- a/src/index.php
+++ b/src/index.php
@@ -1,10 +1,11 @@
-<?php
-declare(strict_types=1);
+<?php declare(strict_types=1);
 
 namespace addianto\LionGame\Backend;
 
 use Dotenv\Dotenv;
 use addianto\LionGame\Backend\App;
+use addianto\LionGame\Backend\Config\Config;
+use addianto\LionGame\Backend\Service\SheetsService;
 
 require __DIR__ . '/../vendor/autoload.php';
 
diff --git a/src/service/SheetsService.php b/src/service/SheetsService.php
index 9c6a0c35c68b74fb9e7b0138e93bcba368286974..abb256a5a5d5a7961345f5cc64b2312f88566bb3 100644
--- a/src/service/SheetsService.php
+++ b/src/service/SheetsService.php
@@ -25,8 +25,7 @@ class SheetsService
         $values = array();
 
         try {
-            $response = $this->api
-                ->spreadsheets_values
+            $response = $this->api->spreadsheets_values
                 ->get($this->spreadsheetId, $range);
 
             $values = $response->getValues();
@@ -45,7 +44,7 @@ class SheetsService
         try {
             $body = new ValueRange([
                 "majorDimension" => "ROWS",
-                "values" => array($values),
+                "values" => $values,
             ]);
 
             $optionalParameters = array(
@@ -53,8 +52,7 @@ class SheetsService
                 "valueInputOption" => "RAW",
             );
 
-            $response = $this->api
-                ->spreadsheets_values
+            $this->api->spreadsheets_values
                 ->append($this->spreadsheetId, $range, $body, $optionalParameters);
 
             $isAppended = true;
diff --git a/tests/AppTest.php b/tests/AppTest.php
index 3fb849c9e53d264dcac379fa0f87b5979acd279b..6b8b9363bbecb34b7ac8fca691d4184636322e54 100644
--- a/tests/AppTest.php
+++ b/tests/AppTest.php
@@ -23,12 +23,14 @@ class AppTest extends TestCase
     const SUCCESS_JSON_DECODE = 0;
 
     private SlimApp $app;
+    private SheetsService $sheets;
 
     protected function setUp(): void
     {
+        $this->sheets = $this->createMock(SheetsService::class);
         $this->app = (new App(
             self::API_KEY,
-            $this->createMock(SheetsService::class),
+            $this->sheets,
             self::DEBUG
         ))->get();
     }
@@ -38,30 +40,147 @@ class AppTest extends TestCase
         $this->assertInstanceOf(SlimApp::class, $this->app);
     }
 
-    public function testPOSTLogsShouldResolve(): void
+    public function testAppendLogShouldReturnJSON(): void
     {
-        $request = $this->buildRequest("POST", "/logs");
-        $response = $this->app->handle($request);
+        $this->sheets
+            ->method("appendValues")
+            ->willReturn(true);
+        $request = $this->buildRequest(
+            "POST",
+            "/api/logs",
+            self::API_KEY,
+            $this->getValidRequestBody()
+        );
+
+        $json = $this->getJSON($this->app->handle($request));
 
-        $this->assertEquals(200, $response->getStatusCode());
+        $this->assertEquals(200, $json->status);
     }
 
-    public function testPOSTLogsShouldReturnJSON(): void
+    public function testBatchAppendLogsShouldReturnJSON(): void
     {
-        $request = $this->buildRequest("POST", "/logs");
+        $this->sheets
+            ->method("appendValues")
+            ->willReturn(true);
+        $request = $this->buildRequest(
+            "POST",
+            "/api/logs/batch",
+            self::API_KEY,
+            array(
+                $this->getValidRequestBody(),
+                $this->getValidRequestBody(),
+                $this->getValidRequestBody(),
+            )
+        );
 
-        $response = $this->app->handle($request);
-        $jsonString = $response->getBody()->__toString();
-        $json = json_decode($jsonString);
+        $json = $this->getJSON($this->app->handle($request));
 
-        $this->assertEquals(self::SUCCESS_JSON_DECODE, json_last_error());
+        $this->assertEquals(200, $json->status);
+        $this->assertEquals("Par1", $json->data->participant_name);
+    }
+
+    public function testFailedAuthorizationOnAppendLog(): void
+    {
+        $request = $this->buildRequest(
+            "POST",
+            "/api/logs",
+            "a",
+            $this->getValidRequestBody()
+        );
+
+        $json = $this->getJSON($this->app->handle($request));
+
+        $this->assertEquals(403, $json->status);
+    }
+
+    public function testInvalidDataOnAppendLog(): void
+    {
+        $request = $this->buildRequest(
+            "POST",
+            "/api/logs",
+            self::API_KEY,
+            $this->getInvalidRequestBody()
+        );
+
+        $json = $this->getJSON($this->app->handle($request));
+
+        $this->assertEquals(400, $json->status);
+    }
+
+    public function testInvalidDataOnBatchAppendLog(): void
+    {
+        $request = $this->buildRequest(
+            "POST",
+            "/api/logs",
+            self::API_KEY,
+            array(
+                $this->getValidRequestBody(),
+                $this->getInvalidRequestBody(),
+                $this->getValidRequestBody(),
+            )
+        );
+
+        $json = $this->getJSON($this->app->handle($request));
+
+        $this->assertEquals(400, $json->status);
     }
 
-    private function buildRequest(string $method, string $path)
+    public function testGoogleSheetsAPIUnavailableOnAppendLog(): void
     {
+        $this->sheets
+            ->method("appendValues")
+            ->willReturn(false);
+        $request = $this->buildRequest(
+            "POST",
+            "/api/logs",
+            self::API_KEY,
+            $this->getValidRequestBody()
+        );
+
+        $json = $this->getJSON($this->app->handle($request));
+
+        $this->assertEquals(503, $json->status);
+    }
+
+    private function buildRequest(
+        string $method,
+        string $path,
+        string $authorization,
+        array $body = []
+    ) {
         return RequestFactory::create()
             ->createServerRequestFromGlobals()
+            ->withHeader("Authorization", $authorization)
+            ->withHeader("Content-Type", "application/json")
             ->withMethod(strtoupper($method))
-            ->withUri(UriFactory::createUri($path));
+            ->withUri(UriFactory::createUri($path))
+            ->withParsedBody($body);
+    }
+
+    private function getJSON(Response $response): object
+    {
+        $jsonString = $response->getBody()->__toString();
+        $json = json_decode($jsonString);
+
+        $this->assertEquals(self::SUCCESS_JSON_DECODE, json_last_error());
+
+        return $json;
+    }
+
+    private function getValidRequestBody(): array
+    {
+        return [
+            "VSWM try out", "Try Out", "Minggu 1 Jo", "1", "Par1", "", "", "LvTLiO",
+            "", "5", "4", "IND", "default_theme", "16", "", "60", "", "0", "1", "1",
+            "red", "8", "NA", "12", "131037", "0", "", "", ""
+        ];
+    }
+
+    private function getInvalidRequestBody(): array
+    {
+        return [
+            "VSWM try out", "Try Out", "Minggu 1 Jo", "1", "Par1", "", "", "LvTLiO",
+            "", "5", "4", "IND", "default_theme", "16", "", "60", "", "0", "1", "1"
+        ];
     }
 }