Webhooks
On this page
- Overview
- Setting Up an Endpoint
- Payload Structure
- Request Headers
- Signature format
- Verifying Signatures
- Next.js App Router
- Edge Runtime (Cloudflare Workers / Vercel Edge)
- Node.js / Express
- Events
- translations.published
- translations.updated
- keys.created
- keys.deleted
- sync.completed
- language.added
- language.removed
- Content CMS Events
- Shared fields
- content.entry.created
- content.entry.updated
- content.entry.published
- content.entry.unpublished
- content.entry.deleted
- content.entry.bulkPublished / bulkUpdated / bulkDeleted
- content.model.created / updated / deleted
- content.field.added / updated / deleted
- Responding to Webhooks
- Delivery Logs
- Managing the Secret
- Recipe: Rebuild a static site on content publish
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
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.
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");
},
);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" }
}
}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": "media",
"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.
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).
Better I18N