Automation

Webhooks

Mergua can POST to a URL of yours when a project's translations change, so a pipeline starts without polling for it. One webhook per project, two events, every delivery signed. The payload names what changed and never carries the translations themselves — your pipeline pulls the files, the same way it would have anyway.

A webhook is push: Mergua calls you. The CLI is pull: your pipeline calls Mergua and writes the files into your repository. Most setups use both — the webhook starts the pipeline, the CLI does the work.

The Mergua CLI →

Setting one up

Open a project, go to Settings and find the Webhook card. It takes three answers, and a project has one webhook. Configuring it is the project owner's to do — it carries a credential, which puts it in the same bundle as the API keys rather than with the translations.

Endpoint URL

Where the POST goes. HTTPS only, and the host has to resolve to a public address: localhost, a private range and a cloud metadata address are all refused while you are still looking at the field. The check runs again immediately before every delivery, so a name that starts resolving inward later is caught then too. Redirects are not followed — a 302 would walk around the check that just passed — so answer at the URL you configured.

Branch

Which branch's changes are sent. It defaults to main, and that default is the point: a webhook that fired for every intermediate state of every branch would put half-finished work into a pipeline that only ever wanted the finished kind. Pick a single branch, or All branches if you really want all of them. For a merge it is the branch that was merged into that is tested, not the one that was merged — the pipeline listening is main's, and it has never heard of your feature branch.

Signing secret

Generated when you save, shown once, and never shown again. Copy it before you leave the page. Mergua signs every delivery with it, in the X-Mergua-Signature header — see Verifying the signature.

Saving again rotates the secret

Every save issues a new secret and the previous one stops working immediately, even if you only changed the branch. Rotating a secret you did not mean to rotate is one save away from being fixed; a stale secret kept quietly on file is not. Update your receiver in the same sitting.

Send deliveries turns sending off without deleting anything — useful while your receiver is being rebuilt. Anything collected while it is off is dropped rather than queued.

The two events

Two, and that is the whole list. Both go to the same URL, and the one that arrived is named in the X-Mergua-Event header as well as in the body.

EventFires when
translations.changedSomething changed in a project's translations — keys added, values written, statuses moved, a file imported, an AI round finished. Collected over a window and sent once per branch, language and JSON file.
branch.mergedA branch was merged in Mergua. Sent immediately, and tested against the branch it was merged into.

There is no separate event for a key being created, updated or deleted, and none for a review being approved. Whatever happened, what your pipeline does next is the same thing — fetch the files again — so all of it arrives as translations.changed, with the counts saying how much moved and the status breakdown saying where the language now stands.

A merge fires both: branch.merged at once, because that is the moment a release pipeline is waiting for, and translations.changed for the target branch out of the normal window.

What arrives

A JSON body, and five headers with it. The body says what changed; it never says what the text now reads.

HeaderCarries
X-Mergua-Signaturesha256= and the HMAC of the raw body. Check it first.
X-Mergua-Eventtranslations.changed or branch.merged.
X-Mergua-DeliveryThe delivery id, the same UUID the body carries as id. Deduplicate on it.
Content-Typeapplication/json, always.
User-AgentMergua-Webhooks/1.

translations.changed

{
  "event": "translations.changed",
  "id": "8f2c1e40-5b7a-4c3d-9e21-6a1f0b8d4e77",
  "timestamp": "2026-08-31T10:30:00.000Z",
  "project": { "id": "…", "slug": "acme-web", "name": "Acme Web" },
  "branch": "main",
  "locale": "de",
  "namespace": "common",
  "counts": { "added": 12, "updated": 3, "deleted": 0 },
  "statuses": { "DRAFT": 4, "TRANSLATED": 11, "REVIEWED": 0 },
  "keys": ["common.save", "common.cancel"],
  "keysTruncated": false
}

One delivery covers one language and one JSON file on one branch. A project holding three files, all of them touched by a single import, produces three deliveries — one per file — because that is the unit you would pull.

