Webhooks
A webhook is an HTTPS endpoint of yours that easyi18n calls when a project
publishes. A build system uses it to pull new files as soon as a version
exists; a CMS uses it to pull the documents that were just published. Every
route here needs the publish scope and sits under
https://api.easyi18n.com/v1/projects/{projectId}.
Events
| Event | Fires when |
|---|---|
strings.published | A version of the project's keys is published or restored. |
content.published | A Content publish moves at least one document, or a document is rolled back. |
The payload is small on purpose: it says what was published, and you read the content through the API.
A strings.published request body:
{
"id": "evt_2026.10.11.1",
"type": "strings.published",
"schemaVersion": 1,
"createdAt": "2026-10-11T14:02:09.114Z",
"project": {"id": "p7Hq2mXc", "handle": "acme", "slug": "mobile-app"},
"data": {
"versionId": "2026.10.11.1",
"label": "Release 4.2",
"keyCount": 412,
"languages": [
{"code": "en", "isBase": true},
{"code": "es", "isBase": false}
]
}
}
Read the files with
GET /translations?version=
and the versionId.
In a content.published body, data carries the publishId, a summary of
counts, and one row per document:
{
"publishId": "5Hn3vT9cRw",
"summary": {"docsPublished": 1, "docsHeld": 0, "docsUnchanged": 3, "docsSkipped": 0, "docsTooLarge": 0},
"documents": [
{"docId": "wp-post-42", "action": "published", "versionId": "7c1dZq4kLm", "externalId": "42"}
],
"truncated": false
}
action is published, held, unchanged or skipped. Pull the documents
whose action is published with
GET /content/{docId}. documents
holds at most 200 rows; truncated: true says there were more, and then the
listing is the complete source.
externalId is omitted when the document has none.
Register an endpoint
curl -X POST "https://api.easyi18n.com/v1/projects/$PROJECT_ID/webhooks" \
-H "Authorization: Bearer $EASYI18N_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://ci.example.com/hooks/easyi18n", "events": ["strings.published"]}'
{
"webhookId": "3Kd8mQ2xLp",
"url": "https://ci.example.com/hooks/easyi18n",
"events": ["strings.published"],
"enabled": true,
"secret": "whsec_…"
}
Store the secret now. This response is the only one that carries it; later
reads show its last four characters as secretHint.
The URL has to be https, without credentials in it, and its host must
resolve to a public address. A private, loopback or metadata address is
400 invalid_argument, and the same check runs again before every delivery.
events is required. A project holds up to five webhooks through the API;
the sixth is 409 webhook_limit.
The other routes manage the endpoint:
| Route | Effect |
|---|---|
GET /webhooks | Lists the project's webhooks, enabled or not. |
PATCH /webhooks/{webhookId} | Changes url, events or enabled. Send at least one. |
DELETE /webhooks/{webhookId} | Deletes it. |
POST /webhooks/{webhookId}/rotate-secret | Issues a new secret. |
Verify the signature
Each delivery is a POST with a JSON body and three headers:
| Header | Value |
|---|---|
X-Easyi18n-Signature | t=<unix seconds>,v1=<signature> |
X-Easyi18n-Event-Id | The event ID, the same as id in the body. |
X-Easyi18n-Webhook-Id | The webhook that is being delivered to. |
The signature is the HMAC-SHA256, in lowercase hex, of the timestamp, a dot and the raw request body, with the webhook's secret as the key. Compute it over the bytes you received, before parsing the JSON:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, header, secret) {
const parts = header.split(',').map((p) => p.split('='));
const t = parts.find(([k]) => k === 't')[1];
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
return fresh && parts.some(
([k, v]) => k === 'v1' && v.length === expected.length &&
timingSafeEqual(Buffer.from(v), Buffer.from(expected)),
);
}
The header can carry two v1 values, so accept the request when any of them
matches. Rejecting a timestamp more than five minutes away from your clock
bounds how long a captured request can be replayed.
Rotate the secret
POST /webhooks/{webhookId}/rotate-secret returns a new secret, shown once,
and previousSecretExpiresAt. For 24 hours each delivery is signed with both
secrets, one v1 value each, so an endpoint that still holds the old secret
keeps verifying while you deploy the new one.
Deliveries and retries
Answer with a 2xx status within 10 seconds. Anything else is a failed
attempt: another status, a redirect (redirects are not followed), a timeout or
a connection that takes more than 5 seconds to open.
A failed delivery is retried after 30 seconds, 2 minutes, 10 minutes, 1 hour
and 6 hours. After the sixth failed attempt it stops and is marked failed.
When three deliveries in a row end that way, the webhook is disabled with
disabledReason: auto_failures. Fix the endpoint and send
{"enabled": true} to the PATCH route, which also clears the failure count.
Delivery is at least once. A retry, or a resend, repeats the same event ID, so make the handler idempotent: remember the pair of webhook ID and event ID and ignore one you already handled.
The delivery log shows the last 50 deliveries of a webhook, newest first:
curl "https://api.easyi18n.com/v1/projects/$PROJECT_ID/webhooks/3Kd8mQ2xLp/deliveries" \
-H "Authorization: Bearer $EASYI18N_TOKEN"
{
"deliveries": [
{
"deliveryId": "2026.10.11.1",
"eventId": "evt_2026.10.11.1",
"eventType": "strings.published",
"publishId": "2026.10.11.1",
"status": "failed",
"attempts": 6,
"nextAttemptAt": null,
"lastStatusCode": 502,
"lastError": null
}
]
}
status is pending, delivering, delivered or failed. To send one
again, failed ones included:
curl -X POST "https://api.easyi18n.com/v1/projects/$PROJECT_ID/webhooks/3Kd8mQ2xLp/deliveries/2026.10.11.1/resend" \
-H "Authorization: Bearer $EASYI18N_TOKEN"
A resend while an attempt is in flight is 409 delivery_in_flight.