# Docker Hub Webhooks

Docker Hub (hub.docker.com) is Docker Inc.'s container image registry. A
Docker Hub repository webhook is configured per repository (repository →
Webhooks tab → name + destination URL → Create) and fires on a push to
that repository.

Two things make Docker Hub unlike most providers in this repo, and both must
be reflected in your handler:

1. There is no signature verification. None. No signature header, no shared
  secret, no HMAC, no timestamp, no token field, no auth option. The create
  form takes exactly two inputs — a name and a destination URL. You cannot
  verify that a POST came from Docker Hub.
2. There is no event type. Docker Hub webhooks have exactly one trigger —
  a push — and the payload carries no `event`, `type` or `action` field, and
  the request carries no `X-...-Event` header. Do not write
  `switch (event.type)`. Route on `repository.repo_name` and `push_data.tag`.

Scope note: this skill covers Docker Hub repository webhooks only. It does
not cover Docker Build Cloud, Docker Scout integrations, the self-hosted
`distribution` registry's `notifications:` endpoints (a different envelope, with
its own optional custom headers), GitHub Container Registry `registry_package`
webhooks, or Docker Engine events. Do not borrow payloads or auth from those.

## When to Use This Skill

* How do I receive Docker Hub webhooks?
* How do I verify a Docker Hub webhook signature? (You can't — there is none.)
* Is there an `X-Docker-Signature` / `X-Hub-Signature` header on Docker Hub webhooks? (No.)
* How do I secure an unsigned Docker Hub push webhook endpoint?
* How do I read `push_data.tag`, `push_data.pusher` and `repository.repo_name`?
* Which Docker Hub webhook event types exist? (One trigger, no event field.)
* What do I do with `callback_url`? (Nothing — it is legacy and unsupported.)
* How do I handle `dhi_metadata` on a mirrored Docker Hardened Image repository?
* Where is the image digest in the payload? (Not there — look it up via the Hub API.)

## Verification (core): there is none — use a secret URL token

Docker Hub does not sign webhooks. Do not write an HMAC verifier, a
signature-header check, a timestamp/replay window, or a shared-secret comparison
against anything Docker Hub sends — none of those inputs exist, and inventing
one produces a handler that only pretends to check. Docker also publishes no
source-IP allowlist, so do not fabricate one.

The closest available control is a long random token you put in the URL you
register, compared in constant time. This is a bearer secret in a URL: Docker
Hub sees it, and it can leak through logs and proxies — rotate it and keep it
out of access logs. A path segment and a `?token=` query param are equally
visible to Docker Hub; the path segment is a style preference, not a security
gain. If the token env var is unset, fail closed (500) — never accept.

```javascript
const crypto = require('crypto');

// NOT a Docker Hub signature — Docker Hub signs nothing. This is your own
// secret, placed in the URL you registered and echoed straight back to you.
function verifyUrlToken(provided, expected) {
  if (!expected) return null;               // unset => caller MUST fail closed (500)
  if (typeof provided !== 'string') return false;
  const a = Buffer.from(provided);
  const b = Buffer.from(expected);
  // Guard length first: timingSafeEqual throws on a length mismatch.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

```

> For complete handlers with tests, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/docker-hub-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/docker-hub-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/docker-hub-webhooks/examples/fastapi/).

Then layer on the controls that actually matter for an unsigned source:

* Treat the payload as an untrusted hint, not a fact. Before deploying,
  promoting or pulling, re-confirm against Docker Hub itself:
  `GET https://hub.docker.com/v2/namespaces/{namespace}/repositories/{repository}/tags/{tag}`
  (operationId `GetRepositoryTag`) and check the tag exists and its
  `tag_last_pushed` / image `digest` are what you expect — and/or pull by digest
  rather than by tag. Auth: no header at all for a public repository; for a
  private one, a JWT obtained from `POST /v2/auth/token` with
  `{"identifier": "<username or org>", "secret": "<PAT or OAT>"}` — a raw PAT/OAT
  is not itself a bearer token for the Hub API, and an unrecognised bearer value
  turns a public repo's `200` into a `401`.