counts covers every change in the window. statuses is a headcount over the keys the payload names, read at the moment of sending rather than added up as the writes arrived — so it describes the state you will find when you pull, which is the only state you can act on. keys stops at 500 names and sets keysTruncated; the counts still describe everything that changed.

branch.merged

{
  "event": "branch.merged",
  "id": "1d9b7c22-3e44-4f10-8a6c-5b2e9f7a0c31",
  "timestamp": "2026-08-31T10:31:12.000Z",
  "project": { "id": "…", "slug": "acme-web", "name": "Acme Web" },
  "branch": "feature-checkout",
  "target": "main",
  "counts": { "added": 40, "updated": 8, "deleted": 1 }
}

branch is the branch that was merged, target where it went. This one does not wait for a window — it goes out as the merge completes.

Why no translations are in there

Three reasons, and each one is enough on its own. A value shipped in the payload is already out of date by the time the delivery lands, because more edits may have happened while it was in flight — so a receiver acting on it would be acting on a guess. You pull the files anyway, and the pull is authoritative. And a payload full of translations is a second copy of your content sitting in transit logs that were never designed to hold it.

So the shape to build against is: the delivery is a signal, your pull is the data. Read branch, locale and namespace to know what to fetch, then fetch it.

One delivery, not four hundred

Almost nothing in Mergua writes a single cell. An import writes a whole file, an AI round fills a whole language, a bulk approval moves hundreds of rows at once. An event per key would mean an import of 400 keys arriving as 400 POSTs, so translations.changed is collected first and sent once.

A change opens a window

The first change to a language and file on a branch starts a 60 second window and sets its deadline.

Everything after joins it

Further changes to the same language and file add to the counts and to the key list. The deadline does not move.

The window sends once

At the deadline one delivery goes out, describing everything the window collected. The status breakdown is read at that moment.

The window is 60 seconds and it starts at the first change. It does not slide: further edits flow into the same delivery — counts add up, key names join the list — but they do not push the deadline back. A sliding window would starve a project somebody is actively working in, and you would hear nothing for as long as the work went on.

Two consequences worth designing for. A delivery arrives up to about a minute after the change, so do not treat the timestamp as the moment of the edit. And a pipeline triggered by this will sometimes run twice for one working session — make the run idempotent, which for "pull the files and commit if anything differs" it already is.

branch.merged is not collected. A merge is a named event with an obvious trigger, and a minute of latency there would be the whole cost of having the webhook at all.

Verifying the signature

Every delivery carries X-Mergua-Signature, which is sha256= followed by the hex HMAC-SHA256 of the request body under your signing secret. Your endpoint is a public URL, so check it before you act on anything.

Sign the raw body, not your parsed JSON

The signature is computed over the exact bytes on the wire. Parse the body and serialise it again and you get different bytes — a reordered key, a number written another way — and a digest that will never match. Capture the raw body first, verify, then parse. In Express that means express.raw() on the route, not express.json().

Node

import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.MERGUA_WEBHOOK_SECRET;
const app = express();

// express.raw, not express.json: the signature covers the bytes as sent.
app.post('/hooks/mergua', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.get('X-Mergua-Signature') ?? '';
  const expected = 'sha256=' + createHmac('sha256', SECRET).update(req.body).digest('hex');

  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    return res.status(401).send('bad signature');
  }

  const delivery = JSON.parse(req.body.toString('utf8'));

  // alreadyProcessed and enqueuePull are yours — see "Handling repeats" below.
  if (alreadyProcessed(delivery.id)) return res.status(200).send('ok');

  // Answer first, work afterwards: Mergua gives up after 10 seconds.
  res.status(200).send('ok');
  enqueuePull(delivery);
});

app.listen(3000);

timingSafeEqual rather than ===: a comparison that returns early leaks how much of a guess was right. It throws on a length mismatch, which is why the lengths are checked first.

Shell

For a receiver that is a CI job reading the body off a file:

# body.json holds the raw request body, exactly as received.
SIGNATURE=$(openssl dgst -sha256 -hmac "$MERGUA_WEBHOOK_SECRET" -hex < body.json \
  | sed 's/^.*= //')

