Better I18NBetter I18N

Webhooks

Receive real-time HTTP notifications when translations are published, keys change, or languages are added

Overview

Webhooks let your application react instantly to translation events — no polling required. When something changes in Better i18n (a publish, a new key, a language addition), the platform sends an HTTP POST to your endpoint within seconds.

Common use cases:

  • Next.js ISR revalidation — trigger revalidateTag("translations") on translations.published
  • Cache invalidation — clear your Redis/Varnish/CDN cache when translations update
  • CI/CD pipelines — kick off a deploy when new translations are approved
  • Slack/Discord notifications — alert your team when a sync completes
  • Static-site rebuilds — trigger a GitHub Actions repository_dispatch on content.entry.published to rebuild a site whose data comes from the Content CMS

Setting Up an Endpoint

  1. Go to your project → IntegrationsWebhooks
  2. Click + Add Webhook
  3. Enter your endpoint URL and select the events you want to receive
  4. Copy and save the webhook secret — it's only shown once

The webhook secret is displayed only once at creation time. Store it immediately in your environment variables. If you lose it, you can regenerate it from the Webhooks page — but all existing integrations using the old secret will stop verifying until updated.


Payload Structure

Every webhook request has the same envelope format:

{
  "id": "evt_01jfk2abcd...",
  "webhookConfigId": "wh_01jfk2...",
  "eventType": "translations.published",
  "timestamp": 1734567890123,
  "createdAt": "2024-12-19T10:31:30.123Z",
  "version": "1",
  "data": {
    // event-specific fields (see Events below)
  }
}
FieldTypeDescription
idstringStable event id (evt_*). Use as an idempotency key — if you see the same id twice, treat it as the same event (e.g. on manual redelivery).
webhookConfigIdstringID of the webhook endpoint that fired this event
eventTypestringOne of the event types listed below
timestampnumberUnix milliseconds when the event was dispatched
createdAtstringISO-8601 timestamp, same moment as timestamp
versionstringEnvelope version — currently "1"
dataobjectEvent-specific payload

Request Headers

POST /api/webhooks/i18n HTTP/1.1
Content-Type: application/json
X-Better-I18n-Signature: t=1734567890,v1=a1b2c3...,sha256=d4e5f6...
X-Better-I18n-Event: translations.published
X-Better-I18n-Id: evt_01jfk2abcd...
HeaderDescription
X-Better-I18n-SignatureDual-format HMAC signature — see below
X-Better-I18n-EventThe event type (mirrors payload.eventType)
X-Better-I18n-IdThe stable event id (mirrors payload.id)

Signature format

The signature header is a comma-separated list of components, Stripe-style:

t=<unix_seconds>,v1=<hex>,sha256=<hex>
  • t — unix seconds when the payload was signed. Your handler must reject requests whose t is older than ~5 minutes (replay protection).
  • v1 — HMAC-SHA256 of the string ${t}.${body}. This is the current signature scheme; it binds the timestamp into the signature so an attacker can't replay a captured request with a fresh t.
  • sha256 — HMAC-SHA256 of just the body. Legacy scheme, retained for backward compatibility with pre-v1 integrations. New consumers should ignore this component and verify v1.

Verifying Signatures

Always verify the signature before processing a webhook. This proves the request came from Better i18n and hasn't been tampered with.

The signature is computed as:

HMAC-SHA256(secret, rawRequestBody)

and sent as sha256=<hex-digest> in the X-Better-I18n-Signature header.

Use a timing-safe comparison (timingSafeEqual in Node.js, crypto.subtle in the browser/edge). A regular string === check is vulnerable to timing attacks.

Next.js App Router

app/api/webhooks/i18n/route.ts
import { createHmac, timingSafeEqual } from "crypto";

const WEBHOOK_SECRET = process.env.BETTER_I18N_WEBHOOK_SECRET!;
const TOLERANCE_SECONDS = 300;

function verifySignature(body: string, header: string): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((p) => {
      const [k, ...rest] = p.split("=");
      return [k, rest.join("=")];
    }),
  );
  const t = Number(parts.t);
  if (!Number.isFinite(t) || !parts.v1) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", WEBHOOK_SECRET)
    .update(`${t}.${body}`)
    .digest("hex");
  return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get("x-better-i18n-signature") ?? "";

  if (!verifySignature(body, signature)) {
    return new Response("Unauthorized", { status: 401 });
  }

  const event = JSON.parse(body);

  if (event.eventType === "translations.published") {
    // Revalidate Next.js ISR cache
    const { revalidateTag } = await import("next/cache");
    revalidateTag("translations");
  }

  return new Response("OK");
}

Edge Runtime (Cloudflare Workers / Vercel Edge)

