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")ontranslations.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_dispatchoncontent.entry.publishedto rebuild a site whose data comes from the Content CMS
Setting Up an Endpoint
- Go to your project → Integrations → Webhooks
- Click + Add Webhook
- Enter your endpoint URL and select the events you want to receive
- 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)
}
}| Field | Type | Description |
|---|---|---|
id | string | Stable 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). |
webhookConfigId | string | ID of the webhook endpoint that fired this event |
eventType | string | One of the event types listed below |
timestamp | number | Unix milliseconds when the event was dispatched |
createdAt | string | ISO-8601 timestamp, same moment as timestamp |
version | string | Envelope version — currently "1" |
data | object | Event-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...| Header | Description |
|---|---|
X-Better-I18n-Signature | Dual-format HMAC signature — see below |
X-Better-I18n-Event | The event type (mirrors payload.eventType) |
X-Better-I18n-Id | The 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 whosetis 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 fresht.sha256— HMAC-SHA256 of just the body. Legacy scheme, retained for backward compatibility with pre-v1 integrations. New consumers should ignore this component and verifyv1.
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
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)
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
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
}
}| Field | Description |
|---|---|
org | Organization slug |
project | Project slug |
languages | Language codes that were published |
publishedAt | ISO 8601 timestamp |
keysCount | Number 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:
| Field | Type | Description |
|---|---|---|
event | string | Event name (same as eventType in the outer envelope) |
timestamp | string (ISO 8601) | When the mutation occurred |
org | string | Organization slug |
project | string | Project slug |
model | string | null | Content model slug (e.g. "blog-post") |
actor | object | null | Who triggered the mutation |
actor.userId | string | User 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:
- A single rollup (
bulkPublished/bulkUpdated/bulkDeleted) with the full list of entries in one payload. - N per-entry events — one
content.entry.published/.unpublished/.updated/.deletedper 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 any2xx) 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 + deploysMinimal bridge (runs on any edge or Node runtime):
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:
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 deployFilter by event.data.actor?.via === "ui" if you only want to rebuild on human-triggered publishes (skipping MCP / API bulk corrections).