# Guide to Lokalise Webhooks: Features and Best Practices

Lokalise webhooks tell your systems when something changes in a localisation project: translations are updated or proofread, keys are added or modified, a file is imported or exported, a task is closed, a branch is merged, or a translation order completes. They are how teams trigger builds, pull new translations into a repository, or notify reviewers as soon as work is done in Lokalise.

This guide covers how Lokalise webhooks are set up and authenticated, the events and payloads, Lokalise's 24-hour retry window and what happens when it runs out, and the best practices for running a Lokalise receiver in production.

## What are Lokalise webhooks?

Lokalise webhooks are configured per project, through the Webhooks app or the Webhooks API. Each handler has a URL, a set of events and an authentication header. When a subscribed event happens, Lokalise sends a JSON `POST` to the URL.

Lokalise states that it doesn't sign its requests. Instead, it sends a secret in a header with every request:

* By default, a random alphanumeric secret is generated when the app is enabled and sent in `X-Secret`. You can regenerate it or type in your own value.
* You can send your own value in `X-Api-Key` instead.
* Or you can send a custom header with a name and value of your choice.

Your receiver authenticates a request by comparing the header with the value it expects. Every request also carries `Project-Id` and `Webhook-Id` headers that identify the project and the handler.

## Lokalise webhook features

| Feature | Details |
| --- | --- |
| Configuration | Per project: **Apps > Webhooks**, or the Webhooks API |
| Method and format | `POST`, JSON |
| Authentication | A secret in `X-Secret` (default), a value in `X-Api-Key`, or a custom header |
| Signature | None. Lokalise doesn't sign requests |
| Other headers | `Project-Id`, `Webhook-Id` |
| Event name | `event` in the body |
| Setup request | A `ping` when a handler is configured, which needs a `2xx` |
| Response window | A `2xx` within 8 seconds |
| Retries | After about 1, 5, 10, 20 and 30 minutes, then hourly, for up to 24 hours |
| After 24 hours | Accumulated events are deleted and the handler is disabled |
| Ordering | Best effort, not guaranteed |
| Bulk events | 1 to 300 objects per event. Larger actions arrive as several events |

Lokalise publishes the IP addresses it sends from and suggests restricting requests to them: `3.67.82.138`, `3.123.244.162`, `3.124.254.35`, `18.185.175.152`, `18.192.113.205` and `18.194.146.34`. The same list covers all outbound traffic from Lokalise.

## Common events

Lokalise's event names are dotted and mostly start with `project.`:

| Area | Events |
| --- | --- |
| Translations | `project.translation.updated`, `project.translations.updated`, `project.translation.proofread`, `project.translations.proofread` |
| Keys | `project.key.added`, `project.keys.added`, `project.key.modified`, `project.keys.modified`, `project.keys.deleted`, `project.key.comment.added` |
| Files | `project.imported`, `project.exported` |
| Project | `project.copied`, `project.snapshot` |
| Languages | `project.languages.added`, `project.language.removed`, `project.language.settings_changed` |
| Contributors | `project.contributor.added`, `project.contributor.added_public`, `project.contributor.deleted` |
| Tasks | `project.task.created`, `.queued`, `.closed`, `.deleted`, `.language.closed`, `.initial_tm_leverage.calculated` |
| Branches | `project.branch.added`, `project.branch.deleted`, `project.branch.merged` |
| Orders | `team.order.created`, `team.order.completed`, `team.order.deleted` |

Singular and plural names are different events. `project.translation.updated` fires for one translation. `project.translations.updated` fires for a bulk action, such as applying translation memory, and carries up to 300 translations. The same pattern applies to keys. Bulk events include an `action` field, such as `bulk.tm` or `bulk.add_tags`, that says what caused them.

Here is Lokalise's `project.translation.updated` example:

```json
{
  "event": "project.translation.updated",
  "translation": {
    "id": 84835169,
    "value": "Hello, %s!",
    "previous_value": "Hi!",
    "segment": 1
  },
  "language": { "id": 640, "iso": "en", "name": "English" },
  "key": {
    "id": 16307701,
    "name": "test.title",
    "filenames": { "ios": null, "android": null, "web": "%LANG_ISO%.yml", "other": null }
  },
  "project": {
    "id": "138c1ffa0ad94848f01f980e7f2f2af19d1bd553",
    "name": "TheApp Project",
    "branch": "master"
  },
  "user": { "email": "jdoe@mycompany.com", "full_name": "John Doe" },
  "created_at": "2019-07-29 12:18:31",
  "created_at_timestamp": 1564395511
}

```

Most payloads share `event`, `project` (with the `branch`), `user`, `created_at` and `created_at_timestamp`. `created_at` has no time zone, so use `created_at_timestamp`, which is Unix seconds.

The setup guide also lists "Project deleted" among the events you can subscribe to, but the events reference documents no payload or name for it.

## Setting up Lokalise webhooks

1. In your Lokalise project, open **Apps**, find **Webhooks**, and click **Install**.
2. Enter the URL that events should be sent to.
3. Keep the default **X-Secret** header option. Copy the generated secret, or type your own, and store it server-side, for example as `LOKALISE_WEBHOOK_SECRET`.
4. Select the events you want.
5. Click **Enable app**, or **Save changes** if you're adding a handler to an existing app.

Lokalise sends a `ping` to the URL when the handler is configured, and your endpoint must answer it with a `2xx`. Lokalise doesn't document the ping's body or whether it carries the secret header. If enabling the app fails against a receiver that checks the secret, log the ping's headers and body to see what arrived, and answer a ping that carries the right secret with `200` even though it is not an event you handle.

