Guide to Harbor Webhooks: Features and Best Practices
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
| Feature | Details |
|---|---|
| Configuration | Projects > your project > Webhooks, as a project administrator |
| Policies | Several per project, each with its own events and endpoint |
| Notify types | HTTP or SLACK |
| Payload formats | Default or CloudEvents (HTTP only) |
| Method | POST |
| Authentication | Optional auth header, sent verbatim as Authorization |
| Signature | None |
| Certificate check | Verify Remote Certificate option per policy |
| Retries | webhook_job_max_retry in harbor.yml, default 3 |
| Timeout | webhook_ in harbor.yml, default 3 seconds |
| Job history | Executions, tasks and logs per policy |
| Global switch | Configuration > System Settings > Webhooks enabled, for system administrators |
| Source IPs | Your 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.
| Event | Default type | CloudEvents type |
|---|---|---|
| Artifact pushed | PUSH_ARTIFACT | harbor.artifact.pushed |
| Artifact pulled | PULL_ARTIFACT | harbor.artifact.pulled |
| Artifact deleted | DELETE_ARTIFACT | harbor.artifact.deleted |
| Scan completed | SCANNING_COMPLETED | harbor.scan.completed |
| Scan stopped | SCANNING_STOPPED | harbor.scan.stopped |
| Scan failed | SCANNING_FAILED | harbor.scan.failed |
| Quota exceeded | QUOTA_EXCEED | harbor.quota.exceeded |
| Quota near threshold | QUOTA_WARNING | harbor.quota.warned |
| Replication status changed | REPLICATION | harbor.replication.status.changed |
| Tag retention finished | TAG_RETENTION | harbor.tag_ |
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>.
- Log in to Harbor with at least project administrator privileges.
- Go to Projects, select a project, and select Webhooks.
- Select the
HTTPnotify type. - Select the
DefaultorCloudEventspayload format. - Select the events you want.
- Enter your endpoint URL.
- In the authentication header field, enter the full value, for example
Bearer <token>. - Select Verify Remote Certificate so Harbor checks your endpoint's TLS certificate.
- 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
Authorizationheader, 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
200straight away and run the build or deployment asynchronously. - If you run Harbor, raise
webhook_modestly rather than writing slow handlers.job_ http_ client_ timeout - 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
specversionand 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
CloudEventsand deduplicate onid. - With
Default, key ontype, the artifactdigestandoccur_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.