diff --git a/core/base-service/validate.js b/core/base-service/validate.js
index 38adcebfdd9e4b8ad5581cd0f18f4bcccb00ac77..97f7c7a03e75dd7d0ef74a5f18dd0965fcbfb98b 100644
--- a/core/base-service/validate.js
+++ b/core/base-service/validate.js
@@ -11,6 +11,7 @@ function validate(
     includeKeys = false,
     traceErrorMessage = 'Data did not match schema',
     traceSuccessMessage = 'Data after validation',
+    allowAndStripUnknownKeys = true,
   },
   data,
   schema
@@ -18,10 +19,13 @@ function validate(
   if (!schema || !schema.isJoi) {
     throw Error('A Joi schema is required')
   }
-  const { error, value } = Joi.validate(data, schema, {
-    allowUnknown: true,
-    stripUnknown: true,
-  })
+  const options = allowAndStripUnknownKeys
+    ? {
+        allowUnknown: true,
+        stripUnknown: true,
+      }
+    : undefined
+  const { error, value } = Joi.validate(data, schema, options)
   if (error) {
     trace.logTrace(
       'validate',
diff --git a/core/base-service/validate.spec.js b/core/base-service/validate.spec.js
index 3a9afd8162c1da87adde6eb4588e9359300666fc..152a6423dff0f1ff6c5840065949f64eec03e7fe 100644
--- a/core/base-service/validate.spec.js
+++ b/core/base-service/validate.spec.js
@@ -105,4 +105,14 @@ describe('validate', function() {
       })
     })
   })
+
+  it('allowAndStripUnknownKeys', function() {
+    expect(() =>
+      validate(
+        { ...options, allowAndStripUnknownKeys: false },
+        { requiredString: 'bar', extra: 'nonsense' },
+        schema
+      )
+    ).to.throw(InvalidParameter, '"extra" is not allowed')
+  })
 })
