Integrations
Webhooks
Let langctl tell your own services when translations change — for example, rebuild and redeploy your app as soon as keys are published.
A webhook is an HTTPS URL that langctl sends a POST request to whenever one of the events you picked happens. Each request carries a small JSON summary of what changed and an HMAC signature so you can check it really came from langctl.
Setting up a webhook
- In the dashboard, open Organization → Webhooks (admins and owners only).
- Click New webhook, enter your endpoint URL, choose the events and, optionally, limit it to one project.
- Copy the signing secret. It is shown only once; if you lose it, use Regenerate secret.
- Click Details → Send test to send a
webhook.testdelivery and check that your endpoint answers with a2xxstatus.
Each organization can have up to 10 webhooks. Webhooks fire for changes made anywhere: the dashboard, the CLI and CI.
Events
| Event | Fires when | data |
|---|---|---|
translations.published | Keys are published or unpublished (bulk or single), or a key is created as published | published, keyCount, keys, keysTruncated |
translations.updated | Translation text changes: edits in the dashboard or CLI, saved AI translations, approved AI translations | trigger (edit, ai or review), languages, keyCount, keys, keysTruncated, and for single-key edits published (and renamedFrom when the key was renamed) |
key.created | A translation key is added | keyId, key, module, languages, published |
key.deleted | One or more keys are deleted | keyIds, keyCount, keys, keysTruncated |
import.completed | A file import finishes (langctl push, langctl import, dashboard import) | language, created, updated, skipped, total, overwriteExisting |
project.updated | Project settings change, including languages added or removed | changes (e.g. ["name"], ["languages"]), languagesAdded, languagesRemoved |
Subscribe to * (All events in the dashboard) to receive every event, including ones added later. The catalogue only grows; event names never change.
Key lists are capped at 100 names. When more keys were affected, keysTruncated is true and keyCount has the real total. Payloads never include translation text — fetch it with langctl pull or the export API.
Tip
Rebuilding on publish? Subscribe to translations.published and translations.updated. Edits to keys that are already published, and approvals of AI translations, change what langctl pull returns without a new publish. Add import.completed if you import into published keys.
Payload
Every delivery has the same envelope:
{
"id": "4f0e8f3a-6c1d-4a51-9a52-1d1f0b7f3c2e",
"event": "translations.published",
"createdAt": "2026-10-08T09:30:12.418Z",
"organization": { "id": "8c0a…" },
"project": {
"id": "0bf8…",
"slug": "web-app",
"name": "Web App",
"languages": ["en", "de", "fr"],
"defaultLanguage": "en"
},
"data": {
"published": true,
"keyCount": 2,
"keys": ["home.title", "home.cta"],
"keysTruncated": false
}
} id is the delivery ID. It stays the same when a delivery is retried, so use it to ignore duplicates. project is null for webhook.test.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | langctl-webhooks/1 |
X-Langctl-Event | The event name, e.g. translations.published |
X-Langctl-Delivery | The delivery ID (same as id in the body) |
X-Langctl-Timestamp | Unix time in seconds when this attempt was signed |
X-Langctl-Signature | sha256= followed by the hex HMAC-SHA256 of {timestamp}.{raw body}, keyed with the signing secret |
Verifying signatures
Compute the HMAC over the raw request body — before any JSON parsing — and compare it in constant time. Reject requests whose timestamp is more than 5 minutes old to stop replays. Every retry is signed again with a fresh timestamp.
import crypto from 'node:crypto';
import express from 'express';
const SECRET = process.env.LANGCTL_WEBHOOK_SECRET;
const app = express();
function isValid(rawBody, timestamp, signature) {
if (!timestamp || !signature) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // older than 5 minutes
const expected = 'sha256=' + crypto
.createHmac('sha256', SECRET)
.update(timestamp + '.' + rawBody)
.digest('hex');
return signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
// express.raw keeps the body exactly as sent
app.post('/hooks/langctl', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!isValid(raw, req.get('X-Langctl-Timestamp'), req.get('X-Langctl-Signature'))) {
return res.status(401).send('bad signature');
}
const event = JSON.parse(raw);
res.status(202).send('ok'); // answer quickly, then do the work
if (event.event === 'translations.published') {
triggerDeploy(event.project.slug); // your code
}
});
app.listen(3000); Delivery and retries
- Deliveries are sent in the background; they never slow down or fail the change that triggered them.
- Any
2xxresponse counts as success. Redirects are not followed — point the webhook at the final URL. - langctl waits up to 10 seconds for a response and keeps only the first 1 KB of the body for the delivery log.
- A failed delivery is retried twice: after 30 seconds and after 5 minutes (3 attempts in total). Pending deliveries and retries are queued durably, so they survive langctl restarts and deploys; a retry can arrive a few seconds after its scheduled time.
- Delivery is at-least-once: in rare cases (for example a langctl server stopping mid-request) the same attempt can arrive twice. Use the
X-Langctl-DeliveryID to ignore duplicates. - After 20 deliveries in a row fail every attempt, langctl switches the webhook off, marks it Disabled after failures in the dashboard and emails the organization’s owners and admins (once per disable). Fix the endpoint, use Send test, then turn the webhook back on.
- Events that happen while a webhook is off are not delivered later.
- Details shows the last 100 attempts per webhook: status, duration, error, the response snippet and the payload.
Return a response quickly and do slow work (builds, deploys) asynchronously, so you stay well within the 10-second timeout.
Security
- Webhook URLs must use
https://. URLs with credentials (https://user:pass@…) are rejected. - langctl only connects to public internet addresses. URLs that point — directly or through DNS — to private, loopback, link-local, carrier-grade NAT, multicast or other reserved ranges are rejected when you save the webhook, and checked again on every delivery.
- The signing secret is shown only when the webhook is created or the secret is regenerated. Regenerating invalidates the old secret immediately.
- Only organization admins and owners can see or change webhooks, because webhook URLs often contain tokens of their own.
Example: redeploy on publish
Hosting providers’ deploy hooks (Vercel, Netlify, Cloudflare Pages) accept a plain POST, so you can paste a deploy hook URL straight into a webhook subscribed to translations.published. They don’t check langctl’s signature, so keep the URL secret.
For anything that needs custom headers or logic — for example GitHub’s repository_dispatch, which requires a token — put a small endpoint like the one above in between, verify the signature, then call the provider.
Next Steps
- Publishing Workflow - What publishing means for
langctl pull - CI/CD Integration - Pull translations during your build