webhook-handler.ts
async function verifySignature(
  secret: string,
  body: string,
  header: string,
): Promise<boolean> {
  const parts = Object.fromEntries(
    header.split(",").map((p) => {
      const [k, ...rest] = p.split("=");
      return [k, rest.join("=")];
    }),
  );
  const t = Number(parts.t);
  if (!Number.isFinite(t) || !parts.v1) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false;

  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["verify"],
  );
  const sigBytes = new Uint8Array(
    parts.v1.match(/.{2}/g)!.map((b) => parseInt(b, 16)),
  );
  return crypto.subtle.verify(
    "HMAC",
    key,
    sigBytes,
    new TextEncoder().encode(`${t}.${body}`),
  );
}

export default {
  async fetch(req: Request, env: Env) {
    const body = await req.text();
    const signature = req.headers.get("x-better-i18n-signature") ?? "";

    const valid = await verifySignature(
      env.BETTER_I18N_WEBHOOK_SECRET,
      body,
      signature,
    );

    if (!valid) return new Response("Unauthorized", { status: 401 });

    const event = JSON.parse(body);
    // handle event...
    return new Response("OK");
  },
};

Node.js / Express

webhook.ts
import express from "express";
import { createHmac, timingSafeEqual } from "crypto";

const app = express();

// IMPORTANT: use raw body — parsed JSON loses byte-for-byte accuracy
app.post(
  "/webhooks/i18n",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const body = req.body.toString();
    const header = req.headers["x-better-i18n-signature"] as string;

    const parts = Object.fromEntries(
      header.split(",").map((p) => {
        const [k, ...rest] = p.split("=");
        return [k, rest.join("=")];
      }),
    );
    const t = Number(parts.t);
    if (!Number.isFinite(t) || !parts.v1) {
      return res.status(401).send("Unauthorized");
    }
    if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) {
      return res.status(401).send("Stale request");
    }

    const expected = createHmac("sha256", process.env.BETTER_I18N_WEBHOOK_SECRET!)
      .update(`${t}.${body}`)
      .digest("hex");
    if (!timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))) {
      return res.status(401).send("Bad signature");
    }

    const event = JSON.parse(body);
    // handle event...
    res.status(200).send("OK");
  },
);

In Express, use express.raw() (not express.json()) before verifying signatures. express.json() parses the body first, which can alter whitespace and break signature verification.


Events

translations.published

Fired after translations are published to the CDN (or committed to GitHub). This is the primary event for triggering cache revalidation.

{
  "webhookConfigId": "wh_01jfk2...",
  "eventType": "translations.published",
  "timestamp": 1734567890123,
  "data": {
    "org": "acme",
    "project": "webapp",
    "languages": ["tr", "de", "fr"],
    "publishedAt": "2024-12-19T10:31:30.123Z",
    "keysCount": 42
  }
}
FieldDescription
orgOrganization slug
projectProject slug
languagesLanguage codes that were published
publishedAtISO 8601 timestamp
keysCountNumber of translation keys published

translations.updated

Fired when a translation value is edited in the dashboard (not yet published).

{
  "eventType": "translations.updated",
  "data": {
    "org": "acme",
    "project": "webapp",
    "language": "tr",
    "keysCount": 5
  }
}

keys.created

Fired when new translation keys are added to a project.

{
  "eventType": "keys.created",
  "data": {
    "org": "acme",
    "project": "webapp",
    "keys": ["auth.login.title", "auth.login.submit"],
    "namespace": "auth"
  }
}

keys.deleted

Fired when translation keys are removed from a project.

{
  "eventType": "keys.deleted",
  "data": {
    "org": "acme",
    "project": "webapp",
    "keys": ["deprecated.old_key"],
    "namespace": "deprecated"
  }
}

sync.completed

Fired when a GitHub sync job finishes (either success or failure).

{
  "eventType": "sync.completed",
  "data": {
    "org": "acme",
    "project": "webapp",
    "status": "completed",
    "keysImported": 128,
    "duration": 4200
  }
}

language.added

Fired when a new target language is added to a project.

{
  "eventType": "language.added",
  "data": {
    "org": "acme",
    "project": "webapp",
    "language": "ja",
    "languageName": "Japanese"
  }
}

language.removed

Fired when a target language is removed from a project.

{
  "eventType": "language.removed",
  "data": {
    "org": "acme",
    "project": "webapp",
    "language": "pt"
  }
}

Content CMS Events

If your project uses the Content CMS (@better-i18n/sdk), every mutation emits a webhook. Use these to rebuild static sites on publish, invalidate downstream caches, or notify teams when structural changes happen.

Shared fields

All content.* events carry the same base envelope plus a standard set of fields:

