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
- In the console, open Settings → Alerts & webhooks and choose Add endpoint.
- Enter a name and the URL that should receive events.
- Choose the events to send. Optionally limit repository events to some repositories, and choose the minimum severity for
finding.created. - 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. - 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; a3xxanswer 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.createdis sent only for findings at or above it:critical,major(the default),minor, orall.
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": {}
}
idis unique per event. A retry or a manual redelivery repeats it, so use it to ignore duplicates.versionchanges only for incompatible changes to the envelope or todata. New fields can be added todataat any time; ignore fields you do not know.- Events can arrive out of order. Use
created_atwhen 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:
- Read the raw body bytes before parsing JSON. Re-serialized JSON will not match.
- Split the header on commas. Take
t, and everyv1value (after a secret rotation there are two for 24 hours: one per secret). - Reject the request if
tis more than 300 seconds from your clock. - Compute the expected signature and compare it with each
v1in 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
2xxstatus within 10 seconds. Do slow work after answering (queue it). - A non-
2xxanswer, 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 answer429with aRetry-Afterlonger 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.testevent 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
- 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.
- Copy the custom webhook URL Zapier shows.
- In MergeHarbor, add an endpoint with that URL and the events you want, then choose Send test event.
- Back in Zapier, test the trigger; it picks up the test event. Map fields such as
data.finding.titleinto the next steps, and add a Filter step ontypeif one endpoint sends several event types.
Make
- Create a scenario. Add the Webhooks app with the Custom webhook trigger, then Add a webhook and copy its address.
- In MergeHarbor, add an endpoint with that address.
- In Make, choose Redetermine data structure (or Run once), then choose Send test event in MergeHarbor so Make learns the fields.
- Add the following modules, using a router or filter on
typewhen the endpoint sends several event types.
n8n
- Add a Webhook trigger node with HTTP method POST.
- 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.
- 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
v1signatures, 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
idonce.
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. |