# 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](https://cloudevents.io/) 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_job_http_client_timeout` 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_retention.finished` |

Here is Harbor's `Default` example for a push:

```json
{
  "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:

```yaml
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.

```javascript
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:

```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](/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](/docs/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](/docs/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](/docs/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](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-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](https://hookdeck.com) answers Harbor immediately, checks the `Authorization` header, and holds each registry event until your pipeline has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Harbor webhooks reliably in minutes.