Skip to content
better-i18n.com

Webhooks let you receive notifications when things happen in your project — translations published, keys created, content entries published, a sync finishing.

Setting up a webhook #

  1. Go to Settings → Webhooks
  2. Click "Add webhook"
  3. Enter your endpoint URL (publicly reachable HTTPS)
  4. Select the events you want
  5. Add a secret if you want signed requests — you do
  6. Click Save

Then use Send test event to confirm your endpoint answers. Delivery attempts are logged, so a test that fails tells you the status code it got.

Available events #

EventWhen it fires
translations.publishedTranslations published to the CDN
translations.updatedTranslation values changed
keys.createdNew translation keys added
keys.deletedTranslation keys deleted
language.addedA language was added to the project
language.removedA language was removed
sync.completedA GitHub or CLI sync finished

Content CMS events, if the project uses it:

EventWhen it fires
content.entry.created · content.entry.updatedEntry written
content.entry.published · content.entry.unpublishedEntry went live / was pulled
content.entry.deletedEntry deleted
content.entry.bulkUpdated · content.entry.bulkPublished · content.entry.bulkDeletedBulk actions
content.model.created · content.model.updated · content.model.deletedModel lifecycle
content.field.added · content.field.updated · content.field.deletedField lifecycle

Note the plurals — keys.created, not key.created. Subscribing to a name that does not exist fails quietly, which is a bad afternoon.

Webhook payload format #

JSON
{
  "id": "evt_9f2c...",
  "webhookConfigId": "wh_...",
  "eventType": "translations.published",
  "timestamp": 1774000000000,
  "createdAt": "2026-03-15T10:30:00.000Z",
  "version": "1",
  "data": { }
}

data carries the event-specific fields. The id is stable across a manual replay, so dedupe on it.

Headers on every request:

Code
X-Better-I18n-Signature: t=<unix>,v1=<sig>,sha256=<legacy-sig>
X-Better-I18n-Event: translations.published
X-Better-I18n-Id: evt_9f2c...

Verifying webhook signatures #

The signature header carries three comma-separated parts. Verify v1, which binds the timestamp into the signature so a captured request cannot be replayed later:

TypeScript
import { createHmac, timingSafeEqual } from 'crypto'

function verifyWebhook(rawBody: string, header: string, secret: string): boolean {
  const parts = new Map(header.split(',').map((p) => p.split('=') as [string, string]))
  const t = parts.get('t')
  const v1 = parts.get('v1')
  if (!t || !v1) return false

  // Reject anything older than five minutes.
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false

  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
  return timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
}

Two things that break verification silently: signing the parsed body instead of the raw bytes, and signing body instead of `${t}.${body}`. The sha256= part is the older scheme (HMAC over the body alone) and exists only for consumers written before v1 — ignore it in new code.

Common use cases #

Trigger Next.js revalidation on publish #

TypeScript
if (eventType === 'translations.published') {
  await fetch('https://your-app.com/api/revalidate?path=/', { method: 'POST' })
}

Notify Slack when content goes live #

TypeScript
if (eventType === 'content.entry.published') {
  await slack.send({ text: `Published: ${data.entrySlug}` })
}

Delivery, failures and replay #

Each event is delivered once. There is no automatic retry and no exponential backoff, and a failing endpoint is never auto-disabled — so a deploy window where your endpoint 502s means those events are not coming back on their own.

What you get instead:

  • Every attempt is logged with its response status and body, visible in Settings → Webhooks
  • Redeliver re-sends a logged event — same id, same bytes, fresh signature

Design your handler to be idempotent (dedupe on id) and treat the delivery log as the source of truth for what actually arrived. If your endpoint might be briefly unavailable, prefer a queue in front of it over relying on retries that do not exist.