FieldTypeDescription
eventstringEvent name (same as eventType in the outer envelope)
timestampstring (ISO 8601)When the mutation occurred
orgstringOrganization slug
projectstringProject slug
modelstring | nullContent model slug (e.g. "blog-post")
actorobject | nullWho triggered the mutation
actor.userIdstringUser id (or agent id for mcp)
actor.via"ui" | "mcp" | "api" | "system"Where the mutation came from

The actor.via field lets downstream consumers filter by provenance — for example, only rebuilding when a human publishes (ui) and ignoring bulk agent corrections (mcp).


content.entry.created

Fired when a new entry is inserted into a content model.

{
  "eventType": "content.entry.created",
  "data": {
    "event": "content.entry.created",
    "timestamp": "2026-04-15T10:00:00.000Z",
    "org": "acme",
    "project": "marketing-site",
    "model": "blog-post",
    "entry": {
      "id": "ent_01abc",
      "slug": "hello-world",
      "status": "draft",
      "previousStatus": null,
      "publishedAt": null,
      "sourceLanguageCode": "en"
    },
    "languages": ["en"],
    "changedLanguages": ["en"],
    "actor": { "userId": "usr_xyz", "via": "ui" }
  }
}

content.entry.updated

Fired when an entry is edited and the status does NOT change. If the edit also transitions the status, a content.entry.published or content.entry.unpublished event is emitted instead.

{
  "eventType": "content.entry.updated",
  "data": {
    "event": "content.entry.updated",
    "timestamp": "2026-04-15T10:05:00.000Z",
    "org": "acme",
    "project": "marketing-site",
    "model": "blog-post",
    "entry": {
      "id": "ent_01abc",
      "slug": "hello-world",
      "status": "draft",
      "previousStatus": "draft",
      "publishedAt": null,
      "sourceLanguageCode": "en"
    },
    "languages": ["en", "tr"],
    "changedLanguages": ["tr"],
    "actor": { "userId": "usr_xyz", "via": "ui" }
  }
}

The changedLanguages array tells you which languages were touched by this mutation — use it to scope cache invalidation instead of flushing every locale.

content.entry.published

Fired when an entry transitions to status: "published". This is the event to subscribe to for most static-site rebuild workflows.

{
  "eventType": "content.entry.published",
  "data": {
    "event": "content.entry.published",
    "timestamp": "2026-04-15T10:10:00.000Z",
    "org": "acme",
    "project": "marketing-site",
    "model": "blog-post",
    "entry": {
      "id": "ent_01abc",
      "slug": "hello-world",
      "status": "published",
      "previousStatus": "draft",
      "publishedAt": "2026-04-15T10:10:00.000Z",
      "sourceLanguageCode": "en"
    },
    "languages": ["en", "tr"],
    "changedLanguages": [],
    "actor": { "userId": "usr_xyz", "via": "ui" }
  }
}

previousStatus lets you distinguish first-publishes ("draft" → "published") from re-publishes ("published" → "published"). Useful for welcome emails, social announcements, etc.

content.entry.unpublished

Fired when a published entry reverts to draft or archived.

{
  "eventType": "content.entry.unpublished",
  "data": {
    "event": "content.entry.unpublished",
    "timestamp": "2026-04-15T10:20:00.000Z",
    "org": "acme",
    "project": "marketing-site",
    "model": "blog-post",
    "entry": {
      "id": "ent_01abc",
      "slug": "hello-world",
      "status": "draft",
      "previousStatus": "published",
      "publishedAt": null,
      "sourceLanguageCode": "en"
    },
    "languages": ["en", "tr"],
    "changedLanguages": [],
    "actor": { "userId": "usr_xyz", "via": "ui" }
  }
}

content.entry.deleted

Fired when an entry is hard-deleted. The payload carries the entry slug so you can purge it from downstream caches.

{
  "eventType": "content.entry.deleted",
  "data": {
    "event": "content.entry.deleted",
    "timestamp": "2026-04-15T10:30:00.000Z",
    "org": "acme",
    "project": "marketing-site",
    "model": "blog-post",
    "entry": {
      "id": "ent_01abc",
      "slug": "hello-world",
      "status": null,
      "previousStatus": null,
      "publishedAt": null,
      "sourceLanguageCode": null
    },
    "languages": ["en", "tr"],
    "actor": { "userId": "usr_xyz", "via": "ui" }
  }
}

content.entry.bulkPublished / bulkUpdated / bulkDeleted

Bulk mutations fire two sets of events:

  1. A single rollup (bulkPublished / bulkUpdated / bulkDeleted) with the full list of entries in one payload.
  2. N per-entry events — one content.entry.published / .unpublished / .updated / .deleted per affected entry.

This lets you subscribe granularly (e.g., only content.entry.published for per-entry CDN purge) or coarsely (content.entry.bulkPublished for one rebuild no matter how many entries are in the batch).