diff --git a/frontend/components/endpoint-page.js b/frontend/components/endpoint-page.js
new file mode 100644
index 0000000000000000000000000000000000000000..c423676f51aa44d28e7cedc16caaf7ffaeb1e583
--- /dev/null
+++ b/frontend/components/endpoint-page.js
@@ -0,0 +1,217 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import styled from 'styled-components'
+import { staticBadgeUrl } from '../lib/badge-url'
+import { baseUrl } from '../constants'
+import Meta from './meta'
+import Header from './header'
+import Footer from './footer'
+import { H3, Badge } from './common'
+import { Snippet } from './snippet'
+
+const Explanation = styled.div`
+  max-width: 800px;
+  display: block;
+`
+
+const JsonExampleBlock = styled.code`
+  display: inline-block;
+
+  text-align: left;
+  line-height: 1.2em;
+  padding: 16px 18px;
+
+  border-radius: 4px;
+  background: #eef;
+
+  font-family: Lekton;
+  font-size: ${({ fontSize }) => fontSize};
+
+  white-space: pre;
+`
+
+const JsonExample = ({ data }) => (
+  <JsonExampleBlock>{JSON.stringify(data, undefined, 2)}</JsonExampleBlock>
+)
+JsonExample.propTypes = {
+  data: PropTypes.string.isRequired,
+}
+
+const Schema = styled.dl`
+  display: inline-block;
+  max-width: 800px;
+
+  margin: 0;
+  padding: 10px;
+  text-align: left;
+
+  background: #efefef;
+
+  clear: both;
+  overflow: hidden;
+
+  dt,
+  dd {
+    padding: 0 1%;
+    margin-top: 8px;
+    margin-bottom: 8px;
+    float: left;
+  }
+
+  dt {
+    width: 100px;
+    clear: both;
+  }
+
+  dd {
+    margin-left: 20px;
+    width: 75%;
+  }
+
+  @media (max-width: 600px) {
+    .data_table {
+      text-align: center;
+    }
+  }
+`
+
+const EndpointPage = () => (
+  <div>
+    <Meta />
+    <Header />
+    <H3 id="static-badge">Endpoint (Beta)</H3>
+    <Snippet snippet={`${baseUrl}/badge/endpoint.svg?url=...&style=...`} />
+    <p>Endpoint response:</p>
+    <JsonExample
+      data={{
+        schemaVersion: 1,
+        label: 'hello',
+        message: 'sweet world',
+        color: 'orange',
+      }}
+    />
+    <p>Shields response:</p>
+    <Badge
+      src={staticBadgeUrl(baseUrl, 'hello', 'sweet world', 'orange')}
+      alt="hello | sweet world"
+    />
+    <Explanation>
+      <p>
+        Developers rely on Shields for visual consistency and powerful
+        customization options. As a service provider or data provider, you can
+        use the endpoint badge to provide content while giving users the full
+        power of Shields' badge customization.
+      </p>
+      <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 bandwith 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 is a better alternative than redirecting to the
+        static badge enpoint or generating SVG on your server:
+      </p>
+      <ol>
+        <li>
+          <a href="https://en.wikipedia.org/wiki/Separation_of_content_and_presentation">
+            Content and presentation are separate.
+          </a>{' '}
+          The service provider authors the badge, and Shields takes input from
+          the user to format it. As a service provider you author the badge but
+          don't have to concern yourself with styling. You don't even have to
+          pass the formatting options through to Shields.
+        </li>
+        <li>
+          Badge formatting is always 100% up to date. There's no need to track
+          updates to the npm package, badge templates, or options.
+        </li>
+        <li>
+          A JSON response is easy to implement; easier than an HTTP redirect. It
+          is trivial in almost any framework, and is more compatible with
+          hosting environments such as{' '}
+          <a href="https://runkit.com/docs/endpoint">RunKit endpoints</a>.
+        </li>
+        <li>
+          As a service provider you can rely on the Shields CDN. There's no need
+          to study the HTTP headers. Adjusting cache behavior is as simple as
+          setting a property in the JSON response.
+        </li>
+      </ol>
+    </Explanation>
+    <h4>Schema</h4>
+    <p>
+      The schema may change during the beta period. Any changes will be posted
+      here. After launch, breaking changes will trigger an increment to the
+      `schemaVersion`.
+    </p>
+    <Schema>
+      <dt>schemaVersion</dt>
+      <dd>
+        Required. Always the number <code>1</code>.
+      </dd>
+      <dt>label</dt>
+      <dd>
+        Required. The left text, or the empty string to omit the left side of
+        the badge. This can be overridden by the query string.
+      </dd>
+      <dt>message</dt>
+      <dd>Required. Can't be empty. The right text.</dd>
+      <dt>color</dt>
+      <dd>
+        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.
+      </dd>
+      <dt>labelColor</dt>
+      <dd>
+        Default: <code>grey</code>. The left color.
+      </dd>
+      <dt>isError</dt>
+      <dd>
+        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.
+      </dd>
+      <dt>namedLogo</dt>
+      <dd>
+        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.
+      </dd>
+      <dt>logoSvg</dt>
+      <dd>Default: none. An SVG string containing a custom logo.</dd>
+      <dt>logoColor</dt>
+      <dd>
+        Default: none. Same meaning as the query string. Can be overridden by
+        the query string.
+      </dd>
+      <dt>logoWidth</dt>
+      <dd>
+        Default: none. Same meaning as the query string. Can be overridden by
+        the query string.
+      </dd>
+      <dt>logoPosition</dt>
+      <dd>
+        Default: none. Same meaning as the query string. Can be overridden by
+        the query string.
+      </dd>
+      <dt>style</dt>
+      <dd>
+        Default: <code>flat</code>. The default template to use. Can be
+        overridden by the query string.
+      </dd>
+      <dt>cacheSeconds</dt>
+      <dd>
+        Default: <code>300</code>. Set the HTTP cache lifetime in seconds, which
+        should respected by the Shields' CDN and downstream users. This lets you
+        tune performance and traffic vs. responsiveness. Can be overridden by
+        the user via the query string, but only to a longer value.
+      </dd>
+    </Schema>
+    <Footer baseUrl={baseUrl} />
+  </div>
+)
+export default EndpointPage
diff --git a/frontend/components/usage.js b/frontend/components/usage.js
index 1485ff74fe89916e3db0478f1bc3f582e45d14db..55cf369285f50a8a2193ab2575e37530e8009611 100644
--- a/frontend/components/usage.js
+++ b/frontend/components/usage.js
@@ -1,4 +1,5 @@
 import React, { Fragment } from 'react'
