Gareth Wilson Gareth Wilson

Guide to Harbor Webhooks: Features and Best Practices

Published


Harbor webhooks tell your CI/CD systems what is happening in your container registry: an artifact is pushed or pulled, a vulnerability scan completes, a project nears its storage quota, a replication finishes. If you run Harbor, the open source cloud native registry, webhooks let you trigger a deployment on every push, react to scan results, or keep an audit trail without polling the Harbor API.

This guide covers how Harbor webhook policies are configured, the two payload formats, how to authenticate requests that Harbor doesn't sign, and the practices that keep a receiver reliable.

What are Harbor webhooks?

Harbor webhooks are configured per project as webhook policies. Each policy has a notify type, a payload format, a set of events, an endpoint and optional authentication. A project can have several policies, so different events can go to different services.

Harbor supports two notify types, HTTP and SLACK. This guide is about HTTP, which sends a JSON POST to your endpoint. For HTTP policies you also pick a payload format: Default, the format Harbor has always used, or CloudEvents, which wraps the same data in a CloudEvents envelope.

Requests are not signed. The policy has an optional authentication header field, and Harbor's handler sets its value as the Authorization header unchanged: header.Set("Authorization", event.Target.AuthHeader). Whatever you type, such as Bearer <token>, is exactly what your receiver sees.

Harbor webhook features

FeatureDetails
ConfigurationProjects > your project > Webhooks, as a project administrator
PoliciesSeveral per project, each with its own events and endpoint
Notify typesHTTP or SLACK
Payload formatsDefault or CloudEvents (HTTP only)
MethodPOST
AuthenticationOptional auth header, sent verbatim as Authorization
SignatureNone
Certificate checkVerify Remote Certificate option per policy
Retrieswebhook_job_max_retry in harbor.yml, default 3
Timeoutwebhook_job_http_client_timeout in harbor.yml, default 3 seconds
Job historyExecutions, tasks and logs per policy
Global switchConfiguration > System Settings > Webhooks enabled, for system administrators
Source IPsYour own Harbor jobservice, since Harbor is self-hosted

Common events

Harbor documents 10 events. The Default format puts the code in type. CloudEvents uses a reverse-DNS name instead.

EventDefault typeCloudEvents type
Artifact pushedPUSH_ARTIFACTharbor.artifact.pushed
Artifact pulledPULL_ARTIFACTharbor.artifact.pulled
Artifact deletedDELETE_ARTIFACTharbor.artifact.deleted
Scan completedSCANNING_COMPLETEDharbor.scan.completed
Scan stoppedSCANNING_STOPPEDharbor.scan.stopped
Scan failedSCANNING_FAILEDharbor.scan.failed
Quota exceededQUOTA_EXCEEDharbor.quota.exceeded
Quota near thresholdQUOTA_WARNINGharbor.quota.warned
Replication status changedREPLICATIONharbor.replication.status.changed
Tag retention finishedTAG_RETENTIONharbor.tag_retention.finished

Here is Harbor's Default example for a push:

{
  "type": "PUSH_ARTIFACT",
  "occur_at": 1680501893,
  "operator": "harbor-jobservice",
  "event_data": {
    "resources": [
      {
        "digest": "sha256:954b378c375d852eb3c63ab88978f640b4348b01c1b3456a024a81536dafbbf4",
        "tag": "sha256:954b378c375d852eb3c63ab88978f640b4348b01c1b3456a024a81536dafbbf4",
        "resource_url": "localhost/harbor/alpine@sha256:954b378c375d852eb3c63ab88978f640b4348b01c1b3456a024a81536dafbbf4"
      }
    ],
    "repository": {
      "date_created": 1680501893,
      "name": "alpine",
      "namespace": "harbor",
      "repo_full_name": "harbor/alpine",
      "repo_type": "private"
    }
  }
}

occur_at is in Unix seconds. operator names what caused the event: a user, a robot account, or a value such as harbor-jobservice, auto or MANUAL in Harbor's examples. Scan events add a scan_overview to each resource, keyed by the report's media type, with scan_status, severity, a summary of issue counts and the scanner's name and version. Replication events carry a replication object instead of resources, and tag retention events carry the retention results.

The CloudEvents version of the same push has specversion 1.0, an id (a generated UUID), a requestid, a source such as /projects/2/webhook/policies/15, the type, datacontenttype, time in ISO 8601, operator, and the event data under data.

Setting up Harbor webhooks

Generate a token first, for example with openssl rand -hex 32, and decide on the full header value, such as Bearer <token>.

  1. Log in to Harbor with at least project administrator privileges.
  2. Go to Projects, select a project, and select Webhooks.
  3. Select the HTTP notify type.
  4. Select the Default or CloudEvents payload format.
  5. Select the events you want.
  6. Enter your endpoint URL.
  7. In the authentication header field, enter the full value, for example Bearer <token>.
  8. Select Verify Remote Certificate so Harbor checks your endpoint's TLS certificate.
  9. Click Add.

Retries and the request timeout are set by whoever runs Harbor, in harbor.yml for Docker Compose or under jobservice.notification in the Helm values:

notification:
  webhook_job_max_retry: 3
  webhook_job_http_client_timeout: 3 # seconds

