How do I set up webhooks?
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 #
- Go to Settings → Webhooks
- Click "Add webhook"
- Enter your endpoint URL (publicly reachable HTTPS)
- Select the events you want
- Add a secret if you want signed requests — you do
- 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 #
| Event | When it fires |
|---|---|
translations.published | Translations published to the CDN |
translations.updated | Translation values changed |
keys.created | New translation keys added |
keys.deleted | Translation keys deleted |
language.added | A language was added to the project |
language.removed | A language was removed |
sync.completed | A GitHub or CLI sync finished |
Content CMS events, if the project uses it:
| Event | When it fires |
|---|---|
content.entry.created · content.entry.updated | Entry written |
content.entry.published · content.entry.unpublished | Entry went live / was pulled |
content.entry.deleted | Entry deleted |
content.entry.bulkUpdated · content.entry.bulkPublished · content.entry.bulkDeleted | Bulk actions |
content.model.created · content.model.updated · content.model.deleted | Model lifecycle |
content.field.added · content.field.updated · content.field.deleted | Field 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 #
{
"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:
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:
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 #
if (eventType === 'translations.published') {
await fetch('https://your-app.com/api/revalidate?path=/', { method: 'POST' })
}Notify Slack when content goes live #
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.
Better I18N