# Webhooks

> Send signed JSON events about reviews, findings, merge gates, proof runs, budgets and members to Zapier, Make, n8n or your own services.

Webhooks send an HTTP `POST` with a signed JSON body to a URL you choose whenever something happens in your organization: a review is posted, a finding is created or resolved, a merge gate changes, and more. Use them to connect MergeHarbor to Zapier, Make, n8n or your own services.

For ready-made Slack, Microsoft Teams and email messages, use [notifications](https://mergeharbor.com/docs/integrations) instead.

## Add an endpoint

1. In the console, open **Settings → Alerts & webhooks** and choose **Add endpoint**.
2. Enter a name and the URL that should receive events.
3. Choose the events to send. Optionally limit repository events to some repositories, and choose the minimum severity for `finding.created`.
4. Save. The signing secret (`whsec_…`) is shown **once**. Copy it into the receiving service now; you can rotate it later but never view it again.
5. Choose **Send test event** to check that the endpoint answers.

You need the `settings.manage` permission to add, change, test or remove endpoints and to read the delivery log; `settings.read` is enough to see the list. An organization can have up to 25 endpoints. Creating, changing, rotating and removing endpoints is recorded in the audit log.

### URL rules

- The URL must use `https`. MergeHarbor does not follow redirects; a `3xx` answer counts as a failed delivery.
- Private, loopback, link-local and cloud metadata addresses are refused when MergeHarbor connects, whatever the hostname resolves to.
- The URL is stored encrypted and shown afterwards only as a hint (host and last characters). Many no-code webhook URLs act as passwords, so treat them as secrets.

Self-hosted operators can set `OUTBOUND_WEBHOOKS_ALLOW_PRIVATE=true` to allow private-network destinations and `http://localhost` (for development and self-hosted installations only).

### Filters

- **Repositories**: applies to repository events only. Organization events (`budget.threshold_reached`, `member.invited`, `member.joined`) are always sent to endpoints subscribed to them.
- **Minimum severity**: `finding.created` is sent only for findings at or above it: `critical`, `major` (the default), `minor`, or `all`.

## Payload

Every delivery has the same envelope (version 1):

```json
{
  "id": "evt_8f3c1a9e2b7d4c60a1e5f2d3",
  "type": "review.completed",
  "version": 1,
  "created_at": "2026-10-21T14:03:12Z",
  "org": { "id": "42", "handle": "acme", "name": "Acme" },
  "data": {}
}
```

- `id` is unique per event. A retry or a manual redelivery repeats it, so use it to ignore duplicates.
- `version` changes only for incompatible changes to the envelope or to `data`. New fields can be added to `data` at any time; ignore fields you do not know.
- Events can arrive out of order. Use `created_at` when order matters.

### Headers

| Header                  | Value                                                                          |
| ----------------------- | ------------------------------------------------------------------------------ |
| `Content-Type`          | `application/json`                                                             |
| `User-Agent`            | `MergeHarbor-Webhooks/1`                                                       |
| `MergeHarbor-Event`     | The event type, for example `finding.created`                                  |
| `MergeHarbor-Delivery`  | `dlv_…`: the same for every retry of one delivery; a redelivery gets a new one |
| `MergeHarbor-Signature` | `t=<unix seconds>,v1=<hex signature>`                                          |

## Verify signatures

`v1` is the hex-encoded HMAC-SHA256 of the timestamp, a period and the raw request body, keyed with the full signing secret as shown (including the `whsec_` prefix):

```text
v1 = hex(HMAC-SHA256(secret, t + "." + raw_body))
```

To verify a delivery:

1. Read the raw body bytes **before** parsing JSON. Re-serialized JSON will not match.
2. Split the header on commas. Take `t`, and every `v1` value (after a secret rotation there are two for 24 hours: one per secret).
3. Reject the request if `t` is more than 300 seconds from your clock.
4. Compute the expected signature and compare it with each `v1` in constant time. Accept if any matches.

### Node.js (Express)

```js
import crypto from "node:crypto";
import express from "express";

const secret = process.env.MERGEHARBOR_WEBHOOK_SECRET; // whsec_...
const app = express();

app.post("/mergeharbor", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("MergeHarbor-Signature") || "";
  let t = "";
  const sigs = [];
  for (const part of header.split(",")) {
    const [k, v] = part.split("=", 2);
    if (k === "t") t = v;
    if (k === "v1") sigs.push(v);
  }
  if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(400);

  const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(req.body).digest();
  const ok = sigs.some((s) => {
    const got = Buffer.from(s, "hex");
    return got.length === expected.length && crypto.timingSafeEqual(got, expected);
  });
  if (!ok) return res.sendStatus(400);

  const event = JSON.parse(req.body.toString("utf8"));
  res.sendStatus(204); // answer first, then process
  handle(event);
});
```

### Python (Flask)

```python
import hashlib, hmac, json, os, time
from flask import Flask, request, abort

SECRET = os.environ["MERGEHARBOR_WEBHOOK_SECRET"].encode()  # whsec_...
app = Flask(__name__)

@app.post("/mergeharbor")
def mergeharbor():
    body = request.get_data()  # raw bytes
    t, sigs = "", []
    for part in request.headers.get("MergeHarbor-Signature", "").split(","):
        k, _, v = part.partition("=")
        if k == "t":
            t = v
        elif k == "v1":
            sigs.append(v)
    if not t or abs(time.time() - int(t)) > 300:
        abort(400)
    expected = hmac.new(SECRET, t.encode() + b"." + body, hashlib.sha256).hexdigest()
    if not any(hmac.compare_digest(expected, s) for s in sigs):
        abort(400)
    event = json.loads(body)
    queue_for_processing(event)
    return "", 204
```

### Go

```go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"math"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

var secret = []byte(os.Getenv("MERGEHARBOR_WEBHOOK_SECRET")) // whsec_...

func verify(r *http.Request, body []byte) bool {
	var t string
	var sigs []string
	for _, part := range strings.Split(r.Header.Get("MergeHarbor-Signature"), ",") {
		k, v, _ := strings.Cut(part, "=")
		switch k {
		case "t":
			t = v
		case "v1":
			sigs = append(sigs, v)
		}
	}
	ts, err := strconv.ParseInt(t, 10, 64)
	if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
		return false
	}
	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(t + "."))
	mac.Write(body)
	expected := mac.Sum(nil)
	for _, s := range sigs {
		if got, err := hex.DecodeString(s); err == nil && hmac.Equal(got, expected) {
			return true
		}
	}
	return false
}

func handler(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil || !verify(r, body) {
		http.Error(w, "invalid signature", http.StatusBadRequest)
		return
	}
	w.WriteHeader(http.StatusNoContent)
	go process(body)
}
```

## Delivery and retries

- Answer with any `2xx` status within **10 seconds**. Do slow work after answering (queue it).
- A non-`2xx` answer, a timeout or a connection error is retried. Each delivery gets up to **8 attempts**: the first, then about 1 minute, 5 minutes, 30 minutes, 1 hour, 3 hours, 6 hours and 12 hours after the previous one (about 23 hours in all), with a little jitter. If you answer `429` with a `Retry-After` longer than the next wait, MergeHarbor waits that long instead (up to 12 hours).
- After the last attempt the delivery is marked **failed**. You can still redeliver it from the log.

### Endpoints that keep failing

When every attempt to an endpoint has failed for **3 days** and at least **15 attempts in a row** have failed, MergeHarbor disables the endpoint, cancels its pending deliveries, and tells everyone with `settings.manage` in the console and by email (the **Integrations and indexing** notification category). Fix the receiver, then turn the endpoint back on in **Settings → Alerts & webhooks**; this clears its failure count. Events that happened while it was disabled are not sent.

### Delivery log

Open an endpoint to see its deliveries from the last 30 days: event, status (pending, retrying, succeeded, failed or canceled), attempts, HTTP status, latency and the first 2 KB of your endpoint's last response. Open a delivery to see the exact JSON body that was sent.

- **Redeliver** sends the event again as a new delivery with the same event `id`. The endpoint must be enabled.
- **Send test event** sends a `webhook.test` event immediately and shows the result. Test deliveries are not retried and do not count toward disabling the endpoint.

## Events

Repository events share these objects:

```json
"repository": { "id": "118", "full_name": "acme/payments", "provider": "github" },
"pull_request": {
  "id": "5521",
  "number": 482,
  "title": "Retry failed card captures",
  "url": "https://github.com/acme/payments/pull/482",
  "author": "dana",
  "head_sha": "4b1e9c0d7a2f3e5b6c8d9e0f1a2b3c4d5e6f7a8b"
}
```

`pull_request.id` is the console's id; `console_url` links to the page in the MergeHarbor console.

| Event                      | Sent when                                                             | Scope        |
| -------------------------- | --------------------------------------------------------------------- | ------------ |
| `review.completed`         | A review was posted on a pull request                                 | Repository   |
| `pull_request.reviewed`    | A pull request's review status changed because a review was posted    | Repository   |
| `finding.created`          | A review posted a finding at or above the endpoint's minimum severity | Repository   |
| `finding.status_changed`   | A finding was marked addressed, dismissed or reopened                 | Repository   |
| `merge_gate.changed`       | A pull request's merge gate verdict changed                           | Repository   |
| `repository.indexed`       | An index run of a repository finished                                 | Repository   |
| `proof_run.completed`      | A proof run finished                                                  | Repository   |
| `budget.threshold_reached` | Usage crossed an alert threshold                                      | Organization |
| `member.invited`           | Someone was invited to the organization                               | Organization |
| `member.joined`            | Someone joined the organization                                       | Organization |
| `webhook.test`             | You chose **Send test event**                                         | Endpoint     |

### review.completed

Sent once per posted review. `score` is the 1 to 5 confidence to merge; `findings` counts what this review found.

```json
{
  "id": "evt_1c7d2e9f0a3b4c5d6e7f8a9b",
  "type": "review.completed",
  "version": 1,
  "created_at": "2026-10-21T14:03:12Z",
  "org": { "id": "42", "handle": "acme", "name": "Acme" },
  "data": {
    "review_id": "rv_01JAX4T9Q2",
    "repository": { "id": "118", "full_name": "acme/payments", "provider": "github" },
    "pull_request": {
      "id": "5521",
      "number": 482,
      "title": "Retry failed card captures",
      "url": "https://github.com/acme/payments/pull/482",
      "author": "dana",
      "head_sha": "4b1e9c0d7a2f3e5b6c8d9e0f1a2b3c4d5e6f7a8b"
    },
    "score": 3,
    "findings": { "critical": 0, "major": 2, "minor": 1, "nit": 3, "total": 6 },
    "console_url": "https://app.mergeharbor.com/acme/pulls/5521"
  }
}
```

### pull_request.reviewed

Sent with every posted review, describing the pull request as a whole: `open_findings` counts findings still open on the pull request across all its reviews.

```json
{
  "id": "evt_2d8e3f0a1b4c5d6e7f8a9b0c",
  "type": "pull_request.reviewed",
  "version": 1,
  "created_at": "2026-10-21T14:03:12Z",
  "org": { "id": "42", "handle": "acme", "name": "Acme" },
  "data": {
    "review_id": "rv_01JAX4T9Q2",
    "repository": { "id": "118", "full_name": "acme/payments", "provider": "github" },
    "pull_request": {
      "id": "5521",
      "number": 482,
      "title": "Retry failed card captures",
      "url": "https://github.com/acme/payments/pull/482",
      "author": "dana",
      "head_sha": "4b1e9c0d7a2f3e5b6c8d9e0f1a2b3c4d5e6f7a8b"
    },
    "score": 3,
    "open_findings": { "critical": 0, "major": 3, "minor": 1, "nit": 4, "total": 8 },
    "console_url": "https://app.mergeharbor.com/acme/pulls/5521"
  }
}
```

### finding.created

One event per finding a review posted, filtered by the endpoint's minimum severity.

```json
{
  "id": "evt_3e9f0a1b2c5d6e7f8a9b0c1d",
  "type": "finding.created",
  "version": 1,
  "created_at": "2026-10-21T14:03:12Z",
  "org": { "id": "42", "handle": "acme", "name": "Acme" },
  "data": {
    "finding": {
      "id": "90412",
      "review_id": "rv_01JAX4T9Q2",
      "severity": "major",
      "category": "bug",
      "title": "Capture retry can charge the card twice",
      "path": "internal/capture/retry.go",
      "line": 88,
      "url": "https://app.mergeharbor.com/acme/pulls/5521"
    },
    "repository": { "id": "118", "full_name": "acme/payments", "provider": "github" },
    "pull_request": {
      "id": "5521",
      "number": 482,
      "title": "Retry failed card captures",
      "url": "https://github.com/acme/payments/pull/482",
      "author": "dana",
      "head_sha": "4b1e9c0d7a2f3e5b6c8d9e0f1a2b3c4d5e6f7a8b"
    }
  }
}
```

### finding.status_changed

Sent when someone marks a finding addressed or dismissed, or reopens it, in the console, through the API, or through a coding agent connected over MCP. `reason` is the dismiss reason (otherwise `null`). Findings resolved automatically by a later push are not sent in version 1.

```json
{
  "id": "evt_4f0a1b2c3d6e7f8a9b0c1d2e",
  "type": "finding.status_changed",
  "version": 1,
  "created_at": "2026-10-21T15:20:44Z",
  "org": { "id": "42", "handle": "acme", "name": "Acme" },
  "data": {
    "finding": {
      "id": "90412",
      "severity": "major",
      "title": "Capture retry can charge the card twice",
      "path": "internal/capture/retry.go",
      "line": 88,
      "status": "dismissed"
    },
    "status": "dismissed",
    "reason": "false_positive",
    "changed_by": { "type": "user", "display": "dana@acme.com" },
    "repository": { "id": "118", "full_name": "acme/payments", "provider": "github" },
    "pull_request": {
      "id": "5521",
      "number": 482,
      "title": "Retry failed card captures",
      "url": "https://github.com/acme/payments/pull/482",
      "author": "dana",
      "head_sha": "4b1e9c0d7a2f3e5b6c8d9e0f1a2b3c4d5e6f7a8b"
    }
  }
}
```

### merge_gate.changed

Sent when the [merge gate](https://mergeharbor.com/docs/merge-gate) verdict of a pull request changes, when a new head commit gets a verdict, and when an override is applied or withdrawn. `verdict` is `pass`, `warn` or `block`; `previous_verdict` is `null` the first time (or `pending` while a review of the head was still running). `readiness` is 0 to 100.

```json
{
  "id": "evt_5a1b2c3d4e7f8a9b0c1d2e3f",
  "type": "merge_gate.changed",
  "version": 1,
  "created_at": "2026-10-21T14:03:20Z",
  "org": { "id": "42", "handle": "acme", "name": "Acme" },
  "data": {
    "repository": { "id": "118", "full_name": "acme/payments", "provider": "github" },
    "pull_request": {
      "id": "5521",
      "number": 482,
      "title": "Retry failed card captures",
      "url": "https://github.com/acme/payments/pull/482",
      "author": "dana",
      "head_sha": "4b1e9c0d7a2f3e5b6c8d9e0f1a2b3c4d5e6f7a8b"
    },
    "verdict": "block",
    "previous_verdict": "pass",
    "readiness": 55,
    "overridden": false,
    "blocking": 2,
    "warnings": 1,
    "head_sha": "4b1e9c0d7a2f3e5b6c8d9e0f1a2b3c4d5e6f7a8b",
    "console_url": "https://app.mergeharbor.com/acme/pulls/5521?gate=1"
  }
}
```

### repository.indexed

Sent when an index run finishes successfully.

```json
{
  "id": "evt_6b2c3d4e5f8a9b0c1d2e3f4a",
  "type": "repository.indexed",
  "version": 1,
  "created_at": "2026-10-21T09:12:05Z",
  "org": { "id": "42", "handle": "acme", "name": "Acme" },
  "data": {
    "repository": { "id": "118", "full_name": "acme/payments", "provider": "github" },
    "commit_sha": "9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b",
    "files": 1843,
    "files_skipped": 4,
    "full_scan": false
  }
}
```

### proof_run.completed

Sent when a [proof run](https://mergeharbor.com/docs/proof-runs) completes. `outcome` is `passed`, `bugs_found`, `inconclusive` or `no_tests`.

```json
{
  "id": "evt_7c3d4e5f6a9b0c1d2e3f4a5b",
  "type": "proof_run.completed",
  "version": 1,
  "created_at": "2026-10-21T14:11:37Z",
  "org": { "id": "42", "handle": "acme", "name": "Acme" },
  "data": {
    "proof_run": {
      "id": "731",
      "outcome": "bugs_found",
      "tests_total": 3,
      "tests_passed": 2,
      "tests_bugs": 1,
      "tests_discarded": 0,
      "credits": 2,
      "trigger": "auto"
    },
    "repository": { "id": "118", "full_name": "acme/payments", "provider": "github" },
    "pull_request": {
      "id": "5521",
      "number": 482,
      "title": "Retry failed card captures",
      "url": "https://github.com/acme/payments/pull/482",
      "author": "dana",
      "head_sha": "4b1e9c0d7a2f3e5b6c8d9e0f1a2b3c4d5e6f7a8b"
    },
    "head_sha": "4b1e9c0d7a2f3e5b6c8d9e0f1a2b3c4d5e6f7a8b",
    "console_url": "https://app.mergeharbor.com/acme/pulls/5521"
  }
}
```

### budget.threshold_reached

Sent when usage crosses an alert threshold. `kind` is `allowance` (credits included in the plan), `cap` (the monthly overage limit) or `credit_budget` (your monthly credit budget).

```json
{
  "id": "evt_8d4e5f6a7b0c1d2e3f4a5b6c",
  "type": "budget.threshold_reached",
  "version": 1,
  "created_at": "2026-10-21T11:00:00Z",
  "org": { "id": "42", "handle": "acme", "name": "Acme" },
  "data": { "kind": "credit_budget", "percent": 80, "used": 1600, "limit": 2000 }
}
```

### member.invited

```json
{
  "id": "evt_9e5f6a7b8c1d2e3f4a5b6c7d",
  "type": "member.invited",
  "version": 1,
  "created_at": "2026-10-21T10:30:00Z",
  "org": { "id": "42", "handle": "acme", "name": "Acme" },
  "data": {
    "invitation": { "id": "311", "email": "sam@acme.com", "role": "member" },
    "invited_by": { "display": "dana@acme.com" }
  }
}
```

### member.joined

Sent when someone accepts an invitation, or is provisioned by single sign-on (just-in-time) or SCIM. `via` says how.

```json
{
  "id": "evt_0f6a7b8c9d2e3f4a5b6c7d8e",
  "type": "member.joined",
  "version": 1,
  "created_at": "2026-10-21T10:41:09Z",
  "org": { "id": "42", "handle": "acme", "name": "Acme" },
  "data": {
    "user": { "id": "2207", "email": "sam@acme.com", "name": "Sam Lee" },
    "role": "member",
    "via": "invitation link"
  }
}
```

### webhook.test

```json
{
  "id": "evt_1a7b8c9d0e3f4a5b6c7d8e9f",
  "type": "webhook.test",
  "version": 1,
  "created_at": "2026-10-21T10:45:00Z",
  "org": { "id": "42", "handle": "acme", "name": "Acme" },
  "data": {
    "message": "This is a test event from MergeHarbor.",
    "endpoint": { "id": "17", "name": "Zapier: findings to Jira" }
  }
}
```

## Zapier, Make and n8n

These tools give you a URL that accepts any JSON `POST`. Paste it into MergeHarbor, send a test event so the tool learns the fields, then build the rest of the workflow. They do not check MergeHarbor's signature by themselves, so keep the URL private (anyone with it can send data to your workflow) and, where it matters, verify the signature in a code step.

### Zapier

1. Create a Zap. For the trigger, choose the **Webhooks by Zapier** app and the **Catch Hook** event. Choose **Catch Raw Hook** instead if you want the raw body and headers to verify the signature in a **Code by Zapier** step.
2. Copy the custom webhook URL Zapier shows.
3. In MergeHarbor, add an endpoint with that URL and the events you want, then choose **Send test event**.
4. Back in Zapier, test the trigger; it picks up the test event. Map fields such as `data.finding.title` into the next steps, and add a **Filter** step on `type` if one endpoint sends several event types.

### Make

1. Create a scenario. Add the **Webhooks** app with the **Custom webhook** trigger, then **Add** a webhook and copy its address.
2. In MergeHarbor, add an endpoint with that address.
3. In Make, choose **Redetermine data structure** (or **Run once**), then choose **Send test event** in MergeHarbor so Make learns the fields.
4. Add the following modules, using a router or filter on `type` when the endpoint sends several event types.

### n8n

1. Add a **Webhook** trigger node with HTTP method **POST**.
2. n8n shows a **Test URL** (it only works while the editor is listening for a test event) and a **Production URL** (it works once the workflow is active). Use the Production URL in MergeHarbor for real traffic; to build the workflow, you can add a second endpoint with the Test URL and use **Send test event** while n8n is listening.
3. To verify signatures, enable the raw body option on the Webhook node and check the HMAC in a **Code** node (or the **Crypto** node) using the snippet logic above.

## Security

- **Rotate the secret** from the endpoint's menu (**Rotate secret**). The new secret is shown once. For 24 hours deliveries carry two `v1` signatures, one with the new secret and one with the previous one, so you can update the receiver without dropping events.
- **Do not allowlist by IP address.** MergeHarbor's outgoing addresses are not fixed; verify the signature instead.
- Always verify the timestamp and signature before acting on a delivery, and process each event `id` once.

## Troubleshooting

| What you see                            | What to check                                                                                                                            |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| The URL is refused when saving          | It must be `https` and resolve to a public address. Self-hosted installations: see `OUTBOUND_WEBHOOKS_ALLOW_PRIVATE` above.              |
| Deliveries fail with no HTTP status     | The receiver timed out (more than 10 seconds), refused the connection, has an invalid TLS certificate, or resolves to a blocked address. |
| Deliveries fail with `3xx`              | Redirects are not followed. Use the final URL.                                                                                           |
| Signature never matches                 | Verify against the raw body bytes, not parsed and re-serialized JSON, and use the whole secret including `whsec_`.                       |
| Signature fails only on some deliveries | Check your clock (the timestamp must be within 5 minutes) and that you accept any of the `v1` values after a rotation.                   |
| The same event arrives twice            | Expected after retries and redeliveries; deduplicate on the event `id`.                                                                  |
| The endpoint was turned off             | It failed for 3 days. Fix the receiver, use **Send test event**, then turn the endpoint back on.                                         |
| No `finding.created` events             | Check the endpoint's minimum severity and repository filter.                                                                             |