With Hookdeck. Harbor is a supported source in Hookdeck. Create a source with the Harbor type, paste the auth header value into Authorization Header exactly as you set it in Harbor, including any Bearer or Basic prefix, and use the Hookdeck source URL as the policy's endpoint. Hookdeck's setup note says to use the HTTP notify type. The source accepts POST only, which is the method Harbor uses. A request without the right header still gets a 200 from Hookdeck, but Hookdeck marks it as failed verification and doesn't deliver it to your service.

Securing Harbor webhooks

Compare the whole Authorization header with the value you configured, in constant time. Don't parse the scheme: Harbor sends whatever you typed. Check lengths before calling crypto.timingSafeEqual, which throws on buffers of different lengths.

const crypto = require('crypto');
const express = require('express');

function verifyHarborRequest(headers) {
  const expected = process.env.HARBOR_WEBHOOK_AUTH_HEADER; // e.g. "Bearer <token>"
  if (!expected) return false; // fail closed when unconfigured
  const received = headers['authorization'];
  if (typeof received !== 'string') return false;
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/harbor', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyHarborRequest(req.headers)) {
    return res.sendStatus(401);
  }

  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }
  // Default: event.type is PUSH_ARTIFACT etc. CloudEvents: harbor.artifact.pushed etc.
  res.sendStatus(200);
});

The same check in Python:

import hmac
import os


def verify_harbor_request(headers) -> bool:
    expected = os.environ.get("HARBOR_WEBHOOK_AUTH_HEADER")
    if not expected:
        return False
    received = headers.get("authorization")  # case-insensitive in Starlette and Flask
    if not received:
        return False
    return hmac.compare_digest(received.encode("utf-8"), expected.encode("utf-8"))

A static header proves the sender knows the value, but it doesn't prove the body is unchanged, and it gives no replay protection: nothing is signed, and occur_at is not covered by anything. Use an HTTPS endpoint with Verify Remote Certificate on, keep the token long and random, and change it on every policy that uses it if it might have leaked. Because Harbor is self-hosted, you can also restrict the endpoint to your jobservice's egress addresses.

Make Harbor webhooks production-ready. Hookdeck Event Gateway answers Harbor inside its 3-second default timeout, checks the Authorization header, stores every push and scan event, and retries delivery to your service on a schedule you control.

Harbor webhook limitations and pain points

A 3-second timeout and three retries

The Problem: A deploy hook that runs a build before answering fails, and Harbor gives up after a few attempts.

Why It Happens: By default, Harbor waits 3 seconds for a response and retries a failed notification 3 times. Both are operator settings in harbor.yml, and Harbor's own comments warn that a high retry count puts pressure on the webhook job queue.

Workarounds:

  • Return 200 straight away and run the build or deployment asynchronously.
  • If you run Harbor, raise webhook_job_http_client_timeout modestly rather than writing slow handlers.
  • Check the policy's job history and logs after an outage.

How Hookdeck Can Help: Hookdeck answers each request as soon as it arrives and stores it, then delivers to your service with automatic retries on a schedule you control, and lets you replay any event.

The auth header is sent verbatim

The Problem: Verification fails even though the token is right.

Why It Happens: Harbor puts the whole field value into Authorization. If the policy says Bearer abc and your receiver expects abc, or the other way round, nothing matches.

Workarounds:

  • Store and compare the exact field value, prefix included.
  • Don't trim or change case: compare byte for byte.

How Hookdeck Can Help: Hookdeck's Harbor source compares the full Authorization value with what you paste, so there is one place to get it right.

Two formats, two vocabularies

The Problem: A handler written for PUSH_ARTIFACT ignores every event after someone switches the policy to CloudEvents.

Why It Happens: The format is chosen per policy, and type changes from codes like PUSH_ARTIFACT to names like harbor.artifact.pushed, with the data moving from event_data to data.

Workarounds:

  • Standardise on one format across your policies.
  • Detect specversion and normalise both shapes in one place.

How Hookdeck Can Help: Hookdeck transformations can map both formats into one shape before they reach your service.

No event ID in the Default format

The Problem: Retries can trigger the same deployment twice.

Why It Happens: The Default body has no unique ID. Only CloudEvents adds id.

Workarounds:

  • Use CloudEvents and deduplicate on id.
  • With Default, key on type, the artifact digest and occur_at.

How Hookdeck Can Help: Hookdeck deduplication can drop repeated requests based on the fields you choose.

Best practices

Use one policy per consumer

A CI trigger, a security dashboard and an audit log need different events. Separate policies give each its own endpoint and token, and a failing consumer doesn't affect the others' retries.

Filter pulls carefully

PULL_ARTIFACT fires for every pull, including those by robot accounts and scanners, as operator values like robot$...-Trivy-... show. Subscribe only if you need it, and filter on operator when you do.

Act on the digest, not the tag

The digest identifies exactly what was pushed or scanned. Tags can move, so deploy and record by digest.

Acknowledge fast and process asynchronously

Check the header, store the event, return 200, then do the work. See why to process webhooks asynchronously and our guide to webhook idempotency.

Conclusion

Harbor sends unsigned JSON POST requests from each project's webhook policies, in Default or CloudEvents format, with your auth header value copied verbatim into Authorization. Compare that whole value in constant time over HTTPS, normalise the two formats, deduplicate on id or the digest, and answer within Harbor's 3-second default timeout.

Hookdeck Event Gateway answers Harbor immediately, checks the Authorization header, and holds each registry event until your pipeline has processed it.

Get started with Hookdeck for free and handle Harbor webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.