diff --git a/services/thunderstore/thunderstore-base.js b/services/thunderstore/thunderstore-base.js
new file mode 100644
index 0000000000000000000000000000000000000000..d4b2d7ad136eedf6f1bdd42915449cb85b1f7ee8
--- /dev/null
+++ b/services/thunderstore/thunderstore-base.js
@@ -0,0 +1,93 @@
+import Joi from 'joi'
+import { BaseJsonService } from '../index.js'
+import { nonNegativeInteger } from '../validators.js'
+
+const packageSchema = Joi.object({
+  latest: Joi.object({
+    version_number: Joi.string().required(),
+  }).required(),
+}).required()
+
+const packageMetricsSchema = Joi.object({
+  downloads: nonNegativeInteger,
+  rating_score: nonNegativeInteger,
+})
+
+const documentation = `
+<p>
+  The Thunderstore badges require a package's <code>namespace</code> and <code>name</code>.
+</p>
+<p>
+  Everything can be discerned from your package's URL. Thunderstore package URLs have a mostly consistent
+  format:
+</p>
+<p>
+    <code>https://thunderstore.io/c/[community]/p/[namespace]/[packageName]</code>
+</p>
+<p>
+  For example: <code>https://thunderstore.io/c/lethal-company/p/notnotnotswipez/MoreCompany/</code>.
+  <ul>
+    <li><code>namespace = "notnotnotswipez"</code></li>
+    <li><code>packageName = "MoreCompany"</code></li>
+  </ul>
+</p>
+<details>
+  <summary>Risk Of Rain 2</summary>
+  <p>
+    The 'default community', Risk of Rain 2, has an alternative URL:
+  </p>
+  <p>
+    <code>https://thunderstore.io/package/[namespace]/[packageName]</code>
+  </p>
+</details>
+<details>
+  <summary>Subdomain Communities</summary>
+  <p>
+    Some communities use a 'subdomain' alternative URL, for example, Valheim:
+  </p>
+  <p>
+    <code>https://valheim.thunderstore.io/package/[namespace]/[packageName]</code>
+  </p>
+</details>
+`
+
+/**
+ * Services which query Thunderstore endpoints should extend BaseThunderstoreService
+ *
+ * @abstract
+ */
+class BaseThunderstoreService extends BaseJsonService {
+  static thunderstoreGreen = '23FFB0'
+
+  /**
+   * Fetches package metadata from the Thunderstore API.
+   *
+   * @param {object} pkg - Package specifier
+   * @param {string} pkg.namespace - the package namespace
+   * @param {string} pkg.packageName - the package name
+   * @returns {Promise<object>} - Promise containing validated package information
+   */
+  async fetchPackage({ namespace, packageName }) {
+    return this._requestJson({
+      schema: packageSchema,
+      url: `https://thunderstore.io/api/experimental/package/${namespace}/${packageName}`,
+    })
+  }
+
+  /**
+   * Fetches package metrics from the Thunderstore API.
+   *
+   * @param {object} pkg - Package specifier
+   * @param {string} pkg.namespace - the package namespace
+   * @param {string} pkg.packageName - the package name
+   * @returns {Promise<object>} - Promise containing validated package metrics
+   */
+  async fetchPackageMetrics({ namespace, packageName }) {
+    return this._requestJson({
+      schema: packageMetricsSchema,
+      url: `https://thunderstore.io/api/v1/package-metrics/${namespace}/${packageName}`,
+    })
+  }
+}
+
+export { BaseThunderstoreService, documentation }
diff --git a/services/thunderstore/thunderstore-downloads.service.js b/services/thunderstore/thunderstore-downloads.service.js
new file mode 100644
index 0000000000000000000000000000000000000000..b642f925eccc3ed7560fd114bc1da6739df26c9b
--- /dev/null
+++ b/services/thunderstore/thunderstore-downloads.service.js
@@ -0,0 +1,41 @@
+import { renderDownloadsBadge } from '../downloads.js'
+import { BaseThunderstoreService, documentation } from './thunderstore-base.js'
+
+export default class ThunderstoreDownloads extends BaseThunderstoreService {
+  static category = 'downloads'
+
+  static route = {
+    base: 'thunderstore/dt',
+    pattern: ':namespace/:packageName',
+  }
+
+  static examples = [
+    {
+      title: 'Thunderstore Downloads',
+      namedParams: {
+        namespace: 'notnotnotswipez',
+        packageName: 'MoreCompany',
+      },
+      staticPreview: renderDownloadsBadge({ downloads: 120000 }),
+      documentation,
+    },
+  ]
+
+  static defaultBadgeData = {
+    label: 'downloads',
+  }
+
+  /**
+   * @param {object} pkg - Package specifier
+   * @param {string} pkg.namespace - the package namespace
+   * @param {string} pkg.packageName - the package name
+   * @returns {Promise<object>} - Promise containing the rendered badge payload
+   */
+  async handle({ namespace, packageName }) {
+    const { downloads } = await this.fetchPackageMetrics({
+      namespace,
+      packageName,
+    })
+    return renderDownloadsBadge({ downloads })
+  }
+}
diff --git a/services/thunderstore/thunderstore-downloads.tester.js b/services/thunderstore/thunderstore-downloads.tester.js
new file mode 100644
index 0000000000000000000000000000000000000000..7649798a4aef28b48276bdede6898b4364526656
--- /dev/null
+++ b/services/thunderstore/thunderstore-downloads.tester.js
@@ -0,0 +1,12 @@
+import { createServiceTester } from '../tester.js'
+import { isMetric } from '../test-validators.js'
+
+export const t = await createServiceTester()
+
+t.create('Downloads')
+  .get('/ebkr/r2modman.json')
+  .expectBadge({ label: 'downloads', message: isMetric })
+
+t.create('Downloads (not found)')
+  .get('/not-a-namespace/not-a-package-name.json')
+  .expectBadge({ label: 'downloads', message: 'not found', color: 'red' })
diff --git a/services/thunderstore/thunderstore-likes.service.js b/services/thunderstore/thunderstore-likes.service.js
new file mode 100644
index 0000000000000000000000000000000000000000..4304c308b992062b48710ffe9685820d57c4e034
--- /dev/null
+++ b/services/thunderstore/thunderstore-likes.service.js
@@ -0,0 +1,53 @@
+import { metric } from '../text-formatters.js'
+import { BaseThunderstoreService, documentation } from './thunderstore-base.js'
+
+export default class ThunderstoreLikes extends BaseThunderstoreService {
+  static category = 'social'
+
+  static route = {
+    base: 'thunderstore/likes',
+    pattern: ':namespace/:packageName',
+  }
+
+  static examples = [
+    {
+      title: 'Thunderstore Likes',
+      namedParams: {
+        namespace: 'notnotnotswipez',
+        packageName: 'MoreCompany',
+      },
+      staticPreview: {
+        label: 'likes',
+        message: '150',
+        style: 'social',
+      },
+      documentation,
+    },
+  ]
+
+  static defaultBadgeData = {
+    label: 'likes',
+    namedLogo: 'thunderstore',
+  }
+
+  static render({ likes }) {
+    return {
+      message: metric(likes),
+      color: `#${this.thunderstoreGreen}`,
+    }
+  }
+
+  /**
+   * @param {object} pkg - Package specifier
+   * @param {string} pkg.namespace - the package namespace
+   * @param {string} pkg.packageName - the package name
+   * @returns {Promise<object>} - Promise containing the rendered badge payload
+   */
+  async handle({ namespace, packageName }) {
+    const { rating_score: likes } = await this.fetchPackageMetrics({
+      namespace,
+      packageName,
+    })
+    return this.constructor.render({ likes })
+  }
+}
diff --git a/services/thunderstore/thunderstore-likes.tester.js b/services/thunderstore/thunderstore-likes.tester.js
new file mode 100644
index 0000000000000000000000000000000000000000..65af0a50144fbaf316a94a586a9a1c49907b7c31
--- /dev/null
+++ b/services/thunderstore/thunderstore-likes.tester.js
@@ -0,0 +1,12 @@
+import { createServiceTester } from '../tester.js'
+import { isMetric } from '../test-validators.js'
+
+export const t = await createServiceTester()
+
+t.create('Likes')
+  .get('/ebkr/r2modman.json')
+  .expectBadge({ label: 'likes', message: isMetric })
+
+t.create('Likes (not found)')
+  .get('/not-a-namespace/not-a-package-name.json')
+  .expectBadge({ label: 'likes', message: 'not found', color: 'red' })
diff --git a/services/thunderstore/thunderstore-version.service.js b/services/thunderstore/thunderstore-version.service.js
new file mode 100644
index 0000000000000000000000000000000000000000..6618cf26b35718d843017d05dd06ee98723bf837
--- /dev/null
+++ b/services/thunderstore/thunderstore-version.service.js
@@ -0,0 +1,40 @@
+import { renderVersionBadge } from '../version.js'
+import { BaseThunderstoreService, documentation } from './thunderstore-base.js'
+
+export default class ThunderstoreVersion extends BaseThunderstoreService {
+  static category = 'version'
+
+  static route = {
+    base: 'thunderstore/v',
+    pattern: ':namespace/:packageName',
+  }
+
+  static examples = [
+    {
+      title: 'Thunderstore Version',
+      namedParams: {
+        namespace: 'notnotnotswipez',
+        packageName: 'MoreCompany',
+      },
+      staticPreview: renderVersionBadge({ version: '1.4.5' }),
+      documentation,
+    },
+  ]
+
+  static defaultBadgeData = {
+    label: 'thunderstore',
+  }
+
+  /**
+   * @param {object} pkg - Package specifier
+   * @param {string} pkg.namespace - the package namespace
+   * @param {string} pkg.packageName - the package name
+   * @returns {Promise<object>} - Promise containing the rendered badge payload
+   */
+  async handle({ namespace, packageName }) {
+    const {
+      latest: { version_number: version },
+    } = await this.fetchPackage({ namespace, packageName })
+    return renderVersionBadge({ version })
+  }
+}
diff --git a/services/thunderstore/thunderstore-version.tester.js b/services/thunderstore/thunderstore-version.tester.js
new file mode 100644
index 0000000000000000000000000000000000000000..d3afc05fbc32fdb06451876145f70c409c4e0f77
--- /dev/null
+++ b/services/thunderstore/thunderstore-version.tester.js
@@ -0,0 +1,12 @@
+import { createServiceTester } from '../tester.js'
+import { isSemver } from '../test-validators.js'
+
+export const t = await createServiceTester()
+
+t.create('Version')
+  .get('/ebkr/r2modman.json')
+  .expectBadge({ label: 'thunderstore', message: isSemver })
+
+t.create('Version (not found)')
+  .get('/not-a-namespace/not-a-package-name.json')
+  .expectBadge({ label: 'thunderstore', message: 'not found', color: 'red' })