diff --git a/src/all/tappytoon/AndroidManifest.xml b/src/all/tappytoon/AndroidManifest.xml
new file mode 100644
index 000000000..30deb7f79
--- /dev/null
+++ b/src/all/tappytoon/AndroidManifest.xml
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest package="eu.kanade.tachiyomi.extension" />
diff --git a/src/all/tappytoon/build.gradle b/src/all/tappytoon/build.gradle
new file mode 100644
index 000000000..e96973b1d
--- /dev/null
+++ b/src/all/tappytoon/build.gradle
@@ -0,0 +1,13 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlinx-serialization'
+
+ext {
+    extName = 'Tappytoon'
+    pkgNameSuffix = 'all.tappytoon'
+    extClass = '.TappytoonFactory'
+    extVersionCode = 1
+    isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/all/tappytoon/res/mipmap-hdpi/ic_launcher.png b/src/all/tappytoon/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..4384fe137
Binary files /dev/null and b/src/all/tappytoon/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/all/tappytoon/res/mipmap-mdpi/ic_launcher.png b/src/all/tappytoon/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..b558c71fa
Binary files /dev/null and b/src/all/tappytoon/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/all/tappytoon/res/mipmap-xhdpi/ic_launcher.png b/src/all/tappytoon/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..e9cd0968f
Binary files /dev/null and b/src/all/tappytoon/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/all/tappytoon/res/mipmap-xxhdpi/ic_launcher.png b/src/all/tappytoon/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..ebcd17c91
Binary files /dev/null and b/src/all/tappytoon/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/all/tappytoon/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/tappytoon/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..2126158ce
Binary files /dev/null and b/src/all/tappytoon/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/all/tappytoon/res/web_hi_res_512.png b/src/all/tappytoon/res/web_hi_res_512.png
new file mode 100644
index 000000000..c245a69e2
Binary files /dev/null and b/src/all/tappytoon/res/web_hi_res_512.png differ
diff --git a/src/all/tappytoon/src/eu/kanade/tachiyomi/extension/all/tappytoon/Tappytoon.kt b/src/all/tappytoon/src/eu/kanade/tachiyomi/extension/all/tappytoon/Tappytoon.kt
new file mode 100644
index 000000000..2614be339
--- /dev/null
+++ b/src/all/tappytoon/src/eu/kanade/tachiyomi/extension/all/tappytoon/Tappytoon.kt
@@ -0,0 +1,232 @@
+package eu.kanade.tachiyomi.extension.all.tappytoon
+
+import android.util.Log
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.source.model.Filter
+import eu.kanade.tachiyomi.source.model.FilterList
+import eu.kanade.tachiyomi.source.model.MangasPage
+import eu.kanade.tachiyomi.source.model.Page
+import eu.kanade.tachiyomi.source.model.SChapter
+import eu.kanade.tachiyomi.source.model.SManga
+import eu.kanade.tachiyomi.source.online.HttpSource
+import eu.kanade.tachiyomi.util.asJsoup
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.decodeFromJsonElement
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.Request
+import okhttp3.Response
+import uy.kohesive.injekt.injectLazy
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Locale
+
+class Tappytoon(override val lang: String) : HttpSource() {
+    override val name = "Tappytoon"
+
+    override val baseUrl = "https://www.tappytoon.com/$lang"
+
+    override val supportsLatest = true
+
+    override val client = network.client.newBuilder().addInterceptor { chain ->
+        val res = chain.proceed(chain.request())
+        if (res.isSuccessful) return@addInterceptor res
+        // Log JSON error if available
+        if (res.headers["Content-Type"] == "application/json") {
+            res.body?.string()?.let(json::parseToJsonElement)?.run {
+                val type = jsonObject["type"]?.jsonPrimitive?.content
+                val msg = jsonObject["message"]!!.jsonPrimitive.content
+                Log.e("Tappytoon", "${type ?: "Error"} - $msg")
+            }
+        } else {
+            res.close()
+        }
+        when (val code = res.code) {
+            403 -> throw Error("You are not authorized to view this")
+            else -> throw Error("HTTP error $code")
+        }
+    }.build()
+
+    private val json by injectLazy<Json>()
+
+    private val apiHeaders by lazy {
+        val res = client.newCall(GET(baseUrl, headers)).execute()
+        val data = res.asJsoup().getElementById("__NEXT_DATA__")
+        val obj = json.parseToJsonElement(data.data())
+            .jsonObject["props"]!!.jsonObject["initialState"]!!
+            .jsonObject["axios"]!!.jsonObject["headers"]!!.jsonObject
+        val auth = obj["Authorization"]!!.jsonPrimitive.content
+        val uuid = obj["X-Device-Uuid"]!!.jsonPrimitive.content
+        headers.newBuilder()
+            .set("Origin", "https://www.tappytoon.com")
+            .set("Referer", "https://www.tappytoon.com/")
+            .set("Accept-Language", lang)
+            .set("Authorization", auth)
+            .set("X-Device-Uuid", uuid)
+            .build()
+    }
+
+    private var nextUrl: String? = null
+
+    override fun headersBuilder() =
+        super.headersBuilder().set("Referer", "https://www.tappytoon.com/")
+
+    override fun latestUpdatesRequest(page: Int) =
+        apiUrl.newBuilder().run {
+            addEncodedPathSegment("comics")
+            addEncodedQueryParameter("day_of_week", day)
+            addEncodedQueryParameter("locale", lang)
+            GET(toString(), apiHeaders)
+        }
+
+    override fun popularMangaRequest(page: Int) =
+        apiUrl.newBuilder().run {
+            addEncodedPathSegment("comics")
+            addEncodedQueryParameter("sort_by", "trending")
+            // Sort is only available for completed series
+            addEncodedQueryParameter("filter", "completed")
+            addEncodedQueryParameter("locale", lang)
+            GET(toString(), apiHeaders)
+        }
+
+    override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+        if (nextUrl != null) return GET(nextUrl!!, apiHeaders)
+        val url = apiUrl.newBuilder()
+            .addEncodedPathSegments("comics")
+            .addEncodedQueryParameter("locale", lang)
+        val genre = filters.find { it is Genre } as? Genre
+        if (genre != null && genre.state != 0) {
+            url.addEncodedQueryParameter("genre", genre.alias)
+            url.addEncodedQueryParameter("limit", "50")
+        } else if (query.isNotBlank()) {
+            url.addQueryParameter("keyword", query)
+        }
+        return GET(url.toString(), apiHeaders)
+    }
+
+    // Request the real URL for the webview
+    override fun mangaDetailsRequest(manga: SManga) =
+        GET("$baseUrl/comics/${manga.slug}", headers)
+
+    override fun chapterListRequest(manga: SManga) =
+        apiUrl.newBuilder().run {
+            addEncodedPathSegments("comics/${manga.id}/chapters")
+            addEncodedQueryParameter("locale", lang)
+            GET(toString(), apiHeaders)
+        }
+
+    override fun pageListRequest(chapter: SChapter) =
+        apiUrl.newBuilder().run {
+            addEncodedPathSegments("chapters/${chapter.url}")
+            addEncodedQueryParameter("includes", "images")
+            addEncodedQueryParameter("locale", lang)
+            GET(toString(), apiHeaders)
+        }
+
+    override fun latestUpdatesParse(response: Response) =
+        response.parse<List<Comic>>().accessible.map {
+            SManga.create().apply {
+                url = it.toString()
+                title = it.title
+                description = it.longDescription
+                thumbnail_url = it.posterThumbnailUrl
+                author = it.authors.joinToString()
+                artist = author
+                genre = buildString {
+                    it.genres.joinToString(this, postfix = ", ")
+                    append("Rating: ").append(it.ageRating)
+                }
+                status = when {
+                    it.isCompleted -> SManga.COMPLETED
+                    !it.isHiatus -> SManga.ONGOING
+                    else -> SManga.UNKNOWN
+                }
+            }
+        }.run { MangasPage(this, false) }
+
+    override fun popularMangaParse(response: Response) =
+        latestUpdatesParse(response)
+
+    override fun searchMangaParse(response: Response) =
+        response.headers["Link"].let {
+            nextUrl = it?.substringAfter('<')?.substringBefore('>')
+            latestUpdatesParse(response).copy(hasNextPage = it != null)
+        }
+
+    override fun chapterListParse(response: Response) =
+        response.parse<List<Chapter>>().accessible.map {
+            SChapter.create().apply {
+                name = it.toString()
+                url = it.id.toString()
+                chapter_number = it.order
+                date_upload = dateFormat.parse(it.createdAt)?.time ?: 0L
+            }
+        }
+
+    override fun pageListParse(response: Response) =
+        response.parse<Images>().mapIndexed { idx, img ->
+            Page(idx, "", img.toString())
+        }
+
+    override fun fetchMangaDetails(manga: SManga) =
+        rx.Observable.just(manga.apply { initialized = true })!!
+
+    override fun getFilterList() = FilterList(
+        Filter.Header("NOTE: can't be used with text search!"),
+        Genre(genres.keys.toTypedArray())
+    )
+
+    private inline fun <reified T> Response.parse() =
+        json.decodeFromJsonElement<T>(json.parseToJsonElement(body!!.string()))
+
+    class Genre(values: Array<String>) : Filter.Select<String>("Genre", values)
+
+    private inline val Genre.alias: String
+        get() = genres[values[state]]!!
+
+    private inline val SManga.slug: String
+        get() = url.substringBefore('|')
+
+    private inline val SManga.id: String
+        get() = url.substringAfter('|')
+
+    override fun mangaDetailsParse(response: Response) =
+        throw UnsupportedOperationException("Not used")
+
+    override fun imageUrlParse(response: Response) =
+        throw UnsupportedOperationException("Not used")
+
+    companion object {
+        private val apiUrl = "https://api-global.tappytoon.com".toHttpUrl()
+
+        private val genres = mapOf(
+            "<select>" to "",
+            "Action" to "action",
+            "Romance" to "romance",
+            "Fantasy" to "fantasy",
+            "School" to "school",
+            "Slice of Life" to "slice",
+            "BL" to "bl",
+            "Comedy" to "comedy",
+            "GL" to "gl"
+        )
+
+        private val dateFormat by lazy {
+            SimpleDateFormat("yyyy-MM-d'T'HH:mm:ss", Locale.ROOT)
+        }
+
+        private val day by lazy {
+            when (Calendar.getInstance()[Calendar.DAY_OF_WEEK]) {
+                Calendar.SUNDAY -> "sun"
+                Calendar.MONDAY -> "mon"
+                Calendar.TUESDAY -> "tue"
+                Calendar.WEDNESDAY -> "wed"
+                Calendar.THURSDAY -> "thu"
+                Calendar.FRIDAY -> "fri"
+                Calendar.SATURDAY -> "sat"
+                else -> error("What day is it?")
+            }
+        }
+    }
+}
diff --git a/src/all/tappytoon/src/eu/kanade/tachiyomi/extension/all/tappytoon/TappytoonAPI.kt b/src/all/tappytoon/src/eu/kanade/tachiyomi/extension/all/tappytoon/TappytoonAPI.kt
new file mode 100644
index 000000000..2540a46eb
--- /dev/null
+++ b/src/all/tappytoon/src/eu/kanade/tachiyomi/extension/all/tappytoon/TappytoonAPI.kt
@@ -0,0 +1,64 @@
+package eu.kanade.tachiyomi.extension.all.tappytoon
+
+import kotlinx.serialization.Serializable
+
+interface Accessible {
+    val isAccessible: Boolean
+}
+
+inline val <A : Accessible> List<A>.accessible: List<A>
+    get() = filter { it.isAccessible }
+
+@Serializable
+class Comic(
+    private val id: Int,
+    val title: String,
+    private val slug: String,
+    val longDescription: String,
+    val posterThumbnailUrl: String,
+    val isHiatus: Boolean,
+    override val isAccessible: Boolean,
+    val isCompleted: Boolean,
+    val ageRating: Name,
+    val genres: List<Name>,
+    val authors: List<Name>
+) : Accessible {
+    override fun toString() = "$slug|$id"
+}
+
+@Serializable
+class Name(private val name: String) {
+    override fun toString() = name
+}
+
+@Serializable
+class Chapter(
+    val id: Int,
+    val order: Float,
+    private val title: String,
+    private val subtitle: String,
+    override val isAccessible: Boolean,
+    private val isFree: Boolean,
+    private val isUserUnlocked: Boolean,
+    private val isUserRented: Boolean,
+    val createdAt: String
+) : Accessible {
+    override fun toString() = buildString {
+        append(title)
+        if (subtitle.isNotEmpty()) {
+            append(" - ")
+            append(subtitle)
+        }
+        if (!isFree && !(isUserUnlocked || isUserRented)) {
+            append(" \uD83D\uDD12")
+        }
+    }
+}
+
+@Serializable
+class Images(private val images: List<URL>) : List<URL> by images
+
+@Serializable
+class URL(private val url: String) {
+    override fun toString() = url
+}
diff --git a/src/all/tappytoon/src/eu/kanade/tachiyomi/extension/all/tappytoon/TappytoonFactory.kt b/src/all/tappytoon/src/eu/kanade/tachiyomi/extension/all/tappytoon/TappytoonFactory.kt
new file mode 100644
index 000000000..c6b7b1840
--- /dev/null
+++ b/src/all/tappytoon/src/eu/kanade/tachiyomi/extension/all/tappytoon/TappytoonFactory.kt
@@ -0,0 +1,9 @@
+package eu.kanade.tachiyomi.extension.all.tappytoon
+
+import eu.kanade.tachiyomi.source.SourceFactory
+
+class TappytoonFactory : SourceFactory {
+    private val langs = setOf("en", "fr", "de")
+
+    override fun createSources() = langs.map(::Tappytoon)
+}