If you use Hookdeck, create a Lokalise source, paste the secret into it, and use the Hookdeck source URL as the webhook URL. Hookdeck checks the `X-Secret` header, so keep the default **X-Secret** option. A handler switched to `X-Api-Key` or a custom header sends no `X-Secret` and fails verification. Hookdeck accepts every request with a `200` first, which also answers the ping, and checks the header afterwards. A request with a missing or wrong secret is recorded as failed verification and is not delivered to your service.

You can also create handlers through the Webhooks API, which is useful for configuring the same webhook across many projects.

## Securing Lokalise webhooks

There is nothing to compute. Compare the `X-Secret` header with your stored secret in constant time. Because the body isn't signed, you can parse it however you like.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyLokaliseSecret(headerValue, expectedSecret) {
  if (!headerValue || !expectedSecret) return false;
  const received = Buffer.from(headerValue, 'utf8');
  const expected = Buffer.from(expectedSecret, 'utf8');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/lokalise', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.LOKALISE_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyLokaliseSecret(req.get('x-secret'), secret)) {
    return res.sendStatus(401);
  }

  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }
  // Route on event.event and acknowledge anything you don't handle, including the ping.
  // Persist the event, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac

def verify_lokalise_secret(header_value, expected_secret: str) -> bool:
    if not header_value or not expected_secret:
        return False
    return hmac.compare_digest(header_value.encode("utf-8"), expected_secret.encode("utf-8"))

# In FastAPI: verify_lokalise_secret(request.headers.get("x-secret"),
#                                    os.environ["LOKALISE_WEBHOOK_SECRET"])

```

The secret travels with every request, so anyone who captures one request can send forged ones. Serve the endpoint over HTTPS only, and consider allowlisting Lokalise's published IP addresses as a second check.

> **Make Lokalise webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the `X-Secret` header, accepts every translation and key event as soon as it arrives, and durably queues it, so a long outage on your side doesn't run out Lokalise's 24-hour window and disable the handler.

## Lokalise webhook limitations and pain points

### A shared secret instead of a signature

**The Problem:** Nothing proves that the body you received is the body Lokalise sent.

**Why It Happens:** Lokalise doesn't sign requests. It sends the secret itself in a header, so a captured request can be replayed or altered.

**Workarounds:**

* Use HTTPS, a long random secret, and Lokalise's published IP allowlist.
* Regenerate the secret with the **Refresh** icon if it might have leaked, and update your receiver at the same time.
* Before acting on content, such as committing translations, fetch the current data from the Lokalise API.

**How Hookdeck Can Help:** Hookdeck checks the secret for you, so it lives on the Hookdeck source rather than in every service, and requests with the wrong secret are recorded as failed verification instead of being delivered.

### After 24 hours, events are deleted and the handler is disabled

**The Problem:** A day-long outage loses every event from that day, and nothing arrives afterwards until someone notices.

**Why It Happens:** Lokalise accumulates events for up to 24 hours while a handler fails. After that it deletes them and disables the handler, which must be re-enabled by hand in the project settings.

**Workarounds:**

* Alert on failures early, well before the 24 hours run out.
* After an outage, re-enable the handler and reconcile through the Lokalise API, for example by downloading the latest files.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives and stores it, so Lokalise sees successes even while your service is down. Hookdeck then retries delivery to your service on a schedule you control and lets you replay anything that failed.

### An 8 second response window

**The Problem:** Handlers that download files or run builds before responding time out and get retried.

**Why It Happens:** Lokalise counts a request as failed if it doesn't get a `2xx` within 8 seconds.

**Workarounds:**

* Respond `200` straight after checking the secret and storing the event.
* Run builds, exports and pull requests from a queue.

**How Hookdeck Can Help:** Hookdeck answers Lokalise as soon as the request is stored, without waiting for your service, and your service can take as long as it needs.

### Bulk actions arrive in pieces

**The Problem:** A bulk change of 1,000 keys shows up as several events, and there is no field telling you it's one action or that the last piece has arrived.

**Why It Happens:** Bulk and replace actions put 1 to 300 objects in each event and split larger actions across multiple events.

**Workarounds:**

* Process each event's objects independently, rather than waiting for a complete set.
* Debounce downstream work, such as a translation export or build, so it runs once after a burst of events instead of once per event.

**How Hookdeck Can Help:** Hookdeck's rate limiting on your destination smooths out bursts of bulk events so your service isn't flooded.

### No delivery id

**The Problem:** You can't tell a retry from a new event by an id.

**Why It Happens:** `Webhook-Id` identifies the handler, not the delivery, and the payloads have no event id.

**Workarounds:**

* Build a key from `event`, `project.id`, the object IDs (such as `translation.id` or `key.id`) and `created_at_timestamp`.
* Make handlers idempotent, for example by writing the latest translation value rather than appending changes.

**How Hookdeck Can Help:** Hookdeck's deduplication can drop identical repeated deliveries based on the fields you choose.

## Best practices

### Compare the secret in constant time

Use `crypto.timingSafeEqual` or `hmac.compare_digest` rather than `===` or `==`, and fail closed if the secret isn't configured.

### Branch-aware routing

Payloads include `project.branch`. If you use Lokalise branching, check the branch before acting, so work on a feature branch doesn't trigger a production build.

### Handle singular and plural events

Subscribe to both forms if you need every change: `project.translation.updated` and `project.translations.updated`, `project.key.added` and `project.keys.added`. Otherwise bulk actions, such as applying translation memory, pass you by.

### Acknowledge fast and process asynchronously

See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Lokalise authenticates webhooks with a shared secret in the `X-Secret` header rather than a signature. Compare it in constant time over HTTPS, answer the setup `ping` with a `2xx`, subscribe to both singular and bulk events, respond within 8 seconds, and watch for failures, because after 24 hours Lokalise deletes the queued events and disables the handler.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks the Lokalise secret, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Lokalise webhooks reliably in minutes.