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"
}
FieldRequiredMeaning
urlYesAn https URL on port 443 whose host resolves only to public addresses. At most 2,048 characters.
event_typesYesThe event types to receive, or ["*"] for all.
descriptionNoUp to 4,096 characters.
stack_id or project_idNoLimits 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:

CategoryEvent types
Projects and branchesproject.created, project.updated, project.deleted, branch.created, branch.updated, branch.set_default, branch.deleted
Languageslanguage.added, language.removed, language.settings_changed, language.completed
Resourcesresource.created, resource.updated, resource.renamed, resource.deleted
Imports and exportsimport.started, import.completed, import.failed, export.completed, export.failed
Automation and AIpretranslation.completed, ai_translation.completed, ai_review.completed
Qualityquality_check.finding_created, quality_check.finding_resolved
Translation ordersorder.created, order.quoted, order.submitted, order.in_progress, order.completed, order.cancelled
Members and stacksmember.invited, member.joined, member.declined, member.removed, member.role_changed, stack.created, stack.updated, stack.deleted
Glossaries, style guides and LINAsglossary.*, term.*, style_guide.*, translation_memory.updated, translation_memory.exported, lina.*
Project configurationlabel.*, backup.created, backup.deleted, integration.connected, integration.updated, integration.disconnected, linguistic_engine_connection.*, linguistic_engine_model.*
Platformapi_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}}
HeaderMeaning
webhook-idThe event ID. It stays the same when a delivery is retried or redelivered, so use it to ignore duplicates.
webhook-timestampUnix time in seconds at which this attempt was signed.
webhook-signaturev1, followed by the base64 HMAC-SHA256 signature.

The body is the event envelope:

FieldMeaning
idEvent ID (evt_…), the same as webhook-id.
typeEvent type, for example import.completed.
api_versionThe payload version, fixed when the endpoint was created.
created_atWhen the event happened. This isn't the time of the delivery.
workspace_idThe workspace the event belongs to.
dataA small snapshot of the object: its public ID and a few fields. To get the complete object, read it from the API.
🚧

Beta

While API V2 is in beta, the content of data can still change. Its keys currently use camelCase, and some values differ from the V2 resources: an import's status in import.completed is SUCCESS or DONE, not SUCCEEDED. Read the object from the API when you need its V2 representation.

Verify the signature

Check every request before you trust it:

  1. Build the signed content: {webhook-id}.{webhook-timestamp}.{raw body}. Use the raw body bytes exactly as received, not re-serialized JSON.
  2. Compute HMAC-SHA256 over it. The key is your secret without the whsec_ prefix, base64-decoded.
  3. Base64-encode the result and compare it with the value after v1, in webhook-signature, in constant time.
  4. Reject requests whose webhook-timestamp is 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 2xx status 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-id you 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}/test sends a ping event to that endpoint only. It is queued like any other delivery.
  • GET /v2/webhook-endpoints/{webhookEndpointId}/deliveries lists the deliveries of an endpoint, with status, attempt_count, next_attempt_at, last_response_status, last_error and up to 8 KB of the last failed response body.
  • POST /v2/webhook-deliveries/{webhookDeliveryId}/redeliver sends 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.


Did this page help you?