Skip to content

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

  1. In the dashboard, open Organization → Webhooks (admins and owners only).
  2. Click New webhook, enter your endpoint URL, choose the events and, optionally, limit it to one project.
  3. Copy the signing secret. It is shown only once; if you lose it, use Regenerate secret.
  4. Click Details → Send test to send a webhook.test delivery and check that your endpoint answers with a 2xx status.

Each organization can have up to 10 webhooks. Webhooks fire for changes made anywhere: the dashboard, the CLI and CI.

Events

EventFires whendata
translations.publishedKeys are published or unpublished (bulk or single), or a key is created as publishedpublished, keyCount, keys, keysTruncated
translations.updatedTranslation text changes: edits in the dashboard or CLI, saved AI translations, approved AI translationstrigger (edit, ai or review), languages, keyCount, keys, keysTruncated, and for single-key edits published (and renamedFrom when the key was renamed)
key.createdA translation key is addedkeyId, key, module, languages, published
key.deletedOne or more keys are deletedkeyIds, keyCount, keys, keysTruncated
import.completedA file import finishes (langctl push, langctl import, dashboard import)language, created, updated, skipped, total, overwriteExisting
project.updatedProject settings change, including languages added or removedchanges (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:

json
{
"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

HeaderValue
Content-Typeapplication/json
User-Agentlangctl-webhooks/1
X-Langctl-EventThe event name, e.g. translations.published
X-Langctl-DeliveryThe delivery ID (same as id in the body)
X-Langctl-TimestampUnix time in seconds when this attempt was signed
X-Langctl-Signaturesha256= 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.

server.js
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 2xx response 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-Delivery ID 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