diff --git a/.gitignore b/.gitignore
index a645da247bb26de62e2b39dfb4151ae1284ee9d3..5c8b82a5b47e6b0dd871a447a9bc12a97cd9af9a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -96,6 +96,7 @@ typings/
 badge-examples.json
 supported-features.json
 service-definitions.yml
+frontend/categories/*.yaml
 
 # Local runtime configuration.
 /config/local*.yml
diff --git a/core/base-service/base.js b/core/base-service/base.js
index 4befd03cc245f4dbdc894f0ac59fb6d498ecfef4..f60470fa3ba78cf0d843c2597769532531bf7947 100644
--- a/core/base-service/base.js
+++ b/core/base-service/base.js
@@ -140,6 +140,15 @@ class BaseService {
    */
   static examples = []
 
+  /**
+   * Optional: an OpenAPI Paths Object describing this service's
+   * route or routes in OpenAPI format.
+   *
+   * @see https://swagger.io/specification/#paths-object
+   * @abstract
+   */
+  static openApi = undefined
+
   static get _cacheLength() {
     const cacheLengths = {
       build: 30,
@@ -183,7 +192,7 @@ class BaseService {
   }
 
   static getDefinition() {
-    const { category, name, isDeprecated } = this
+    const { category, name, isDeprecated, openApi } = this
     const { base, format, pattern } = this.route
     const queryParams = getQueryParamNames(this.route)
 
@@ -200,7 +209,7 @@ class BaseService {
       route = undefined
     }
 
-    const result = { category, name, isDeprecated, route, examples }
+    const result = { category, name, isDeprecated, route, examples, openApi }
 
     assertValidServiceDefinition(result, `getDefinition() for ${this.name}`)
 
diff --git a/core/base-service/examples.js b/core/base-service/examples.js
index 6aa9ff03ed891c2869f0e18ed666103cd5009e4e..4f7f2342dfe7beafe6825805f58a1b3d59db3248 100644
--- a/core/base-service/examples.js
+++ b/core/base-service/examples.js
@@ -129,6 +129,7 @@ function transformExample(inExample, index, ServiceClass) {
     ServiceClass
   )
 
+  const category = categories.find(c => c.id === ServiceClass.category)
   return {
     title,
     example: {
@@ -146,9 +147,7 @@ function transformExample(inExample, index, ServiceClass) {
       style: style === 'flat' ? undefined : style,
       namedLogo,
     },
-    keywords: keywords.concat(
-      categories.find(c => c.id === ServiceClass.category).keywords
-    ),
+    keywords: category ? keywords.concat(category.keywords) : keywords,
     documentation: documentation ? { __html: documentation } : undefined,
   }
 }
diff --git a/core/base-service/openapi.js b/core/base-service/openapi.js
new file mode 100644
index 0000000000000000000000000000000000000000..1cb463a76c55297eee6fe058bf1eadc211bb8f97
--- /dev/null
+++ b/core/base-service/openapi.js
@@ -0,0 +1,335 @@
+const baseUrl = process.env.BASE_URL || 'https://img.shields.io'
+const globalParamRefs = [
+  { $ref: '#/components/parameters/style' },
+  { $ref: '#/components/parameters/logo' },
+  { $ref: '#/components/parameters/logoColor' },
+  { $ref: '#/components/parameters/label' },
+  { $ref: '#/components/parameters/labelColor' },
+  { $ref: '#/components/parameters/color' },
+  { $ref: '#/components/parameters/cacheSeconds' },
+  { $ref: '#/components/parameters/link' },
+]
+
+function getCodeSamples(altText) {
+  return [
+    {
+      lang: 'URL',
+      label: 'URL',
+      source: '$url',
+    },
+    {
+      lang: 'Markdown',
+      label: 'Markdown',
+      source: `![${altText}]($url)`,
+    },
+    {
+      lang: 'reStructuredText',
+      label: 'rSt',
+      source: `.. image:: $url\n:   alt: ${altText}`,
+    },
+    {
+      lang: 'AsciiDoc',
+      label: 'AsciiDoc',
+      source: `image:$url[${altText}]`,
+    },
+    {
+      lang: 'HTML',
+      label: 'HTML',
+      source: `<img alt="${altText}" src="$url">`,
+    },
+  ]
+}
+
+function pattern2openapi(pattern) {
+  return pattern
+    .replace(/:([A-Za-z0-9_\-.]+)(?=[/]?)/g, (matches, grp1) => `{${grp1}}`)
+    .replace(/\([^)]*\)/g, '')
+    .replace(/\+$/, '')
+}
+
+function getEnum(pattern, paramName) {
+  const re = new RegExp(`${paramName}\\(([A-Za-z0-9_\\-|]+)\\)`)
+  const match = pattern.match(re)
+  if (match === null) {
+    return undefined
+  }
+  if (!match[1].includes('|')) {
+    return undefined
+  }
+  return match[1].split('|')
+}
+
+function param2openapi(pattern, paramName, exampleValue, paramType) {
+  const outParam = {}
+  outParam.name = paramName
+  // We don't have description if we are building the OpenAPI spec from examples[]
+  outParam.in = paramType
+  if (paramType === 'path') {
+    outParam.required = true
+  } else {
+    /* Occasionally we do have required query params, but we can't
+    detect this if we are building the OpenAPI spec from examples[]
+    so just assume all query params are optional */
+    outParam.required = false
+  }
+
+  if (exampleValue === null && paramType === 'query') {
+    outParam.schema = { type: 'boolean' }
+    outParam.allowEmptyValue = true
+  } else {
+    outParam.schema = { type: 'string' }
+  }
+
+  if (paramType === 'path') {
+    outParam.schema.enum = getEnum(pattern, paramName)
+  }
+
+  outParam.example = exampleValue
+  return outParam
+}
+
+function getVariants(pattern) {
+  /*
+  given a URL pattern (which may include '/one/or/:more?/:optional/:parameters*')
+  return an array of all possible permutations:
+  [
+    '/one/or/:more/:optional/:parameters',
+    '/one/or/:optional/:parameters',
+    '/one/or/:more/:optional',
+    '/one/or/:optional',
+  ]
+  */
+  const patterns = [pattern.split('/')]
+  while (patterns.flat().find(p => p.endsWith('?') || p.endsWith('*'))) {
+    for (let i = 0; i < patterns.length; i++) {
+      const pattern = patterns[i]
+      for (let j = 0; j < pattern.length; j++) {
+        const path = pattern[j]
+        if (path.endsWith('?') || path.endsWith('*')) {
+          pattern[j] = path.slice(0, -1)
+          patterns.push(patterns[i].filter(p => p !== pattern[j]))
+        }
+      }
+    }
+  }
+  for (let i = 0; i < patterns.length; i++) {
+    patterns[i] = patterns[i].join('/')
+  }
+  return patterns
+}
+
+function examples2openapi(examples) {
+  const paths = {}
+  for (const example of examples) {
+    const patterns = getVariants(example.example.pattern)
+
+    for (const pattern of patterns) {
+      const openApiPattern = pattern2openapi(pattern)
+      if (
+        openApiPattern.includes('*') ||
+        openApiPattern.includes('?') ||
+        openApiPattern.includes('+') ||
+        openApiPattern.includes('(')
+      ) {
+        throw new Error(`unexpected characters in pattern '${openApiPattern}'`)
+      }
+
+      /*
+      There's several things going on in this block:
+      1. Filter out any examples for params that don't appear
+         in this variant of the route
+      2. Make sure we add params to the array
+         in the same order they appear in the route
+      3. If there are any params we don't have an example value for,
+         make sure they still appear in the pathParams array with
+         exampleValue == undefined anyway
+      */
+      const pathParams = []
+      for (const param of openApiPattern
+        .split('/')
+        .filter(p => p.startsWith('{') && p.endsWith('}'))) {
+        const paramName = param.slice(1, -1)
+        const exampleValue = example.example.namedParams[paramName]
+        pathParams.push(param2openapi(pattern, paramName, exampleValue, 'path'))
+      }
+
+      const queryParams = example.example.queryParams || {}
+
+      const parameters = [
+        ...pathParams,
+        ...Object.entries(queryParams).map(([paramName, exampleValue]) =>
+          param2openapi(pattern, paramName, exampleValue, 'query')
+        ),
+        ...globalParamRefs,
+      ]
+      paths[openApiPattern] = {
+        get: {
+          summary: example.title,
+          description: example?.documentation?.__html
+            .replace(/<br>/g, '<br />') // react does not like <br>
+            .replace(/{/g, '&#123;')
+            .replace(/}/g, '&#125;')
+            .replace(/<style>(.|\n)*?<\/style>/, ''), // workaround for w3c-validation TODO: remove later
+          parameters,
+          'x-code-samples': getCodeSamples(example.title),
+        },
+      }
+    }
+  }
+  return paths
+}
+
+function addGlobalProperties(endpoints) {
+  const paths = {}
+  for (const key of Object.keys(endpoints)) {
+    paths[key] = endpoints[key]
+    paths[key].get.parameters = [
+      ...paths[key].get.parameters,
+      ...globalParamRefs,
+    ]
+    paths[key].get['x-code-samples'] = getCodeSamples(paths[key].get.summary)
+  }
+  return paths
+}
+
+function services2openapi(services) {
+  const paths = {}
+  for (const service of services) {
+    if (service.openApi) {
+      // if the service declares its own OpenAPI definition, use that...
+      for (const [key, value] of Object.entries(
+        addGlobalProperties(service.openApi)
+      )) {
+        if (key in paths) {
+          throw new Error(`Conflicting route: ${key}`)
+        }
+        paths[key] = value
+      }
+    } else {
+      // ...otherwise do our best to build one from examples[]
+      for (const [key, value] of Object.entries(
+        examples2openapi(service.examples)
+      )) {
+        // allow conflicting routes for legacy examples
+        paths[key] = value
+      }
+    }
+  }
+  return paths
+}
+
+function category2openapi(category, services) {
+  const spec = {
+    openapi: '3.0.0',
+    info: {
+      version: '1.0.0',
+      title: category.name,
+      license: {
+        name: 'CC0',
+      },
+    },
+    servers: [{ url: baseUrl }],
+    components: {
+      parameters: {
+        style: {
+          name: 'style',
+          in: 'query',
+          required: false,
+          description:
+            'One of: flat (default), flat-square, plastic, for-the-badge, social',
+          schema: {
+            type: 'string',
+          },
+          example: 'flat',
+        },
+        logo: {
+          name: 'logo',
+          in: 'query',
+          required: false,
+          description:
+            'One of the named logos (bitcoin, dependabot, gitlab, npm, paypal, serverfault, stackexchange, superuser, telegram, travis) or simple-icons. All simple-icons are referenced using icon slugs. You can click the icon title on <a href="https://simpleicons.org/" rel="noopener noreferrer" target="_blank">simple-icons</a> to copy the slug or they can be found in the <a href="https://github.com/simple-icons/simple-icons/blob/master/slugs.md">slugs.md file</a> in the simple-icons repository.',
+          schema: {
+            type: 'string',
+          },
+          example: 'appveyor',
+        },
+        logoColor: {
+          name: 'logoColor',
+          in: 'query',
+          required: false,
+          description:
+            'The color of the logo (hex, rgb, rgba, hsl, hsla and css named colors supported). Supported for named logos and Shields logos but not for custom logos. For multicolor Shields logos, the corresponding named logo will be used and colored.',
+          schema: {
+            type: 'string',
+          },
+          example: 'violet',
+        },
+        label: {
+          name: 'label',
+          in: 'query',
+          required: false,
+          description:
+            'Override the default left-hand-side text (<a href="https://developer.mozilla.org/en-US/docs/Glossary/percent-encoding">URL-Encoding</a> needed for spaces or special characters!)',
+          schema: {
+            type: 'string',
+          },
+          example: 'healthiness',
+        },
+        labelColor: {
+          name: 'labelColor',
+          in: 'query',
+          required: false,
+          description:
+            'Background color of the left part (hex, rgb, rgba, hsl, hsla and css named colors supported).',
+          schema: {
+            type: 'string',
+          },
+          example: 'abcdef',
+        },
+        color: {
+          name: 'color',
+          in: 'query',
+          required: false,
+          description:
+            'Background color of the right part (hex, rgb, rgba, hsl, hsla and css named colors supported).',
+          schema: {
+            type: 'string',
+          },
+          example: 'fedcba',
+        },
+        cacheSeconds: {
+          name: 'cacheSeconds',
+          in: 'query',
+          required: false,
+          description:
+            'HTTP cache lifetime (rules are applied to infer a default value on a per-badge basis, any values specified below the default will be ignored).',
+          schema: {
+            type: 'string',
+          },
+          example: '3600',
+        },
+        link: {
+          name: 'link',
+          in: 'query',
+          required: false,
+          description:
+            'Specify what clicking on the left/right of a badge should do. Note that this only works when integrating your badge in an `<object>` HTML tag, but not an `<img>` tag or a markup language.',
+          style: 'form',
+          explode: true,
+          schema: {
+            type: 'array',
+            maxItems: 2,
+            items: {
+              type: 'string',
+            },
+          },
+        },
+      },
+    },
+    paths: services2openapi(services),
+  }
+
+  return spec
+}
+
+export { category2openapi }
diff --git a/core/base-service/openapi.spec.js b/core/base-service/openapi.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..8d6b3f09e46588d0b7a8934c2f04f6aa14c8776b
--- /dev/null
+++ b/core/base-service/openapi.spec.js
@@ -0,0 +1,379 @@
+import chai from 'chai'
+import { category2openapi } from './openapi.js'
+import BaseJsonService from './base-json.js'
+const { expect } = chai
+
+class OpenApiService extends BaseJsonService {
+  static category = 'build'
+  static route = { base: 'openapi/service', pattern: ':packageName/:distTag*' }
+
+  // this service defines its own API Paths Object
+  static openApi = {
+    '/openapi/service/{packageName}': {
+      get: {
+        summary: 'OpenApiService Summary',
+        description: 'OpenApiService Description',
+        parameters: [
+          {
+            name: 'packageName',
+            description: 'packageName description',
+            in: 'path',
+            required: true,
+            schema: { type: 'string' },
+            example: 'badge-maker',
+          },
+        ],
+      },
+    },
+    '/openapi/service/{packageName}/{distTag}': {
+      get: {
+        summary: 'OpenApiService Summary (with Tag)',
+        description: 'OpenApiService Description (with Tag)',
+        parameters: [
+          {
+            name: 'packageName',
+            description: 'packageName description',
+            in: 'path',
+            required: true,
+            schema: { type: 'string' },
+            example: 'badge-maker',
+          },
+          {
+            name: 'distTag',
+            description: 'distTag description',
+            in: 'path',
+            required: true,
+            schema: { type: 'string' },
+            example: 'latest',
+          },
+        ],
+      },
+    },
+  }
+}
+
+class LegacyService extends BaseJsonService {
+  static category = 'build'
+  static route = { base: 'legacy/service', pattern: ':packageName/:distTag*' }
+
+  // this service defines an Examples Array
+  static examples = [
+    {
+      title: 'LegacyService Title',
+      namedParams: { packageName: 'badge-maker' },
+      staticPreview: { label: 'build', message: 'passing' },
+      documentation: 'LegacyService Description',
+    },
+    {
+      title: 'LegacyService Title (with Tag)',
+      namedParams: { packageName: 'badge-maker', distTag: 'latest' },
+      staticPreview: { label: 'build', message: 'passing' },
+      documentation: 'LegacyService Description (with Tag)',
+    },
+  ]
+}
+
+const expected = {
+  openapi: '3.0.0',
+  info: { version: '1.0.0', title: 'build', license: { name: 'CC0' } },
+  servers: [{ url: 'https://img.shields.io' }],
+  components: {
+    parameters: {
+      style: {
+        name: 'style',
+        in: 'query',
+        required: false,
+        description:
+          'One of: flat (default), flat-square, plastic, for-the-badge, social',
+        schema: { type: 'string' },
+        example: 'flat',
+      },
+      logo: {
+        name: 'logo',
+        in: 'query',
+        required: false,
+        description:
+          'One of the named logos (bitcoin, dependabot, gitlab, npm, paypal, serverfault, stackexchange, superuser, telegram, travis) or simple-icons. All simple-icons are referenced using icon slugs. You can click the icon title on <a href="https://simpleicons.org/" rel="noopener noreferrer" target="_blank">simple-icons</a> to copy the slug or they can be found in the <a href="https://github.com/simple-icons/simple-icons/blob/master/slugs.md">slugs.md file</a> in the simple-icons repository.',
+        schema: { type: 'string' },
+        example: 'appveyor',
+      },
+      logoColor: {
+        name: 'logoColor',
+        in: 'query',
+        required: false,
+        description:
+          'The color of the logo (hex, rgb, rgba, hsl, hsla and css named colors supported). Supported for named logos and Shields logos but not for custom logos. For multicolor Shields logos, the corresponding named logo will be used and colored.',
+        schema: { type: 'string' },
+        example: 'violet',
+      },
+      label: {
+        name: 'label',
+        in: 'query',
+        required: false,
+        description:
+          'Override the default left-hand-side text (<a href="https://developer.mozilla.org/en-US/docs/Glossary/percent-encoding">URL-Encoding</a> needed for spaces or special characters!)',
+        schema: { type: 'string' },
+        example: 'healthiness',
+      },
+      labelColor: {
+        name: 'labelColor',
+        in: 'query',
+        required: false,
+        description:
+          'Background color of the left part (hex, rgb, rgba, hsl, hsla and css named colors supported).',
+        schema: { type: 'string' },
+        example: 'abcdef',
+      },
+      color: {
+        name: 'color',
+        in: 'query',
+        required: false,
+        description:
+          'Background color of the right part (hex, rgb, rgba, hsl, hsla and css named colors supported).',
+        schema: { type: 'string' },
+        example: 'fedcba',
+      },
+      cacheSeconds: {
+        name: 'cacheSeconds',
+        in: 'query',
+        required: false,
+        description:
+          'HTTP cache lifetime (rules are applied to infer a default value on a per-badge basis, any values specified below the default will be ignored).',
+        schema: { type: 'string' },
+        example: '3600',
+      },
+      link: {
+        name: 'link',
+        in: 'query',
+        required: false,
+        description:
+          'Specify what clicking on the left/right of a badge should do. Note that this only works when integrating your badge in an `<object>` HTML tag, but not an `<img>` tag or a markup language.',
+        style: 'form',
+        explode: true,
+        schema: { type: 'array', maxItems: 2, items: { type: 'string' } },
+      },
+    },
+  },
+  paths: {
+    '/openapi/service/{packageName}': {
+      get: {
+        summary: 'OpenApiService Summary',
+        description: 'OpenApiService Description',
+        parameters: [
+          {
+            name: 'packageName',
+            description: 'packageName description',
+            in: 'path',
+            required: true,
+            schema: { type: 'string' },
+            example: 'badge-maker',
+          },
+          { $ref: '#/components/parameters/style' },
+          { $ref: '#/components/parameters/logo' },
+          { $ref: '#/components/parameters/logoColor' },
+          { $ref: '#/components/parameters/label' },
+          { $ref: '#/components/parameters/labelColor' },
+          { $ref: '#/components/parameters/color' },
+          { $ref: '#/components/parameters/cacheSeconds' },
+          { $ref: '#/components/parameters/link' },
+        ],
+        'x-code-samples': [
+          { lang: 'URL', label: 'URL', source: '$url' },
+          {
+            lang: 'Markdown',
+            label: 'Markdown',
+            source: '![OpenApiService Summary]($url)',
+          },
+          {
+            lang: 'reStructuredText',
+            label: 'rSt',
+            source: '.. image:: $url\n:   alt: OpenApiService Summary',
+          },
+          {
+            lang: 'AsciiDoc',
+            label: 'AsciiDoc',
+            source: 'image:$url[OpenApiService Summary]',
+          },
+          {
+            lang: 'HTML',
+            label: 'HTML',
+            source: '<img alt="OpenApiService Summary" src="$url">',
+          },
+        ],
+      },
+    },
+    '/openapi/service/{packageName}/{distTag}': {
+      get: {
+        summary: 'OpenApiService Summary (with Tag)',
+        description: 'OpenApiService Description (with Tag)',
+        parameters: [
+          {
+            name: 'packageName',
+            description: 'packageName description',
+            in: 'path',
+            required: true,
+            schema: { type: 'string' },
+            example: 'badge-maker',
+          },
+          {
+            name: 'distTag',
+            description: 'distTag description',
+            in: 'path',
+            required: true,
+            schema: { type: 'string' },
+            example: 'latest',
+          },
+          { $ref: '#/components/parameters/style' },
+          { $ref: '#/components/parameters/logo' },
+          { $ref: '#/components/parameters/logoColor' },
+          { $ref: '#/components/parameters/label' },
+          { $ref: '#/components/parameters/labelColor' },
+          { $ref: '#/components/parameters/color' },
+          { $ref: '#/components/parameters/cacheSeconds' },
+          { $ref: '#/components/parameters/link' },
+        ],
+        'x-code-samples': [
+          { lang: 'URL', label: 'URL', source: '$url' },
+          {
+            lang: 'Markdown',
+            label: 'Markdown',
+            source: '![OpenApiService Summary (with Tag)]($url)',
+          },
+          {
+            lang: 'reStructuredText',
+            label: 'rSt',
+            source:
+              '.. image:: $url\n:   alt: OpenApiService Summary (with Tag)',
+          },
+          {
+            lang: 'AsciiDoc',
+            label: 'AsciiDoc',
+            source: 'image:$url[OpenApiService Summary (with Tag)]',
+          },
+          {
+            lang: 'HTML',
+            label: 'HTML',
+            source: '<img alt="OpenApiService Summary (with Tag)" src="$url">',
+          },
+        ],
+      },
+    },
+    '/legacy/service/{packageName}/{distTag}': {
+      get: {
+        summary: 'LegacyService Title (with Tag)',
+        description: 'LegacyService Description (with Tag)',
+        parameters: [
+          {
+            name: 'packageName',
+            in: 'path',
+            required: true,
+            schema: { type: 'string' },
+            example: 'badge-maker',
+          },
+          {
+            name: 'distTag',
+            in: 'path',
+            required: true,
+            schema: { type: 'string' },
+            example: 'latest',
+          },
+          { $ref: '#/components/parameters/style' },
+          { $ref: '#/components/parameters/logo' },
+          { $ref: '#/components/parameters/logoColor' },
+          { $ref: '#/components/parameters/label' },
+          { $ref: '#/components/parameters/labelColor' },
+          { $ref: '#/components/parameters/color' },
+          { $ref: '#/components/parameters/cacheSeconds' },
+          { $ref: '#/components/parameters/link' },
+        ],
+        'x-code-samples': [
+          { lang: 'URL', label: 'URL', source: '$url' },
+          {
+            lang: 'Markdown',
+            label: 'Markdown',
+            source: '![LegacyService Title (with Tag)]($url)',
+          },
+          {
+            lang: 'reStructuredText',
+            label: 'rSt',
+            source: '.. image:: $url\n:   alt: LegacyService Title (with Tag)',
+          },
+          {
+            lang: 'AsciiDoc',
+            label: 'AsciiDoc',
+            source: 'image:$url[LegacyService Title (with Tag)]',
+          },
+          {
+            lang: 'HTML',
+            label: 'HTML',
+            source: '<img alt="LegacyService Title (with Tag)" src="$url">',
+          },
+        ],
+      },
+    },
+    '/legacy/service/{packageName}': {
+      get: {
+        summary: 'LegacyService Title (with Tag)',
+        description: 'LegacyService Description (with Tag)',
+        parameters: [
+          {
+            name: 'packageName',
+            in: 'path',
+            required: true,
+            schema: { type: 'string' },
+            example: 'badge-maker',
+          },
+          { $ref: '#/components/parameters/style' },
+          { $ref: '#/components/parameters/logo' },
+          { $ref: '#/components/parameters/logoColor' },
+          { $ref: '#/components/parameters/label' },
+          { $ref: '#/components/parameters/labelColor' },
+          { $ref: '#/components/parameters/color' },
+          { $ref: '#/components/parameters/cacheSeconds' },
+          { $ref: '#/components/parameters/link' },
+        ],
+        'x-code-samples': [
+          { lang: 'URL', label: 'URL', source: '$url' },
+          {
+            lang: 'Markdown',
+            label: 'Markdown',
+            source: '![LegacyService Title (with Tag)]($url)',
+          },
+          {
+            lang: 'reStructuredText',
+            label: 'rSt',
+            source: '.. image:: $url\n:   alt: LegacyService Title (with Tag)',
+          },
+          {
+            lang: 'AsciiDoc',
+            label: 'AsciiDoc',
+            source: 'image:$url[LegacyService Title (with Tag)]',
+          },
+          {
+            lang: 'HTML',
+            label: 'HTML',
+            source: '<img alt="LegacyService Title (with Tag)" src="$url">',
+          },
+        ],
+      },
+    },
+  },
+}
+
+function clean(obj) {
+  // remove any undefined values in the object
+  return JSON.parse(JSON.stringify(obj))
+}
+
+describe('category2openapi', function () {
+  it('generates an Open API spec', function () {
+    expect(
+      clean(
+        category2openapi({ name: 'build' }, [
+          OpenApiService.getDefinition(),
+          LegacyService.getDefinition(),
+        ])
+      )
+    ).to.deep.equal(expected)
+  })
+})
diff --git a/core/base-service/service-definitions.js b/core/base-service/service-definitions.js
index 1d66f4e91446c71f3b65c1d650686f0bdccc5b10..8718b58cbdf1a7743fc4d32a00e275917b56b8c5 100644
--- a/core/base-service/service-definitions.js
+++ b/core/base-service/service-definitions.js
@@ -46,6 +46,28 @@ const serviceDefinition = Joi.object({
       })
     )
     .default([]),
+  openApi: Joi.object().pattern(
+    /./,
+    Joi.object({
+      get: Joi.object({
+        summary: Joi.string().required(),
+        description: Joi.string().required(),
+        parameters: Joi.array()
+          .items(
+            Joi.object({
+              name: Joi.string().required(),
+              description: Joi.string(),
+              in: Joi.string().valid('query', 'path').required(),
+              required: Joi.boolean().required(),
+              schema: Joi.object({ type: Joi.string().required() }).required(),
+              example: Joi.string(),
+            })
+          )
+          .min(1)
+          .required(),
+      }).required(),
+    }).required()
+  ),
 }).required()
 
 function assertValidServiceDefinition(example, message = undefined) {
diff --git a/frontend/categories/.gitkeep b/frontend/categories/.gitkeep
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/scripts/export-openapi-cli.js b/scripts/export-openapi-cli.js
new file mode 100644
index 0000000000000000000000000000000000000000..bc0481ba17576097d57585859dd2e0fac706ecb1
--- /dev/null
+++ b/scripts/export-openapi-cli.js
@@ -0,0 +1,49 @@
+import fs from 'fs'
+import path from 'path'
+import yaml from 'js-yaml'
+import { collectDefinitions } from '../core/base-service/loader.js'
+import { category2openapi } from '../core/base-service/openapi.js'
+
+const specsPath = path.join('frontend', 'categories')
+
+function writeSpec(filename, spec) {
+  // Omit undefined
+  // https://github.com/nodeca/js-yaml/issues/356#issuecomment-312430599
+  const cleaned = JSON.parse(JSON.stringify(spec))
+
+  fs.writeFileSync(
+    filename,
+    yaml.dump(cleaned, { flowLevel: 5, forceQuotes: true })
+  )
+}
+
+;(async () => {
+  const definitions = await collectDefinitions()
+
+  for (const category of definitions.categories) {
+    const services = definitions.services.filter(
+      service => service.category === category.id && !service.isDeprecated
+    )
+
+    writeSpec(
+      path.join(specsPath, `${category.id}.yaml`),
+      category2openapi(category, services)
+    )
+  }
+
+  let coreServices = []
+  coreServices = coreServices.concat(
+    definitions.services.filter(
+      service => service.category === 'static' && !service.isDeprecated
+    )
+  )
+  coreServices = coreServices.concat(
+    definitions.services.filter(
+      service => service.category === 'dynamic' && !service.isDeprecated
+    )
+  )
+  writeSpec(
+    path.join(specsPath, '1core.yaml'),
+    category2openapi({ name: 'Core' }, coreServices)
+  )
+})()
diff --git a/scripts/export-service-definitions-cli.js b/scripts/export-service-definitions-cli.js
index e019bbcd175cbd29956c509e740ab9c51c7838c5..b953b76d736904d4ea29b9082637fb81fdd811ca 100644
--- a/scripts/export-service-definitions-cli.js
+++ b/scripts/export-service-definitions-cli.js
@@ -3,6 +3,19 @@ import { collectDefinitions } from '../core/base-service/loader.js'
 ;(async () => {
   const definitions = await collectDefinitions()
 
+  // filter out static, dynamic and debug badge examples
+  const publicCategories = definitions.categories.map(c => c.id)
+  definitions.services = definitions.services.filter(s =>
+    publicCategories.includes(s.category)
+  )
+
+  // drop the openApi property for the "legacy" frontend
+  for (const service of definitions.services) {
+    if (service.openApi) {
+      service.openApi = undefined
+    }
+  }
+
   // Omit undefined
   // https://github.com/nodeca/js-yaml/issues/356#issuecomment-312430599
   const cleaned = JSON.parse(JSON.stringify(definitions))
diff --git a/services/dynamic/dynamic-json.service.js b/services/dynamic/dynamic-json.service.js
index 5e01de94d1cde560ac1fb2eb234826cbc7db6347..0dc3fa56a879827e4892984297130bac1db5ad72 100644
--- a/services/dynamic/dynamic-json.service.js
+++ b/services/dynamic/dynamic-json.service.js
@@ -6,6 +6,53 @@ import jsonPath from './json-path.js'
 export default class DynamicJson extends jsonPath(BaseJsonService) {
   static enabledMetrics = [MetricNames.SERVICE_RESPONSE_SIZE]
   static route = createRoute('json')
+  static openApi = {
+    '/badge/dynamic/json': {
+      get: {
+        summary: 'Dynamic JSON Badge',
+        description: `<p>
+          The Dynamic JSON Badge allows you to extract an arbitrary value from any
+          JSON Document using a JSONPath selector and show it on a badge.
+        </p>`,
+        parameters: [
+          {
+            name: 'url',
+            description: 'The URL to a JSON document',
+            in: 'query',
+            required: true,
+            schema: { type: 'string' },
+            example:
+              'https://github.com/badges/shields/raw/master/package.json',
+          },
+          {
+            name: 'query',
+            description:
+              'A <a href="https://jsonpath.com/">JSONPath</a> expression that will be used to query the document',
+            in: 'query',
+            required: true,
+            schema: { type: 'string' },
+            example: '$.name',
+          },
+          {
+            name: 'prefix',
+            description: 'Optional prefix to append to the value',
+            in: 'query',
+            required: false,
+            schema: { type: 'string' },
+            example: '[',
+          },
+          {
+            name: 'suffix',
+            description: 'Optional suffix to append to the value',
+            in: 'query',
+            required: false,
+            schema: { type: 'string' },
+            example: ']',
+          },
+        ],
+      },
+    },
+  }
 
   async fetch({ schema, url, errorMessages }) {
     return this._requestJson({
diff --git a/services/dynamic/dynamic-xml.service.js b/services/dynamic/dynamic-xml.service.js
index 2278f7260a30565efad52cc07860394933d0a8dd..7696f9e3e041e000e632d160ac82ff28df0c3aa3 100644
--- a/services/dynamic/dynamic-xml.service.js
+++ b/services/dynamic/dynamic-xml.service.js
@@ -15,6 +15,53 @@ export default class DynamicXml extends BaseService {
   static category = 'dynamic'
   static enabledMetrics = [MetricNames.SERVICE_RESPONSE_SIZE]
   static route = createRoute('xml')
+  static openApi = {
+    '/badge/dynamic/xml': {
+      get: {
+        summary: 'Dynamic XML Badge',
+        description: `<p>
+          The Dynamic XML Badge allows you to extract an arbitrary value from any
+          XML Document using an XPath selector and show it on a badge.
+        </p>`,
+        parameters: [
+          {
+            name: 'url',
+            description: 'The URL to a XML document',
+            in: 'query',
+            required: true,
+            schema: { type: 'string' },
+            example: 'https://httpbin.org/xml',
+          },
+          {
+            name: 'query',
+            description:
+              'A <a href="http://xpather.com/">XPath</a> expression that will be used to query the document',
+            in: 'query',
+            required: true,
+            schema: { type: 'string' },
+            example: '//slideshow/slide[1]/title',
+          },
+          {
+            name: 'prefix',
+            description: 'Optional prefix to append to the value',
+            in: 'query',
+            required: false,
+            schema: { type: 'string' },
+            example: '[',
+          },
+          {
+            name: 'suffix',
+            description: 'Optional suffix to append to the value',
+            in: 'query',
+            required: false,
+            schema: { type: 'string' },
+            example: ']',
+          },
+        ],
+      },
+    },
+  }
+
   static defaultBadgeData = { label: 'custom badge' }
 
   transform({ pathExpression, buffer }) {
diff --git a/services/dynamic/dynamic-yaml.service.js b/services/dynamic/dynamic-yaml.service.js
index 1c606f90334ea1678af4d6ee5a2fc27ef244f2d2..46f84dd8e08b3cdf6fb4b6fb50c280f8d8cf111b 100644
--- a/services/dynamic/dynamic-yaml.service.js
+++ b/services/dynamic/dynamic-yaml.service.js
@@ -6,6 +6,53 @@ import jsonPath from './json-path.js'
 export default class DynamicYaml extends jsonPath(BaseYamlService) {
   static enabledMetrics = [MetricNames.SERVICE_RESPONSE_SIZE]
   static route = createRoute('yaml')
+  static openApi = {
+    '/badge/dynamic/yaml': {
+      get: {
+        summary: 'Dynamic YAML Badge',
+        description: `<p>
+          The Dynamic YAML Badge allows you to extract an arbitrary value from any
+          YAML Document using a JSONPath selector and show it on a badge.
+        </p>`,
+        parameters: [
+          {
+            name: 'url',
+            description: 'The URL to a YAML document',
+            in: 'query',
+            required: true,
+            schema: { type: 'string' },
+            example:
+              'https://raw.githubusercontent.com/badges/shields/master/.github/dependabot.yml',
+          },
+          {
+            name: 'query',
+            description:
+              'A <a href="https://jsonpath.com/">JSONPath</a> expression that will be used to query the document',
+            in: 'query',
+            required: true,
+            schema: { type: 'string' },
+            example: '$.version',
+          },
+          {
+            name: 'prefix',
+            description: 'Optional prefix to append to the value',
+            in: 'query',
+            required: false,
+            schema: { type: 'string' },
+            example: '[',
+          },
+          {
+            name: 'suffix',
+            description: 'Optional suffix to append to the value',
+            in: 'query',
+            required: false,
+            schema: { type: 'string' },
+            example: ']',
+          },
+        ],
+      },
+    },
+  }
 
   async fetch({ schema, url, errorMessages }) {
     return this._requestYaml({
diff --git a/services/endpoint/endpoint.service.js b/services/endpoint/endpoint.service.js
index 8a64ccc74ca88dbb509b4fd7091830ba47a4eae1..26be6f8402de4e7a4dbfe74295bf2f543375b400 100644
--- a/services/endpoint/endpoint.service.js
+++ b/services/endpoint/endpoint.service.js
@@ -11,14 +11,144 @@ const queryParamSchema = Joi.object({
   url: optionalUrl.required(),
 }).required()
 
+const description = `<p>
+  Using the endpoint badge, you can provide content for a badge through
+  a JSON endpoint. The content can be prerendered, or generated on the
+  fly. To strike a balance between responsiveness and bandwidth
+  utilization on one hand, and freshness on the other, cache behavior is
+  configurable, subject to the Shields minimum. The endpoint URL is
+  provided to Shields through the query string. Shields fetches it and
+  formats the badge.
+</p>
+<p>
+  The endpoint badge takes a single required query param: <code>url</code>, which is the URL to your JSON endpoint
+</p>
+<div>
+  <h2>Example JSON Endpoint Response</h2>
+  <code>&#123; "schemaVersion": 1, "label": "hello", "message": "sweet world", "color": "orange" &#125;</code>
+  <h2>Example Shields Response</h2>
+  <img src="https://img.shields.io/badge/hello-sweet_world-orange" />
+</div>
+<div>
+  <h2>Schema</h2>
+  <table>
+    <tbody>
+      <tr>
+        <th>Property</th>
+        <th>Description</th>
+      </tr>
+      <tr>
+        <td><code>schemaVersion</code></td>
+        <td>Required. Always the number <code>1</code>.</td>
+      </tr>
+      <tr>
+        <td><code>label</code></td>
+        <td>
+          Required. The left text, or the empty string to omit the left side of
+          the badge. This can be overridden by the query string.
+        </td>
+      </tr>
+      <tr>
+        <td><code>message</code></td>
+        <td>Required. Can't be empty. The right text.</td>
+      </tr>
+      <tr>
+        <td><code>color</code></td>
+        <td>
+          Default: <code>lightgrey</code>. The right color. Supports the eight
+          named colors above, as well as hex, rgb, rgba, hsl, hsla and css named
+          colors. This can be overridden by the query string.
+        </td>
+      </tr>
+      <tr>
+        <td><code>labelColor</code></td>
+        <td>
+          Default: <code>grey</code>. The left color. This can be overridden by
+          the query string.
+        </td>
+      </tr>
+      <tr>
+        <td><code>isError</code></td>
+        <td>
+          Default: <code>false</code>. <code>true</code> to treat this as an
+          error badge. This prevents the user from overriding the color. In the
+          future, it may affect cache behavior.
+        </td>
+      </tr>
+      <tr>
+        <td><code>namedLogo</code></td>
+        <td>
+          Default: none. One of the named logos supported by Shields or
+          <a href="https://simpleicons.org/">simple-icons</a>. Can be overridden
+          by the query string.
+        </td>
+      </tr>
+      <tr>
+        <td><code>logoSvg</code></td>
+        <td>Default: none. An SVG string containing a custom logo.</td>
+      </tr>
+      <tr>
+        <td><code>logoColor</code></td>
+        <td>
+          Default: none. Same meaning as the query string. Can be overridden by
+          the query string. Only works for named logos and Shields logos. If you
+          override the color of a multicolor Shield logo, the corresponding
+          named logo will be used and colored.
+        </td>
+      </tr>
+      <tr>
+        <td><code>logoWidth</code></td>
+        <td>
+          Default: none. Same meaning as the query string. Can be overridden by
+          the query string.
+        </td>
+      </tr>
+      <tr>
+        <td><code>logoPosition</code></td>
+        <td>
+          Default: none. Same meaning as the query string. Can be overridden by
+          the query string.
+        </td>
+      </tr>
+      <tr>
+        <td><code>style</code></td>
+        <td>
+          Default: <code>flat</code>. The default template to use. Can be
+          overridden by the query string.
+        </td>
+      </tr>
+    </tbody>
+  </table>
+</div>`
+
 export default class Endpoint extends BaseJsonService {
   static category = 'dynamic'
+
   static route = {
     base: 'endpoint',
     pattern: '',
     queryParamSchema,
   }
 
+  static openApi = {
+    '/endpoint': {
+      get: {
+        summary: 'Endpoint Badge',
+        description,
+        parameters: [
+          {
+            name: 'url',
+            description: 'The URL to your JSON endpoint',
+            in: 'query',
+            required: true,
+            schema: { type: 'string' },
+            example: 'https://shields.redsparr0w.com/2473/monday',
+          },
+        ],
+      },
+    },
+  }
+
   static _cacheLength = 300
   static defaultBadgeData = { label: 'custom badge' }
 
diff --git a/services/static-badge/static-badge.service.js b/services/static-badge/static-badge.service.js
index 0bcbe488e3b5f2684363987fed54e5ea56e878b2..257cdb626001d52af12b5f06dd1c3d30be77d47e 100644
--- a/services/static-badge/static-badge.service.js
+++ b/services/static-badge/static-badge.service.js
@@ -1,15 +1,73 @@
 import { escapeFormat } from '../../core/badge-urls/path-helpers.js'
 import { BaseStaticService } from '../index.js'
 
+const description = `<p>
+  The static badge accepts a single required path parameter which encodes either:
+</p>
+<ul>
+  <li>
+    Label, message and color seperated by a dash <code>-</code>. For example:<br />
+    <img alt="any text: you like" src="https://img.shields.io/badge/any_text-you_like-blue" /> -
+    <a href="https://img.shields.io/badge/any_text-you_like-blue">https://img.shields.io/badge/any_text-you_like-blue</a>
+  </li>
+  <li>
+    Message and color only, seperated by a dash <code>-</code>. For example:<br />
+    <img alt="just the message" src="https://img.shields.io/badge/just%20the%20message-8A2BE2" /> -
+    <a href="https://img.shields.io/badge/just%20the%20message-8A2BE2">https://img.shields.io/badge/just%20the%20message-8A2BE2</a>
+  </li>
+</ul>
+<table>
+  <tbody>
+    <tr>
+      <th>URL input</th>
+      <th>Badge output</th>
+    </tr>
+    <tr>
+      <td>Underscore <code>_</code> or <code>%20</code></td>
+      <td>Space <code>&nbsp;</code></td>
+    </tr>
+    <tr>
+      <td>Double underscore <code>__</code></td>
+      <td>Underscore <code>_</code></td>
+    </tr>
+    <tr>
+      <td>Double dash <code>--</code></td>
+      <td>Dash <code>-</code></td>
+    </tr>
+  </tbody>
+</table>
+<p>
+  Hex, rgb, rgba, hsl, hsla and css named colors may be used.
+</p>`
+
 export default class StaticBadge extends BaseStaticService {
   static category = 'static'
-
   static route = {
     base: '',
     format: '(?::|badge/)((?:[^-]|--)*?)-?((?:[^-]|--)*)-((?:[^-.]|--)+)',
     capture: ['label', 'message', 'color'],
   }
 
+  static openApi = {
+    '/badge/{badgeContent}': {
+      get: {
+        summary: 'Static Badge',
+        description,
+        parameters: [
+          {
+            name: 'badgeContent',
+            description:
+              'Label, (optional) message, and color. Seperated by dashes',
+            in: 'path',
+            required: true,
+            schema: { type: 'string' },
+            example: 'build-passing-brightgreen',
+          },
+        ],
+      },
+    },
+  }
+
   handle({ label, message, color }) {
     return { label: escapeFormat(label), message: escapeFormat(message), color }
   }