+import { Link } from 'react-router-dom'
 import PropTypes from 'prop-types'
 import styled from 'styled-components'
 import { staticBadgeUrl } from '../lib/badge-url'
@@ -193,6 +194,19 @@ export default class Usage extends React.PureComponent {
         {this.constructor.renderStaticBadgeEscapingRules()}
         {this.renderColorExamples()}
 
+        <H3 id="endpoint">Endpoint (Beta)</H3>
+
+        <p>
+          <Snippet
+            snippet={`${baseUrl}/badge/endpoint.svg?url=<URL>&style<STYLE>`}
+          />
+        </p>
+
+        <p>
+          Create badges from{' '}
+          <Link to={'/endpoint'}>your own JSON endpoint</Link>.
+        </p>
+
         <H3 id="dynamic-badge">Dynamic</H3>
 
         <DynamicBadgeMaker baseUrl={baseUrl} />
diff --git a/gh-badges/templates/_shields_test-template.json b/gh-badges/templates/_shields_test-template.json
index e39706a9568a586dbaac7894781c387dcad68e3f..af44570bba65ede7be4b8f8b6b2e94ee591d034a 100644
--- a/gh-badges/templates/_shields_test-template.json
+++ b/gh-badges/templates/_shields_test-template.json
@@ -2,6 +2,9 @@
   "color": {{=JSON.stringify(it.color || null)}},
 {{?it.labelColor}}
   "labelColor": {{=JSON.stringify(it.labelColor)}},
+{{?}}
+{{?it.logoWidth}}
+  "logoWidth": {{=JSON.stringify(it.logoWidth)}},
 {{?}}
   "name": {{=JSON.stringify(it.text[0])}},
   "value": {{=JSON.stringify(it.text[1])}}
diff --git a/pages/index.js b/pages/index.js
index 90cd8635e6771425025d316cd6c425b8b417448a..5ba840123baf3386103af37083e84e0adf64d6bc 100644
--- a/pages/index.js
+++ b/pages/index.js
@@ -1,6 +1,7 @@
 import React from 'react'
 import { HashRouter, StaticRouter, Route } from 'react-router-dom'
 import Main from '../frontend/components/main'
+import EndpointPage from '../frontend/components/endpoint-page'
 
 export default class Router extends React.Component {
   render() {
@@ -8,6 +9,7 @@ export default class Router extends React.Component {
       <div>
         <Route path="/" exact component={Main} />
         <Route path="/examples/:category" component={Main} />
+        <Route path="/endpoint" component={EndpointPage} />
       </div>
     )
 
diff --git a/services/base.js b/services/base.js
index c2067ec3d39ba42f0bfd772959649347ee841f1a..d8765290032387ce4a2e14204f0ea7a21455dd7b 100644
--- a/services/base.js
+++ b/services/base.js
@@ -32,6 +32,15 @@ const defaultBadgeDataSchema = Joi.object({
   namedLogo: Joi.string(),
 }).required()
 
+const optionalStringWhenNamedLogoPrsent = Joi.alternatives().when('namedLogo', {
+  is: Joi.string().required(),
+  then: Joi.string(),
+})
+
+const optionalNumberWhenAnyLogoPresent = Joi.alternatives()
+  .when('namedLogo', { is: Joi.string().required(), then: Joi.number() })
+  .when('logoSvg', { is: Joi.string().required(), then: Joi.number() })
+
 const serviceDataSchema = Joi.object({
   isError: Joi.boolean(),
   label: Joi.string().allow(''),
@@ -45,27 +54,15 @@ const serviceDataSchema = Joi.object({
   labelColor: Joi.string(),
   namedLogo: Joi.string(),
   logoSvg: Joi.string(),
-  logoColor: Joi.forbidden(),
-  logoWidth: Joi.forbidden(),
-  logoPosition: Joi.forbidden(),
-  cacheLengthSeconds: Joi.number()
+  logoColor: optionalStringWhenNamedLogoPrsent,
+  logoWidth: optionalNumberWhenAnyLogoPresent,
+  logoPosition: optionalNumberWhenAnyLogoPresent,
+  cacheSeconds: Joi.number()
     .integer()
     .min(0),
+  style: Joi.string(),
 })
   .oxor('namedLogo', 'logoSvg')
-  .when(
-    Joi.alternatives().try(
-      Joi.object({ namedLogo: Joi.string().required() }).unknown(),
-      Joi.object({ logoSvg: Joi.string().required() }).unknown()
-    ),
-    {
-      then: Joi.object({
-        logoColor: Joi.string(),
-        logoWidth: Joi.number(),
-        logoPosition: Joi.number(),
-      }),
-    }
-  )
   .required()
 
 class BaseService {
@@ -379,7 +376,7 @@ class BaseService {
   //    string.
   static _makeBadgeData(overrides, serviceData) {
     const {
-      style,
+      style: overrideStyle,
       label: overrideLabel,
       logoColor: overrideLogoColor,
       link: overrideLink,
@@ -415,7 +412,8 @@ class BaseService {
       logoWidth: serviceLogoWidth,
       logoPosition: serviceLogoPosition,
       link: serviceLink,
-      cacheLengthSeconds: serviceCacheLengthSeconds,
+      cacheSeconds: serviceCacheSeconds,
+      style: serviceStyle,
     } = serviceData
     const serviceLogoSvgBase64 = serviceLogoSvg
       ? svg2base64(serviceLogoSvg)
@@ -427,7 +425,9 @@ class BaseService {
       label: defaultLabel,
       labelColor: defaultLabelColor,
     } = this.defaultBadgeData
-    const defaultCacheLengthSeconds = this._cacheLength
+    const defaultCacheSeconds = this._cacheLength
+
+    const style = coalesce(overrideStyle, serviceStyle)
 
     const namedLogoSvgBase64 = prepareNamedLogo({
       name: coalesce(
@@ -480,10 +480,7 @@ class BaseService {
         overrideNamedLogo ? undefined : serviceLogoPosition
       ),
       links: toArray(overrideLink || serviceLink),
-      cacheLengthSeconds: coalesce(
-        serviceCacheLengthSeconds,
-        defaultCacheLengthSeconds
-      ),
+      cacheLengthSeconds: coalesce(serviceCacheSeconds, defaultCacheSeconds),
     }
   }
 
@@ -517,13 +514,14 @@ class BaseService {
     )
   }
 
-  static _validate(data, schema) {
+  static _validate(data, schema, { allowAndStripUnknownKeys = true } = {}) {
     return validate(
       {
         ErrorClass: InvalidResponse,
         prettyErrorMessage: 'invalid response data',
         traceErrorMessage: 'Response did not match schema',
         traceSuccessMessage: 'Response after validation',
+        allowAndStripUnknownKeys,
       },
       data,
       schema
diff --git a/services/base.spec.js b/services/base.spec.js
index 506d51442d9e18ece7af07614d0817e36b3ee369..f0b9d2cacd9248ef34c5dc65b0b43283e2b40353 100644
--- a/services/base.spec.js
+++ b/services/base.spec.js
@@ -507,7 +507,7 @@ describe('BaseService', function() {
       it('overrides the cache length', function() {
         const badgeData = DummyService._makeBadgeData(
           { style: 'pill' },
-          { cacheLengthSeconds: 123 }
+          { cacheSeconds: 123 }
         )
         expect(badgeData.cacheLengthSeconds).to.equal(123)
       })
diff --git a/services/cache-headers.js b/services/cache-headers.js
index a9370a290fe382ca95f6207ee0db8d0ad2f38fe7..58ecc7e19285f67a196beb8d98b24c334b301b63 100644
--- a/services/cache-headers.js
+++ b/services/cache-headers.js
@@ -12,7 +12,9 @@ const queryParamSchema = Joi.object({
   maxAge: Joi.number()
     .integer()
     .min(0),
-}).required()
+})
+  .unknown(true)
+  .required()
 
 function overrideCacheLengthFromQueryParams(queryParams) {
   try {
diff --git a/services/cache-headers.spec.js b/services/cache-headers.spec.js
index 71681b76b8b9e87b0a6aae1c8ce06d4bc4d8caef..a2806065a21a73759207e10a20bd29c7875720b4 100644
--- a/services/cache-headers.spec.js
+++ b/services/cache-headers.spec.js
@@ -35,6 +35,11 @@ describe('Cache header functions', function() {
         serviceDefaultCacheLengthSeconds: 900,
         queryParams: { maxAge: 1000 },
       }).expect(1000)
+      given({
+        cacheHeaderConfig,
+        serviceDefaultCacheLengthSeconds: 900,
+        queryParams: { maxAge: 1000, other: 'here', maybe: 'bogus' },
+      }).expect(1000)
       given({
         cacheHeaderConfig,
         serviceDefaultCacheLengthSeconds: 900,
diff --git a/services/endpoint/endpoint.service.js b/services/endpoint/endpoint.service.js
new file mode 100644
index 0000000000000000000000000000000000000000..ed9fa31ce8e77481827da0fbd15218e4e8776888
--- /dev/null
+++ b/services/endpoint/endpoint.service.js
@@ -0,0 +1,132 @@
+'use strict'
+
+const { URL } = require('url')
+const Joi = require('joi')
+const { errorMessages } = require('../dynamic/dynamic-helpers')
+const BaseJsonService = require('../base-json')
+const { InvalidParameter } = require('../errors')
+const { optionalUrl } = require('../validators')
+
+const blockedDomains = ['github.com', 'shields.io']
+
+const queryParamSchema = Joi.object({
+  url: optionalUrl.required(),
+}).required()
+
+const anySchema = Joi.any()
+
+const optionalStringWhenNamedLogoPresent = Joi.alternatives().when(
+  'namedLogo',
+  {
+    is: Joi.string().required(),
+    then: Joi.string(),
+  }
+)
+
+const optionalNumberWhenAnyLogoPresent = Joi.alternatives()
+  .when('namedLogo', { is: Joi.string().required(), then: Joi.number() })
+  .when('logoSvg', { is: Joi.string().required(), then: Joi.number() })
+
+const endpointSchema = Joi.object({
+  schemaVersion: 1,
+  label: Joi.string()
+    .allow('')
+    .required(),
+  message: Joi.string().required(),
+  color: Joi.string(),
+  labelColor: Joi.string(),
+  isError: Joi.boolean().default(false),
+  namedLogo: Joi.string(),
+  logoSvg: Joi.string(),
+  logoColor: optionalStringWhenNamedLogoPresent,
+  logoWidth: optionalNumberWhenAnyLogoPresent,
+  logoPosition: optionalNumberWhenAnyLogoPresent,
+  style: Joi.string(),
+  cacheSeconds: Joi.number()
+    .integer()
+    .min(0),
+})
+  // `namedLogo` or `logoSvg`; not both.
+  .oxor('namedLogo', 'logoSvg')
+  .required()
+
+module.exports = class Endpoint extends BaseJsonService {
+  static get category() {
+    return 'dynamic'
+  }
+
+  static get route() {
+    return {
+      base: 'badge/endpoint',
+      pattern: '',
+      queryParams: ['url'],
+    }
+  }
+
+  static get _cacheLength() {
+    return 300
+  }
+
+  static get defaultBadgeData() {
+    return {
+      label: 'custom badge',
+    }
+  }
+
+  static render({
+    label,
+    message,
+    color,
+    labelColor,
+    namedLogo,
+    logoSvg,
+    logoColor,
+    logoWidth,
+    logoPosition,
+    style,
+    isError,
+    cacheSeconds,
+  }) {
+    return {
+      isError,
+      label,
+      message,
+      color,
+      labelColor,
+      namedLogo,
+      logoSvg,
+      logoColor,
+      logoWidth,
+      logoPosition,
+      style,
+      cacheSeconds,
+    }
+  }
+
+  async handle(namedParams, queryParams) {
+    const { url } = this.constructor._validateQueryParams(
+      queryParams,
+      queryParamSchema
+    )
+
+    const { protocol, hostname } = new URL(url)
+    if (protocol !== 'https:') {
+      throw new InvalidParameter({ prettyMessage: 'please use https' })
+    }
+    if (blockedDomains.some(domain => hostname.endsWith(domain))) {
+      throw new InvalidParameter({ prettyMessage: 'domain is blocked' })
+    }
+
+    const json = await this._requestJson({
+      schema: anySchema,
+      url,
+      errorMessages,
+    })
+    // Override the validation options because we want to reject unknown keys.
+    const validated = this.constructor._validate(json, endpointSchema, {
+      allowAndStripUnknownKeys: false,
+    })
+
+    return this.constructor.render(validated)
+  }
+}
diff --git a/services/endpoint/endpoint.tester.js b/services/endpoint/endpoint.tester.js
new file mode 100644
index 0000000000000000000000000000000000000000..92a05e0e77d4e2f41cf080305b28b9733c91dcc0
--- /dev/null
+++ b/services/endpoint/endpoint.tester.js
@@ -0,0 +1,257 @@
+'use strict'
+
+const { expect } = require('chai')
+const { getShieldsIcon } = require('../../lib/logos')
+
+const t = (module.exports = require('../create-service-tester')())
+
+t.create('Valid schema (mocked)')
+  .get('.json?url=https://example.com/badge')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: '',
+        message: 'yo',
+      })
+  )
+  .expectJSON({ name: '', value: 'yo' })
+
+t.create('color and labelColor')
+  .get('.json?url=https://example.com/badge&style=_shields_test')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: 'hey',
+        message: 'yo',
+        color: '#f0dcc3',
+        labelColor: '#e6e6fa',
+      })
+  )
+  .expectJSON({
+    name: 'hey',
+    value: 'yo',
+    color: '#f0dcc3',
+    labelColor: '#e6e6fa',
+  })
+
+t.create('style')
+  .get('.json?url=https://example.com/badge')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: 'hey',
+        message: 'yo',
+        color: '#99c',
+        style: '_shields_test',
+      })
+  )
+  .expectJSON({
+    name: 'hey',
+    value: 'yo',
+    // `color` is only in _shields_test which is being specified by the
+    // service, not the request. If the color key is here we know this has
+    // worked.
+    color: '#99c',
+  })
+
+t.create('named logo')
+  .get('.svg?url=https://example.com/badge')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: 'hey',
+        message: 'yo',
+        namedLogo: 'github',
+      })
+  )
+  .after((err, res, body) => {
+    expect(err).not.to.be.ok
+    expect(body).to.include(getShieldsIcon({ name: 'github' }))
+  })
+
+t.create('named logo with color')
+  .get('.svg?url=https://example.com/badge')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: 'hey',
+        message: 'yo',
+        namedLogo: 'github',
+        logoColor: 'blue',
+      })
+  )
+  .after((err, res, body) => {
+    expect(err).not.to.be.ok
+    expect(body).to.include(getShieldsIcon({ name: 'github', color: 'blue' }))
+  })
+
+const logoSvg = Buffer.from(
+  getShieldsIcon({ name: 'github' }).replace('data:image/svg+xml;base64,', ''),
+  'base64'
+).toString('ascii')
+
+t.create('custom svg logo')
+  .get('.svg?url=https://example.com/badge')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: 'hey',
+        message: 'yo',
+        logoSvg,
+      })
+  )
+  .after((err, res, body) => {
+    expect(err).not.to.be.ok
+    expect(body).to.include(getShieldsIcon({ name: 'github' }))
+  })
+
+t.create('logoWidth')
+  .get('.json?url=https://example.com/badge&style=_shields_test')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: 'hey',
+        message: 'yo',
+        logoSvg,
+        logoWidth: 30,
+      })
+  )
+  .expectJSON({
+    name: 'hey',
+    value: 'yo',
+    color: 'lightgrey',
+    logoWidth: 30,
+  })
+
+t.create('Invalid schema (mocked)')
+  .get('.json?url=https://example.com/badge')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: -1,
+      })
+  )
+  .expectJSON({ name: 'custom badge', value: 'invalid response data' })
+
+t.create('Invalid schema (mocked)')
+  .get('.json?url=https://example.com/badge')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: 'hey',
+        message: 'yo',
+        extra: 'keys',
+        bogus: true,
+      })
+  )
+  .expectJSON({ name: 'custom badge', value: 'invalid response data' })
+
+t.create('User color overrides success color')
+  .get('.json?url=https://example.com/badge&colorB=101010&style=_shields_test')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: '',
+        message: 'yo',
+        color: 'blue',
+      })
+  )
+  .expectJSON({ name: '', value: 'yo', color: '#101010' })
+
+t.create('User color does not override error color')
+  .get('.json?url=https://example.com/badge&colorB=101010&style=_shields_test')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        isError: true,
+        label: 'something is',
+        message: 'not right',
+        color: 'red',
+      })
+  )
+  .expectJSON({ name: 'something is', value: 'not right', color: 'red' })
+
+t.create('cacheSeconds')
+  .get('.json?url=https://example.com/badge')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: '',
+        message: 'yo',
+        cacheSeconds: 500,
+      })
+  )
+  .expectHeader('cache-control', 'max-age=500')
+
+t.create('user can override service cacheSeconds')
+  .get('.json?url=https://example.com/badge&maxAge=1000')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: '',
+        message: 'yo',
+        cacheSeconds: 500,
+      })
+  )
+  .expectHeader('cache-control', 'max-age=1000')
+
+t.create('user does not override longer service cacheSeconds')
+  .get('.json?url=https://example.com/badge&maxAge=450')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: '',
+        message: 'yo',
+        cacheSeconds: 500,
+      })
+  )
+  .expectHeader('cache-control', 'max-age=500')
+
+t.create('cacheSeconds does not override longer Shields default')
+  .get('.json?url=https://example.com/badge')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: '',
+        message: 'yo',
+        cacheSeconds: 10,
+      })
+  )
+  .expectHeader('cache-control', 'max-age=300')
+
+t.create('Bad scheme')
+  .get('.json?url=http://example.com/badge')
+  .expectJSON({ name: 'custom badge', value: 'please use https' })
+
+t.create('Blocked domain')
+  .get('.json?url=https://img.shields.io/badge/foo-bar-blue.json')
+  .expectJSON({ name: 'custom badge', value: 'domain is blocked' })