Skip to content

Webhooks

Webhooks push changes to you as they happen, so you do not have to poll for them.

You add an endpoint in the Localeo dashboard under Project → Integrations, choose which events it should receive, and we POST a JSON envelope to it every time one occurs.

Requirements

Your endpoint must be:

  • https — plain http is refused when you save the endpoint. The payload is signed either way, so you can always prove it came from us, but a signature authenticates; it does not conceal. Over http the body travels in cleartext, and that body is your translation content: keys, source strings, and every value in the event.
  • Publicly reachable. The destination is checked at connection time against the address it actually resolves to, and private, loopback and link-local addresses are refused.

Testing against a server on your own machine? Use a tunnel such as ngrok or cloudflared. Both give you a public https URL, which needs no special handling on our side.

The envelope

Every delivery has the same outer shape. Only data differs per event.

{
"event": "release_published",
"event_version": 1,
"occurred_at": "2026-08-28T09:14:22.481Z",
"project_id": "Xk3p9q",
"data": {
"release_id": "b7Wq2m",
"name": "August strings",
"tag": "v2.4.0",
"status": "published"
}
}

project_id and every other id are the same opaque public ids the API uses — never internal numbers.

event_version is 1. It changes only if the envelope ever changes shape in a way that would break a parser, so it is worth branching on rather than ignoring.

Events

Eventdata
key_createdkey, context
key_updatedkey, context
key_deletedkey
translation_createdkey, language_code, value, status
translation_updatedkey, language_code, value, status
translation_approvedkey, language_code, value, status
release_createdrelease_id, name, tag, status
release_publishedrelease_id, name, tag, status
release_updatedrelease_id, name, tag
release_deletedrelease_id, name, tag

Key and translation events identify things by name

Release events carry a release_id you can pass straight to the API. Key and translation events do not carry an id — they identify the string by its key and language_code, which is what you would look it up by anyway.

Headers

POST /your-endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Localeo-Webhooks/1.0
X-Localeo-Event: release_published
X-Localeo-Event-Version: 1
X-Localeo-Delivery: 9fKq2b
X-Localeo-Timestamp: 1787907262
X-Localeo-Signature: sha256=4c2b...
HeaderMeaning
X-Localeo-EventThe event type, matching event in the body.
X-Localeo-Event-VersionMatches event_version in the body.
X-Localeo-DeliveryIdentifies this delivery. Stable across retries, so use it to deduplicate.
X-Localeo-TimestampUnix seconds, signed together with the body. Only sent when a secret is set.
X-Localeo-Signaturesha256= followed by the hex HMAC. Only sent when a secret is set.

Use X-Localeo-Delivery to deduplicate

Every retry of a delivery carries the same X-Localeo-Delivery, so if you record the ones you have processed you can safely ignore repeats. A redelivery you trigger yourself is a new delivery and gets a new value — you asked for it to be sent again, so it is not a duplicate.

Verifying the signature

When the endpoint has a secret, we sign each delivery. The signature is an HMAC-SHA256 over the timestamp, a literal ., and the exact raw body, hex-encoded:

signature = HMAC-SHA256(secret, "<X-Localeo-Timestamp>.<raw request body>")

Sign the raw bytes you received. Parsing the JSON and re-serialising it will change the bytes and the signature will not match.

import crypto from "node:crypto";
// `rawBody` must be a Buffer or string of the untouched request body.
export function verifyLocaleoSignature(rawBody, headers, secret) {
const timestamp = headers["x-localeo-timestamp"];
const signature = headers["x-localeo-signature"];
if (!timestamp || !signature) return false;
// Reject anything too old: the timestamp is signed, so an attacker cannot
// change it, but without this a captured request can be replayed forever.
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(ageSeconds) || ageSeconds > 300) return false;
const expected =
"sha256=" +
crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
// Constant time: a plain === leaks how much of the signature matched.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

The timestamp is regenerated for each attempt, so a retry carries a fresh timestamp and a fresh signature over the same body.

Responding

Return any 2xx as soon as you have stored the event. We wait up to 30 seconds for a response.

Do the actual work afterwards, not before responding — a slow handler becomes a timeout, and a timeout is a failed delivery that we will retry.

What counts as a failure

  • Any status outside 200–299.
  • A connection that never completes: DNS failure, refused connection, TLS error, or a timeout.
  • A redirect. We do not follow redirects, so a 301 or 302 is recorded as-is and treated as a failure. Register the final URL.

Retries

A failed delivery is retried 5 times in total, with the gap growing after each attempt — roughly 1 second, then 16, 81 and 256. A delivery that never succeeds is given up on about six minutes after the first attempt.

Because retries carry the same X-Localeo-Delivery, a receiver that briefly went down will usually get the event again without any action from you.

The delivery log

Every attempt is recorded, and you can read it in the dashboard under Project → Integrations → View logs. For each attempt you can see the exact payload we sent, the headers, your response status and body, how long it took, and what failed.

A few things worth knowing about what you see there:

  • Response bodies are stored up to 8 KB. Anything longer is cut, and the log says so rather than leaving you wondering whether your server stopped mid-sentence.
  • Deliveries are kept for 30 days, then removed.
  • Redeliver re-sends the original payload as a new delivery, which is the quickest way to test a fix against a real event.
  • Send test event posts a realistic sample for one of the events your endpoint subscribes to. It goes through exactly the same path as a real delivery — same envelope, same signing, same log — so if a test arrives correctly, real events will too.

If an endpoint fails repeatedly, the integrations list marks it as failing with the number of consecutive failures, so a receiver that broke quietly is visible without opening the log.