if [ "sha256=$SIGNATURE" = "$HTTP_X_MERGUA_SIGNATURE" ]; then
  echo "signature ok"
else
  echo "signature mismatch" >&2
  exit 1
fi

Handling repeats

Every delivery has an id — a UUID, in the body and in the X-Mergua-Delivery header. It identifies the delivery, not the change, and it stays the same if the same delivery ever reaches you twice. Record the ones you have processed and drop a repeat.

A short-lived store is enough — a Redis key with a day's expiry, or a table you prune. You are guarding against a duplicate that arrives seconds apart, not against one from last month.

Two deliveries about the same file are not repeats. Edit a language now and again in ten minutes and you get two windows, two ids and two deliveries, and both are real. That is why the check is on the id rather than on branch, locale and namespace.

What to answer

Answer 2xx, and answer it quickly. Mergua gives up after 10 seconds and reads anything from 300 upwards as a failure — redirects included, since they are not followed. Verify the signature, put the work on a queue, return 200; do not pull and commit inside the request.

A failed delivery is not sent again

There is no retry. If your receiver was down, rejected the request or took longer than ten seconds, that delivery is gone — and the changes it described are not re-announced by the next one, which will describe only what happened in its own window. A receiver that was unreachable for a minute has missed that minute.

What you get instead is the record: the Last delivery line on the webhook card in the project settings shows the status code, the time and the error of the most recent attempt. That is the place to look when a pipeline stopped starting.

So do not make the webhook the only way translations reach your repository. Keep a scheduled CLI pull as the floor — nightly is plenty — and let the webhook be what makes it prompt rather than what makes it work.

Which receivers work

One rule decides this, and it is worth knowing before you paste a URL in. Mergua sends fixed headers — content type, user agent and the three X-Mergua-* ones — and the body is always Mergua's own JSON. There is no Authorization header to set, and no way to change the shape of the body.

Works as it is

Anything that carries its own credential in the URL and does not care what the body says. Netlify and Vercel deploy hooks, Cloudflare Pages, a GitLab pipeline trigger with its token and ref in the query string, a Jenkins build token — and, of course, an endpoint you wrote yourself.

Needs something in between

Anything that insists on a particular body — Slack, Mattermost, Discord and Teams incoming webhooks all answer 400 to a payload that is not theirs — or on a header, such as GitHub's repository_dispatch and anything behind cloud IAM.

For the second group, put a small relay in front. Point Mergua at a catch-all URL from Zapier, Make or n8n — all three accept arbitrary JSON — and build the step that turns it into whatever the destination wants. No code, and about a quarter of an hour. A Cloudflare Worker or a Lambda does the same job if you would rather own it.

You rarely have to build that step more than once. Mattermost's incoming webhooks read Slack's payload format, and Discord's do too if you append /slack to the webhook URL — so one Slack-shaped message covers all three destinations.

Verify the signature in the relay, not after it: your relay is the public URL now, and it is the one thing holding the secret.

Worth knowing what such a message can say, before you build it. The payload carries no translations, so a chat notification comes out as "12 keys changed in de/common" plus a link into the project. That is the honest ceiling.

Webhooks and the CLI

The webhook tells you something changed. It does not move any files — the Mergua CLI does that, and the two are meant to be used together: the delivery starts your pipeline, and the pipeline runs a pull that writes the JSON into your repository and commits it.

QuestionWebhookCLI
Who calls whomMergua calls your URL.Your pipeline calls Mergua.
What you getA signed signal naming what changed.The translation files themselves, written to disk.
When it runsWithin about a minute of the change.Whenever your pipeline runs it.
If it failsThe delivery is lost; the last attempt is recorded in the project settings.The job fails and your pipeline retries it like any other job.

You can also skip the CLI and read the files over the public API with the branch, language and file the payload just named.

The Mergua CLI — the commands, the configuration, key sync, branch matching and the merge notification that goes the other way, from your Git host into Mergua.

Note which direction that one runs: the CLI's merge notification tells Mergua that you merged in Git. branch.merged tells you that a branch was merged in Mergua.