Integrations / Webhooks

Webhooks

Receive HTTP callbacks when translation events occur in your project. Use webhooks to trigger deploys, update caches, or sync with external systems.

Setting up webhooks

Configure webhooks from the dashboard webhooks page . Each webhook needs:

  • URL — The endpoint that receives POST requests
  • Events — Which events trigger the webhook
  • Secret — Used to sign payloads for verification

Events

EventDescription
translation.completedA translation batch has finished processing.
strings.detectedNew untranslated strings were found during a scan.
coverage.droppedTranslation coverage for a language dropped below its previous level.

Payload format

Webhooks are sent as POST requests with a JSON body:

{
  "event": "translation.completed",
  "data": {
    "target_language": "es",
    "total": 42,
    "new": 12
  },
  "webhook_id": "wh_abc123",
  "delivered_at": "2025-01-15T10:30:00Z"
}

Verifying webhook signatures

Every Polyglot webhook is signed so your receiver can confirm the request really came from us — not from someone who learned your URL. Two headers carry the signing data:

HeaderValue
x-polyglot-signatureHex-encoded HMAC-SHA256 of `${timestamp}.${body}`, keyed with your webhook's secret.
x-polyglot-timestampUnix seconds at the time the webhook was sent. Use this to reject replays.

Look up the secret for a webhook on the dashboard webhooks page . Treat it like a password — anyone with it can forge requests that verify successfully.

Verification algorithm

  1. Read the raw request body as a string (do NOT re-serialize parsed JSON — whitespace matters for the HMAC).
  2. Read the x-polyglot-timestamp and x-polyglot-signature headers.
  3. Reject if the timestamp is more than 5 minutes old (replay protection).
  4. Compute HMAC-SHA256(secret, `$timestamp.$body`), hex-encode the result.
  5. Compare to the signature header using a constant-time compare. Reject on mismatch.
  6. Only then trust the payload and process the event.

Node.js / TypeScript

import crypto from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

export function verifyPolyglotWebhook(
  rawBody: string,
  headers: Record<string, string | undefined>,
  secret: string,
): boolean {
  const signature = headers["x-polyglot-signature"];
  const timestamp = headers["x-polyglot-timestamp"];
  if (!signature || !timestamp) return false;

  // Replay protection — reject anything older than 5 minutes.
  const ts = Number(timestamp);
  if (!Number.isFinite(ts)) return false;
  if (Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  // Use timingSafeEqual to avoid leaking signature info via response timing.
  const a = Buffer.from(signature, "hex");
  const b = Buffer.from(expected, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Python

import hmac
import hashlib
import time

TOLERANCE_SECONDS = 5 * 60

def verify_polyglot_webhook(raw_body: bytes, headers: dict, secret: str) -> bool:
    signature = headers.get("x-polyglot-signature")
    timestamp = headers.get("x-polyglot-timestamp")
    if not signature or not timestamp:
        return False

    try:
        ts = int(timestamp)
    except ValueError:
        return False
    if abs(time.time() - ts) > TOLERANCE_SECONDS:
        return False

    signed = f"{timestamp}.{raw_body.decode('utf-8')}".encode("utf-8")
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature, expected)

Ruby

require "openssl"

TOLERANCE_SECONDS = 5 * 60

def verify_polyglot_webhook(raw_body, headers, secret)
  signature = headers["x-polyglot-signature"]
  timestamp = headers["x-polyglot-timestamp"]
  return false unless signature && timestamp

  ts = Integer(timestamp) rescue (return false)
  return false if (Time.now.to_i - ts).abs > TOLERANCE_SECONDS

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
  Rack::Utils.secure_compare(signature, expected)
end

Go

package webhook

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "net/http"
    "strconv"
    "time"
)

const toleranceSeconds = 5 * 60

func Verify(r *http.Request, rawBody []byte, secret string) bool {
    sig := r.Header.Get("x-polyglot-signature")
    tsHeader := r.Header.Get("x-polyglot-timestamp")
    if sig == "" || tsHeader == "" {
        return false
    }

    ts, err := strconv.ParseInt(tsHeader, 10, 64)
    if err != nil {
        return false
    }
    if abs(time.Now().Unix()-ts) > toleranceSeconds {
        return false
    }

    mac := hmac.New(sha256.New, []byte(secret))
    fmt.Fprintf(mac, "%s.%s", tsHeader, rawBody)
    expected := hex.EncodeToString(mac.Sum(nil))

    return hmac.Equal([]byte(sig), []byte(expected))
}

func abs(n int64) int64 {
    if n < 0 {
        return -n
    }
    return n
}

Common pitfalls

  • Re-serializing the body. If your framework parses JSON before your handler sees it, you have to either capture the raw body separately or your computed HMAC will not match — JSON whitespace, key ordering, and number formatting all change the signature input.
  • Using string comparison. Always use a constant-time comparison (Node's crypto.timingSafeEqual, Python's hmac.compare_digest, Ruby's Rack::Utils.secure_compare, Go's hmac.Equal). String === leaks signature bytes through response timing.
  • Forgetting the timestamp check. Without it, an attacker who captures one valid signed request can replay it forever. 5 minutes is a reasonable tolerance; tighten if your servers are clock-synchronized via NTP.
  • Logging the secret. Don't. If a secret leaks, rotate it from the dashboard immediately — it's the same as handing out a fake-event factory.

Testing your verification

Use the Send test event button on the webhook's detail page in the dashboard. It dispatches a signed webhook.test event with a known payload — your receiver should accept it and return 200. If it returns 401, your verification is broken; if it returns 200 but your real events still fail, you're probably re-serializing the body.

Retry policy

Failed deliveries (non-2xx response, timeout, or connection error) are retried up to 5 times with exponential backoff: roughly 1 minute, 5 minutes, 25 minutes, 2 hours, then 10 hours before the final attempt. After all retries are exhausted, the delivery is marked failed and visible on the webhook's detail page in the dashboard.

Receiver timeouts are 10 seconds — if your handler does heavy work (cache busts, deploy triggers, downstream API calls), enqueue it to a background job and return 200 immediately. A slow handler that eventually returns 200 still counts as a successful delivery, but you'll see deliveries piling up if a single event takes the full timeout.

Use cases

  • Auto-deploy — Trigger a deployment when translations are complete so your site always has the latest translations.
  • Cache invalidation — Bust CDN or application caches when translation files are updated.
  • Slack notifications — Post to a Slack channel when new strings are detected or coverage changes.
  • External TMS sync — Forward translations to an external translation management system.
Webhooks - Docs | Polyglot