diff --git a/lib/REST/NextcloudNews/V1_2.php b/lib/REST/NextcloudNews/V1_2.php
index aec1f542..0759692a 100644
--- a/lib/REST/NextcloudNews/V1_2.php
+++ b/lib/REST/NextcloudNews/V1_2.php
@@ -16,6 +16,7 @@ use JKingWeb\Arsse\Db\ExceptionInput;
 use JKingWeb\Arsse\Feed\Exception as FeedException;
 use JKingWeb\Arsse\Misc\HTTP;
 use JKingWeb\Arsse\REST\Exception;
+use MensBeam\Mime\MimeType;
 use Psr\Http\Message\ServerRequestInterface;
 use Psr\Http\Message\ResponseInterface;
 
@@ -87,16 +88,51 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
         } else {
             return HTTP::respEmpty(401);
         }
-        // normalize the input
+        // parse the input
         $data = (string) $req->getBody();
         if ($data) {
-            // if the entity body is not JSON according to content type, return "415 Unsupported Media Type"
-            if (!HTTP::matchType($req, [self::ACCEPTED_TYPE])) {
-                return HTTP::respEmpty(415, ['Accept' => self::ACCEPTED_TYPE]);
-            }
-            $data = @json_decode($data, true);
-            if (json_last_error() !== \JSON_ERROR_NONE) {
-                // if the body could not be parsed as JSON, return "400 Bad Request"
+            // Officially the body, if any, should be JSON. In practice the
+            //   server must also accept application/x-www-form-urlencoded;
+            //   it's also possible that input is mislabelled, so we'll try
+            //   different combinations till something works, or return an
+            //   error status in the end
+            $type = MimeType::extract($req->getHeaderLine("Content-Type"));
+            try {
+                switch ($type->essence ?? "") {
+                    case "application/json":
+                    case "text/json":
+                        $data = $this->parseJson($data);
+                        break;
+                    case "application/x-www-form-urlencoded":
+                        if ($this->guessForm($data)) {
+                            $data = $this->parseForm($data);
+                        } else {
+                            $data = $this->parseJson($data);
+                        }
+                        break;
+                    case "":
+                        if ($this->guessJson($data)) {
+                            $data = $this->parseJson($data);
+                        } elseif ($this->guessForm($data)) {
+                            $data = $this->parseForm($data);
+                        } else {
+                            return HTTP::respEmpty(400);
+                        }
+                        break;
+                    default:
+                        // other media types would normally be rejected, but 
+                        //   if it happens to be mislabelled JSON we can accept
+                        //   it; we will not try form data here, though,
+                        //   because input is really expected to be JSON
+                        if ($this->guessJson($data)) {
+                            try {
+                                $data = $this->parseJson($data);
+                                break;
+                            } catch (\JsonException $e) {}
+                        }
+                        return HTTP::respEmpty(415, ['Accept' => self::ACCEPTED_TYPE]);
+                }
+            } catch (\JsonException $e) {
                 return HTTP::respEmpty(400);
             }
         } else {
@@ -124,6 +160,28 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
         // @codeCoverageIgnoreEnd
     }
 
+    protected function guessJson(string $data): bool {
+        return (bool) preg_match('/^\s*\{\s*"[a-zA-Z]+"\s*:/s', $data);
+    }
+
+    protected function parseJson(string $data): array {
+        $out = json_decode($data, true, 512, \JSON_THROW_ON_ERROR);
+        if (!is_array($out)) {
+            throw new \JsonException("JSON input must be an object");
+        }
+        return $out;
+    }
+
+    protected function guessForm(string $data): bool {
+        return (bool) preg_match('/^\s*[a-zA-Z]+=/s', $data);
+    }
+
+    protected function parseForm(string $data): array {
+        // we assume that, as PHP application, Nextcloud News uses PHP logic for interpreting form data
+        parse_str($data, $out); // this cannot fail as any string can be interpreted into some sort of array
+        return $out;
+    }
+
     protected function normalizePathIds(string $url): string {
         $path = explode("/", $url);
         // any path components which are database IDs (integers greater than zero) should be replaced with "1", for easier comparison (we don't care about the specific ID)
diff --git a/tests/cases/REST/NextcloudNews/TestV1_2.php b/tests/cases/REST/NextcloudNews/TestV1_2.php
index 9112fe9e..b3a6b70e 100644
--- a/tests/cases/REST/NextcloudNews/TestV1_2.php
+++ b/tests/cases/REST/NextcloudNews/TestV1_2.php
@@ -26,6 +26,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
     protected $h;
     protected $transaction;
     protected $userId;
+    protected $prefix = "/index.php/apps/news/api/v1-2";
     protected static $feeds = [ // expected sample output of a feed list from the database, and the resultant expected transformation by the REST handler
         'db' => [
             [
@@ -307,20 +308,6 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
         ],
     ];
 
-    protected function req(string $method, string $target, $data = "", array $headers = [], bool $authenticated = true, bool $body = true): ResponseInterface {
-        Arsse::$user->id = $this->userId;
-        $prefix = "/index.php/apps/news/api/v1-2";
-        $url = $prefix.$target;
-        if ($body) {
-            $params = [];
-        } else {
-            $params = $data;
-            $data = [];
-        }
-        $req = $this->serverRequest($method, $url, $prefix, $headers, [], $data, "application/json", $params, $authenticated ? "john.doe@example.com" : "");
-        return $this->h->dispatch($req);
-    }
-
     public function setUp(): void {
         parent::setUp();
         self::setConf();
@@ -329,6 +316,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
         Arsse::$user = \Phake::mock(User::class);
         \Phake::when(Arsse::$user)->auth->thenReturn(true);
         \Phake::when(Arsse::$user)->propertiesGet->thenReturn(['admin' => true]);
+        Arsse::$user->id = $this->userId;
         // create a mock database interface
         Arsse::$db = \Phake::mock(Database::class);
         \Phake::when(Arsse::$db)->begin->thenReturn(\Phake::mock(Transaction::class));
@@ -340,6 +328,24 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
         return $value;
     }
 
+    protected function req(string $method, string $target, $data = "", array $headers = [], bool $authenticated = true, bool $body = true): ResponseInterface {
+        $url = $this->prefix.$target;
+        if ($body) {
+            $params = [];
+        } else {
+            $params = $data;
+            $data = [];
+        }
+        $req = $this->serverRequest($method, $url, $this->prefix, $headers, [], $data, "application/json", $params, $authenticated ? $this->userId : "");
+        return $this->h->dispatch($req);
+    }
+
+    protected function reqText(string $method, string $target, string $data, string $type, array $headers = [], bool $authenticated = true): ResponseInterface {
+        $url = $this->prefix.$target;
+        $req = $this->serverRequest($method, $url, $this->prefix, $headers, [], $data, $type, [], $authenticated ? $this->userId : "");
+        return $this->h->dispatch($req);
+    }
+
     public function testSendAuthenticationChallenge(): void {
         $exp = HTTP::respEmpty(401);
         $this->assertMessage($exp, $this->req("GET", "/", "", [], false));
@@ -552,6 +558,38 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
         ];
     }
 
+    #[DataProvider("provideNewSubscriptionsWithType")]
+    public function testAddSubscriptionsWithDifferentMediaTypes(string $input, string $type, ResponseInterface $exp): void {
+        \Phake::when(Arsse::$db)->subscriptionAdd->thenReturn(42);
+        \Phake::when(Arsse::$db)->subscriptionPropertiesSet->thenReturn(true);
+        \Phake::when(Arsse::$db)->subscriptionPropertiesGet->thenReturn(self::v(self::$feeds['db'][1]));
+        \Phake::when(Arsse::$db)->editionLatest->thenReturn(4758915);
+        $act = $this->reqText("POST", "/feeds", $input, $type);
+        $this->assertMessage($exp, $act);
+    }
+
+    public static function provideNewSubscriptionsWithType(): iterable {
+        $success = HTTP::respJson(['feeds' => [self::$feeds['rest'][1]], 'newestItemId' => 4758915]);
+        $badType = HTTP::respEmpty(415, ['Accept' => "application/json"]);
+        return [
+            ['{"url":"http://example.org/news.atom","folderId":8}', "application/json",                  $success],
+            ['{"url":"http://example.org/news.atom","folderId":8}', "text/json",                         $success],
+            ['{"url":"http://example.org/news.atom","folderId":8}', "",                                  $success],
+            ['{"url":"http://example.org/news.atom","folderId":8}', "/",                                 $success],
+            ['{"url":"http://example.org/news.atom","folderId":8}', "application/x-www-form-urlencoded", $success],
+            ['{"url":"http://example.org/news.atom","folderId":8}', "application/octet-stream",          $success],
+            ['url=http://example.org/news.atom&folderId=8',         "application/x-www-form-urlencoded", $success],
+            ['url=http://example.org/news.atom&folderId=8',         "",                                  $success],
+            ['{"url":',                                             "application/json",                  HTTP::respEmpty(400)],
+            ['{"url":',                                             "text/json",                         HTTP::respEmpty(400)],
+            ['{"url":',                                             "",                                  HTTP::respEmpty(400)],
+            ['{"url":',                                             "application/x-www-form-urlencoded", HTTP::respEmpty(400)],
+            ['{"url":',                                             "application/octet-stream",          $badType],
+            ['null',                                                "application/json",                  HTTP::respEmpty(400)],
+            ['null',                                                "text/json",                         HTTP::respEmpty(400)],
+        ];
+    }
+
     public function testRemoveASubscription(): void {
         \Phake::when(Arsse::$db)->subscriptionRemove($this->userId, 1)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing"));
         $exp = HTTP::respEmpty(204);
diff --git a/tests/cases/REST/NextcloudNews/TestV1_3.php b/tests/cases/REST/NextcloudNews/TestV1_3.php
index bf25bfcd..62abb927 100644
--- a/tests/cases/REST/NextcloudNews/TestV1_3.php
+++ b/tests/cases/REST/NextcloudNews/TestV1_3.php
@@ -17,6 +17,7 @@ use PHPUnit\Framework\Attributes\CoversClass;
 
 #[CoversClass(V1_3::class)]
 class TestV1_3 extends TestV1_2 {
+    protected $prefix = "/index.php/apps/news/api/v1-3";
 
     public function setUp(): void {
         parent::setUp();