diff --git a/services/gitea/gitea-last-commit.service.js b/services/gitea/gitea-last-commit.service.js
new file mode 100644
index 0000000000000000000000000000000000000000..cb40bb48becb70fe88ffdfb5110cee0f595f900e
--- /dev/null
+++ b/services/gitea/gitea-last-commit.service.js
@@ -0,0 +1,138 @@
+import Joi from 'joi'
+import { pathParam, queryParam } from '../index.js'
+import { optionalUrl } from '../validators.js'
+import { formatDate } from '../text-formatters.js'
+import { age as ageColor } from '../color-formatters.js'
+import GiteaBase from './gitea-base.js'
+import { description, httpErrorsFor } from './gitea-helper.js'
+
+const schema = Joi.array()
+  .items(
+    Joi.object({
+      commit: Joi.object({
+        author: Joi.object({
+          date: Joi.string().required(),
+        }).required(),
+        committer: Joi.object({
+          date: Joi.string().required(),
+        }).required(),
+      }).required(),
+    }).required(),
+  )
+  .required()
+  .min(1)
+
+const displayEnum = ['author', 'committer']
+
+const queryParamSchema = Joi.object({
+  gitea_url: optionalUrl,
+  display_timestamp: Joi.string()
+    .valid(...displayEnum)
+    .default('author'),
+}).required()
+
+export default class GiteaLastCommit extends GiteaBase {
+  static category = 'activity'
+
+  static route = {
+    base: 'gitea/last-commit',
+    pattern: ':user/:repo/:branch*',
+    queryParamSchema,
+  }
+
+  static openApi = {
+    '/gitea/last-commit/{user}/{repo}': {
+      get: {
+        summary: 'Gitea Last Commit',
+        description,
+        parameters: [
+          pathParam({
+            name: 'user',
+            example: 'gitea',
+          }),
+          pathParam({
+            name: 'repo',
+            example: 'tea',
+          }),
+          queryParam({
+            name: 'display_timestamp',
+            example: 'committer',
+            schema: { type: 'string', enum: displayEnum },
+            description: 'Defaults to `author` if not specified',
+          }),
+          queryParam({
+            name: 'gitea_url',
+            example: 'https://gitea.com',
+          }),
+        ],
+      },
+    },
+    '/gitea/last-commit/{user}/{repo}/{branch}': {
+      get: {
+        summary: 'Gitea Last Commit (branch)',
+        description,
+        parameters: [
+          pathParam({
+            name: 'user',
+            example: 'gitea',
+          }),
+          pathParam({
+            name: 'repo',
+            example: 'tea',
+          }),
+          pathParam({
+            name: 'branch',
+            example: 'main',
+          }),
+          queryParam({
+            name: 'display_timestamp',
+            example: 'committer',
+            schema: { type: 'string', enum: displayEnum },
+            description: 'Defaults to `author` if not specified',
+          }),
+          queryParam({
+            name: 'gitea_url',
+            example: 'https://gitea.com',
+          }),
+        ],
+      },
+    },
+  }
+
+  static defaultBadgeData = { label: 'last commit' }
+
+  static render({ commitDate }) {
+    return {
+      message: formatDate(commitDate),
+      color: ageColor(Date.parse(commitDate)),
+    }
+  }
+
+  async fetch({ user, repo, branch, baseUrl }) {
+    // https://gitea.com/api/swagger#/repository
+    return super.fetch({
+      schema,
+      url: `${baseUrl}/api/v1/repos/${user}/${repo}/commits`,
+      options: { searchParams: { sha: branch } },
+      httpErrors: httpErrorsFor(),
+    })
+  }
+
+  async handle(
+    { user, repo, branch },
+    {
+      gitea_url: baseUrl = 'https://gitea.com',
+      display_timestamp: displayTimestamp,
+    },
+  ) {
+    const body = await this.fetch({
+      user,
+      repo,
+      branch,
+      baseUrl,
+    })
+    return this.constructor.render({
+      commitDate: body[0].commit[displayTimestamp].date,
+    })
+  }
+}
diff --git a/services/gitea/gitea-last-commit.tester.js b/services/gitea/gitea-last-commit.tester.js
new file mode 100644
index 0000000000000000000000000000000000000000..bc1e133cbfd683b0ddc6fc6a41a868e6a26c870e
--- /dev/null
+++ b/services/gitea/gitea-last-commit.tester.js
@@ -0,0 +1,32 @@
+import { isFormattedDate } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+t.create('Last Commit (recent)').get('/gitea/tea.json').expectBadge({
+  label: 'last commit',
+  message: isFormattedDate,
+})
+
+t.create('Last Commit (recent) (self-managed)')
+  .get('/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org')
+  .expectBadge({
+    label: 'last commit',
+    message: isFormattedDate,
+  })
+
+t.create('Last Commit (on-branch) (self-managed)')
+  .get(
+    '/CanisHelix/shields-badge-test/scoped.json?gitea_url=https://codeberg.org',
+  )
+  .expectBadge({
+    label: 'last commit',
+    message: isFormattedDate,
+  })
+
+t.create('Last Commit (project not found)')
+  .get('/CanisHelix/does-not-exist.json?gitea_url=https://codeberg.org')
+  .expectBadge({
+    label: 'last commit',
+    message: 'user or repo not found',
+  })