Skip to content
Docs ·Webhooks

Configure

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 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):

{
  "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):

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)

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)

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

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:

"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.

{
  "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.

{
  "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.

{
  "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.

{
  "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 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.

{
  "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.

{
  "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 completes. outcome is passed, bugs_found, inconclusive or no_tests.

{
  "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).

{
  "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

{
  "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.

{
  "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

{
  "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.