* Validate the shape defensively — require `push_data.tag` and
  `repository.repo_name` as non-empty strings, reject malformed bodies with 400.
* Allowlist expected repositories (`DOCKER_HUB_ALLOWED_REPOS`) so someone
  who learns the URL cannot trigger actions for arbitrary repos.

See [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/docker-hub-webhooks/references/verification.md) for the full
rationale, the Hub API re-check, and the Hookdeck note.

## The Payload

Documented example (verbatim from the docs), plus the top-level `dhi_metadata`
object that mirrored Docker Hardened Image repositories add:

```json
{
  "callback_url": "https://registry.hub.docker.com/u/svendowideit/testhook/hook/2141b5bi5i5b02bec211i4eeih0242eg11000a/",
  "push_data": {
    "pushed_at": 1417566161,
    "pusher": "trustedbuilder",
    "tag": "latest"
  },
  "repository": {
    "comment_count": 0,
    "date_created": 1417494799,
    "description": "",
    "dockerfile": "#\n# BUILD ...",
    "full_description": "Docker Hub based automated build from a GitHub repo",
    "is_official": false,
    "is_private": true,
    "is_trusted": true,
    "name": "testhook",
    "namespace": "svendowideit",
    "owner": "svendowideit",
    "repo_name": "svendowideit/testhook",
    "repo_url": "https://registry.hub.docker.com/u/svendowideit/testhook/",
    "star_count": 0,
    "status": "Active"
  }
}

```

The documented example is old — 2014-era values, `registry.hub.docker.com`
URLs, and `dockerfile` / `is_trusted` left over from the retired Automated
Builds era. Rely only on these fields, treat every one as possibly absent or
null, and ignore unknown fields:

