Webhooks
Receive LingoHub events at your own endpoint and verify their signatures.
Webhooks let LingoHub tell your system when something happens, for example when an import completes or a project is created, instead of you polling the API. LingoHub sends each event as an HTTPS POST to an endpoint you register, signed so you can check that it came from LingoHub.
LingoHub follows the Standard Webhooks specification, so you can verify requests with any Standard Webhooks library.
Create an endpoint
Register the URL and the events it should receive. ["*"] subscribes to every event type, including types added later.
curl -X POST https://api.lingohub.com/v2/webhook-endpoints \
-H "Authorization: Bearer $LINGOHUB_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/lingohub",
"description": "Build pipeline",
"event_types": ["import.completed", "import.failed", "export.completed"]
}'The response contains the endpoint's signing secret (whsec_…). LingoHub shows it only once: store it in your secret manager right away.
{
"object_type": "WEBHOOK_ENDPOINT_SECRET",
"id": "whe_0197c8f2-5a6b-7cde-8f12-3456789abcde",
"secret": "whsec_…",
"created_at": "2026-11-04T10:30:00.123Z",
"updated_at": "2026-11-04T10:30:00.123Z"
}| Field | Required | Meaning |
|---|---|---|
url | Yes | An https URL on port 443 whose host resolves only to public addresses. At most 2,048 characters. |
event_types | Yes | The event types to receive, or ["*"] for all. |
description | No | Up to 4,096 characters. |
stack_id or project_id | No | Limits the endpoint to the events of one stack or one project. You can set one of them, not both. |
An endpoint scoped to a stack or project receives only events that belong to it. Workspace-level events, such as member.*, glossary.*, label.*, api_key.* and webhook_endpoint.*, reach only endpoints without a scope.
Managing webhooks needs an API key with the API access permission: view to read endpoints and deliveries, manage to change them.
Events
GET /v2/webhook-event-types lists every type you can subscribe to, with its category. The main ones:
| Category | Event types |
|---|---|
| Projects and branches | project.created, project.updated, project.deleted, branch.created, branch.updated, branch.set_default, branch.deleted |
| Languages | language.added, language.removed, language.settings_changed, language.completed |
| Resources | resource.created, resource.updated, resource.renamed, resource.deleted |
| Imports and exports | import.started, import.completed, import.failed, export.completed, export.failed |
| Automation and AI | pretranslation.completed, ai_translation.completed, ai_review.completed |
| Quality | quality_check.finding_created, quality_check.finding_resolved |
| Translation orders | order.created, order.quoted, order.submitted, order.in_progress, order.completed, order.cancelled |
| Members and stacks | member.invited, member.joined, member.declined, member.removed, member.role_changed, stack.created, stack.updated, stack.deleted |
| Glossaries, style guides and LINAs | glossary.*, term.*, style_guide.*, translation_memory.updated, translation_memory.exported, lina.* |
| Project configuration | label.*, backup.created, backup.deleted, integration.connected, integration.updated, integration.disconnected, linguistic_engine_connection.*, linguistic_engine_model.* |
| Platform | api_key.*, webhook_endpoint.* |
Events fire for changes made in the LingoHub app, through the API and by background jobs alike.
The request
POST /hooks/lingohub HTTP/1.1
Content-Type: application/json
User-Agent: LingoHub-Webhooks/1
webhook-id: evt_0197c8f2-5a6b-7cde-8f12-3456789abcde
webhook-timestamp: 1793783705
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{"id":"evt_0197c8f2-5a6b-7cde-8f12-3456789abcde","type":"import.completed","api_version":"2026-07-01","created_at":"2026-11-04T09:15:02.123+00:00","workspace_id":"space_0197c8f2-5a6b-7cde-8f12-3456789abc01","data":{"id":"rin_0197c8f2-5a6b-7cde-8f12-3456789abc02","type":"UPLOAD","status":"SUCCESS","segmentsCreated":42,"segmentTextsUpdated":17}}| Header | Meaning |
|---|---|
webhook-id | The event ID. It stays the same when a delivery is retried or redelivered, so use it to ignore duplicates. |
webhook-timestamp | Unix time in seconds at which this attempt was signed. |
webhook-signature | v1, followed by the base64 HMAC-SHA256 signature. |
The body is the event envelope:
| Field | Meaning |
|---|---|
id | Event ID (evt_…), the same as webhook-id. |
type | Event type, for example import.completed. |
api_version | The payload version, fixed when the endpoint was created. |
created_at | When the event happened. This isn't the time of the delivery. |
workspace_id | The workspace the event belongs to. |
data | A small snapshot of the object: its public ID and a few fields. To get the complete object, read it from the API. |
BetaWhile API V2 is in beta, the content of
datacan still change. Its keys currently usecamelCase, and some values differ from the V2 resources: an import'sstatusinimport.completedisSUCCESSorDONE, notSUCCEEDED. Read the object from the API when you need its V2 representation.
Verify the signature
Check every request before you trust it:
- Build the signed content:
{webhook-id}.{webhook-timestamp}.{raw body}. Use the raw body bytes exactly as received, not re-serialized JSON. - Compute HMAC-SHA256 over it. The key is your secret without the
whsec_prefix, base64-decoded. - Base64-encode the result and compare it with the value after
v1,inwebhook-signature, in constant time. - Reject requests whose
webhook-timestampis more than a few minutes old, to stop replays. Every attempt is signed with a fresh timestamp, so retries pass this check.
import base64
import hashlib
import hmac
import time
def verify(secret: str, headers: dict, body: bytes, tolerance_seconds: int = 300) -> None:
timestamp = int(headers["webhook-timestamp"])
if abs(time.time() - timestamp) > tolerance_seconds:
raise ValueError("webhook timestamp is too old")
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{headers['webhook-id']}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
signatures = [part.split(",", 1)[1] for part in headers["webhook-signature"].split() if part.startswith("v1,")]
if not any(hmac.compare_digest(expected, signature) for signature in signatures):
raise ValueError("invalid webhook signature")import crypto from "node:crypto";
export function verify(secret, headers, rawBody, toleranceSeconds = 300) {
const timestamp = Number(headers["webhook-timestamp"]);
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) {
throw new Error("webhook timestamp is too old");
}
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const signed = Buffer.concat([Buffer.from(`${headers["webhook-id"]}.${timestamp}.`), rawBody]);
const expected = crypto.createHmac("sha256", key).update(signed).digest();
const valid = headers["webhook-signature"].split(" ")
.filter((part) => part.startsWith("v1,"))
.map((part) => Buffer.from(part.slice(3), "base64"))
.some((signature) => signature.length === expected.length && crypto.timingSafeEqual(signature, expected));
if (!valid) {
throw new Error("invalid webhook signature");
}
}The Standard Webhooks libraries for many languages do the same in one call.
Respond and retry
- Answer with any
2xxstatus within 10 seconds. Do the real work after you respond, for example in a queue. - Any other status, a redirect, a timeout or a connection error counts as a failure. LingoHub doesn't follow redirects.
- LingoHub retries a failed delivery up to 8 attempts in total: right away, then after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours.
- Delivery is at least once: the same event can arrive more than once. Ignore events whose
webhook-idyou already processed. - Events can arrive in any order. Use
created_at, or read the current state from the API, when the order matters.
After 15 failed attempts in a row, LingoHub disables the endpoint (status becomes AUTO_DISABLED) and stops queuing new events for it. Fix the receiver, then turn the endpoint back on with POST /v2/webhook-endpoints/{webhookEndpointId}/enable. Events from the time it was disabled aren't sent afterwards.
Test, inspect and redeliver
POST /v2/webhook-endpoints/{webhookEndpointId}/testsends apingevent to that endpoint only. It is queued like any other delivery.GET /v2/webhook-endpoints/{webhookEndpointId}/deliverieslists the deliveries of an endpoint, withstatus,attempt_count,next_attempt_at,last_response_status,last_errorand up to 8 KB of the last failed response body.POST /v2/webhook-deliveries/{webhookDeliveryId}/redeliversends a failed delivery again.
Deliveries are kept for 30 or 90 days, depending on your plan.
Rotate the secret
POST /v2/webhook-endpoints/{webhookEndpointId}/rotate-secret returns a new secret, again shown only once. The old secret stops working immediately, including for retries of earlier deliveries, so update your receiver right after you rotate. Requests already on their way can still carry the old signature.
Updated about 1 hour ago