{
  "eventType": "content.entry.bulkPublished",
  "data": {
    "event": "content.entry.bulkPublished",
    "timestamp": "2026-04-15T10:00:00.000Z",
    "org": "acme",
    "project": "marketing-site",
    "model": "blog-post",
    "entries": [
      { "id": "ent_1", "slug": "post-a", "previousStatus": "draft",     "status": "published" },
      { "id": "ent_2", "slug": "post-b", "previousStatus": "published", "status": "published" }
    ],
    "count": 2,
    "languages": ["en", "tr"],
    "actor": { "userId": "mcp-agent", "via": "mcp" }
  }
}

bulkUpdated may mix per-entry transitions — each row in entries[] carries its own previousStatus / status so you can tell which entries newly became published vs. reverted vs. just edited.


content.model.created / updated / deleted

Fired when a content model (the schema for a content type — e.g. "Blog Post") is created, modified, or deleted. Model mutations are relatively rare but have a large blast radius; subscribe if your application type-generates from the schema.

{
  "eventType": "content.model.created",
  "data": {
    "event": "content.model.created",
    "timestamp": "2026-04-15T09:00:00.000Z",
    "org": "acme",
    "project": "marketing-site",
    "model": "blog-post",
    "actor": { "userId": "usr_xyz", "via": "ui" }
  }
}

content.field.added / updated / deleted

Fired when a custom field is added to, modified on, or removed from a content model. The field block describes the field's identity and localization flag.

{
  "eventType": "content.field.added",
  "data": {
    "event": "content.field.added",
    "timestamp": "2026-04-15T09:30:00.000Z",
    "org": "acme",
    "project": "marketing-site",
    "model": "blog-post",
    "field": {
      "name": "hero_image",
      "type": "image",
      "localized": false
    },
    "actor": { "userId": "usr_xyz", "via": "ui" }
  }
}

Responding to Webhooks

Your endpoint must return a 2xx status code within 10 seconds, otherwise the delivery is marked as failed.

  • Return 200 OK (or any 2xx) to acknowledge receipt
  • Do not perform long-running work synchronously — offload to a queue
  • Better i18n does not retry failed deliveries automatically, but you can manually redeliver from the Webhooks page

Delivery Logs

Every delivery attempt is logged in the dashboard under Integrations → Webhooks → Delivery Log. For each entry you can see:

  • HTTP status code
  • Event type
  • Delivery timestamp
  • Response body (first 500 characters)

You can click Redeliver on any past delivery to resend the exact same payload.


Managing the Secret

Your webhook secret is used to sign all outgoing payloads. Keep it in environment variables — never hardcode it.

# .env.local
BETTER_I18N_WEBHOOK_SECRET=whsec_a1b2c3d4...

If you need to rotate the secret (e.g., after a potential leak), click Regenerate Secret in the Webhooks settings. The new secret is shown once — update your environment variables before closing the dialog.

After regenerating, all deliveries signed with the old secret will fail verification until you deploy the new secret to your application.


Recipe: Rebuild a static site on content publish

A common pattern for Content CMS consumers is bridging a Better i18n webhook to a GitHub Actions repository_dispatch, which rebuilds the site on demand:

Content CMS publish
  → content.entry.published webhook
  → your webhook handler (Vercel Function, CF Worker, etc.)
  → POST /repos/OWNER/REPO/dispatches  (GitHub REST API)
  → GitHub Actions rebuilds + deploys

Minimal bridge (runs on any edge or Node runtime):

webhook-bridge.ts
async function handler(req: Request) {
  const body = await req.text();
  const sig = req.headers.get("x-better-i18n-signature") ?? "";
  if (!await verifySignature(SECRET, body, sig)) {
    return new Response("Unauthorized", { status: 401 });
  }

  const event = JSON.parse(body);

  // Only rebuild on actual publishes of blog posts
  const shouldRebuild =
    (event.eventType === "content.entry.published"
      || event.eventType === "content.entry.bulkPublished")
    && event.data.model === "blog-post";

  if (!shouldRebuild) return new Response("OK");

  await fetch(
    "https://api.github.com/repos/YOUR_ORG/YOUR_REPO/dispatches",
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${GITHUB_DISPATCH_TOKEN}`,
        Accept: "application/vnd.github+json",
      },
      body: JSON.stringify({
        event_type: "content-updated",
        client_payload: {
          model: event.data.model,
          entrySlug: event.data.entry?.slug,
          via: event.data.actor?.via,
        },
      }),
    },
  );

  return new Response("Dispatched");
}

And the matching workflow:

.github/workflows/deploy-on-content.yml
on:
  repository_dispatch:
    types: [content-updated]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - run: bun install
      - run: bun run build
      - run: bun run deploy

Filter by event.data.actor?.via === "ui" if you only want to rebuild on human-triggered publishes (skipping MCP / API bulk corrections).

On this page