| Field | Type | Notes |
| --- | --- | --- |
| `push_data.tag` | string | The tag that was pushed. Required by the examples. |
| `push_data.pusher` | string | Docker Hub username that pushed. |
| `push_data.pushed_at` | integer | UNIX seconds (inferred from the 10-digit example; the docs don't state the unit). |
| `repository.repo_name` | string | `namespace/name`. Required by the examples. |
| `repository.namespace` | string | Owning user or org. |
| `repository.name` | string | Repository name without the namespace. |
| `repository.is_private` | boolean |  |
| `repository.repo_url` | string |  |
| `callback_url` | string | Legacy. Ignore it — see below. |
| `dhi_metadata` | object | Mirrored DHI repositories only — see below. |

There is no digest in `push_data`. If you need the image digest, look it up
via the Hub API (`GetRepositoryTag`) or the registry. Do not assume undocumented
fields such as `push_data.images` or `media_type`.

### `callback_url` is legacy — do not call it

The docs state verbatim: "The `callback_url` field is a legacy field and is no
longer supported." Older Docker docs described a "Validate a webhook callback"
step (POST `{"state": "success"|"failure"|"error", …}` back to `callback_url` to
continue a "webhook chain"); Docker removed that section in docker/docs#20565
(Aug 2024) and added the legacy note in docker/docs#23955 / #23962 (Jan 2026),
with the issue reporter reporting 404s on both GET and POST. Webhook chains are
gone. The field still appears in the documented example, so tolerate it and
ignore it — never POST to it.

### `dhi_metadata` (mirrored Docker Hardened Image repositories)

Pushes to a mirrored [Docker Hardened Image](https://docs.docker.com/dhi/)
repository (your org's namespace, repo name prefixed `dhi-`) carry an extra
top-level `dhi_metadata` object. Verbatim: "Docker Hub adds `dhi_metadata` only
to pushes on mirrored DHI repositories. Webhooks on other repositories deliver
the standard payload."

It is a map keyed by architecture-specific manifest digest (`sha256:…`),
with one entry per platform that has a changelog — "Match the digest key
against the platform you care about instead of assuming a single entry." Each
entry has `schema_version`, `change_categories` (any of `vulnerability_fix`,
`version_upgrade`, `other`; empty array = no changes), `previous_version`
(`tag`, `digest`), and `changes`. Branch on the presence of `dhi_metadata`
— it is the only payload-shape variation, and it is not an event type.

Docker generates this from a signed changelog attestation at delivery time, but
the webhook POST itself is still unsigned — the embedded `dhi_metadata` is
not independently verifiable as delivered. Full field reference in
[references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/docker-hub-webhooks/references/overview.md).

## Transport and Delivery

* HTTP POST with a JSON body. Verbatim: "Webhooks are POST requests sent to
  a URL you define in Docker Hub."
* No handshake. No challenge, no echo, no verification request, no special
  test event — Docker Hub just POSTs the JSON on push.
* Respond 2xx quickly and do the work asynchronously.
* Delivery history is visible per webhook under Menu options → View
  History, showing whether each POST succeeded.
* Retry policy, timeout and request headers (User-Agent, Content-Type value)
  are not documented. Don't rely on numbers for them — but handle deliveries
  idempotently anyway (dedupe on `repo_name` + `tag` + `pushed_at`).
* The registered URL must be 255 characters or fewer — budget for your token.

## Environment Variables

```bash
# REQUIRED. A long random token you place in the registered webhook URL, e.g.
# https://example.com/webhooks/docker-hub/<token>. NOT a Docker Hub signature —
# Docker Hub provides no secret. Unset => the handler fails closed with 500.
# Generate with: openssl rand -hex 32
DOCKER_HUB_WEBHOOK_TOKEN=

# OPTIONAL. Comma-separated repository.repo_name allowlist. When set, a push for
# any other repo is rejected with 403.
DOCKER_HUB_ALLOWED_REPOS=myorg/myapp,myorg/dhi-python

# OPTIONAL. Used ONLY to re-confirm the pushed tag against the Docker Hub API
# before acting on it. The PAT/OAT is the `secret` exchanged at
# POST /v2/auth/token for a short-lived JWT — it is not itself a bearer token.
# Unnecessary for public repositories, which need no auth.
DOCKER_HUB_API_IDENTIFIER=
DOCKER_HUB_API_TOKEN=

```

## Who Can Create a Webhook

* Personal repository: the repository owner only — collaborators can't.
* Organization repository: an organization owner or editor, or a team member
  with admin permissions on the repository.
* Via the Docker Hub API: a personal access token with delete
  permissions, or an organization access token with the `scope-webhook-edit`
  scope or higher. (The Hub API reference does not document the webhook CRUD
  endpoints themselves — don't guess paths for them.)

## Local Development

```bash
npx hookdeck-cli listen 3000 docker-hub --path /webhooks/docker-hub

```

Append your token segment so the CLI forwards to the token route —
`--path /webhooks/docker-hub/$DOCKER_HUB_WEBHOOK_TOKEN`. No account required:
the CLI creates a guest account on first run and gives you a public HTTPS URL
plus a web UI for inspecting requests.

Hookdeck's `DOCKER_HUB` source type is "No verification (schema only)" —
there is nothing to verify, and it does not check a Docker Hub signature because
none exists. What Hookdeck does add is an unguessable source URL, plus its own
outbound signature on the Hookdeck → your destination hop (a different hop
from Docker Hub → Hookdeck). Docker Hub is not yet listed in
hookdeck.com/docs/sources (checked 2026-09-28), so there is no Hookdeck guide
page for it yet.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/docker-hub-webhooks/references/overview.md) - The single push trigger, why there is no event type, the full payload field reference, the `dhi_metadata` schema, and what the legacy `callback_url` used to do
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/docker-hub-webhooks/references/setup.md) - Creating the webhook in the Docker Hub UI, who is allowed to, the 255-char URL limit, generating and rotating the URL token, viewing delivery history, and Hookdeck source configuration
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/docker-hub-webhooks/references/verification.md) - Why there is nothing to verify, the secret-URL-token pattern, re-confirming with `GetRepositoryTag`, repo allowlisting, and what Hookdeck does and doesn't do