Agent skill
Docker Hub Webhooks Skill
Receive Docker Hub repository webhooks. Use when building a Docker Hub push webhook receiver, because Docker Hub webhooks are UNSIGNED — there is no signature header, no shared secret, no HMAC, no timestamp and no auth option, so signature verification is impossible and must be replaced with a secret URL token plus a re-check against the Docker Hub API. Use when parsing the push_data.tag / push_data.pusher / repository.repo_name payload, when handling the dhi_metadata object on mirrored Docker Hardened Image repositories, when asking why there is no event type field to switch on, or when wondering what to do about the legacy callback_url field.
Install this skill
npx skills add hookdeck/webhook-skills --skill 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:
- 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.
- There is no event type. Docker Hub webhooks have exactly one trigger — a push — and the payload carries no
event,typeoractionfield, and the request carries noX-...-Eventheader. Do not writeswitch (event.type). Route onrepository.repo_nameandpush_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-Signatureheader 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.pusherandrepository.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_metadataon 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.
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/, examples/nextjs/, 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}(operationIdGetRepositoryTag) and check the tag exists and itstag_last_pushed/ imagedigestare 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 fromPOST /v2/auth/tokenwith{"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's200into a401. - Validate the shape defensively — require
push_data.tagandrepository.repo_nameas 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 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:
{
"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 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.
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
# 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-editscope or higher. (The Hub API reference does not document the webhook CRUD endpoints themselves — don't guess paths for them.)
Local Development
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 - The single push trigger, why there is no event type, the full payload field reference, the
dhi_metadataschema, and what the legacycallback_urlused to do - 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 - 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