Skip to main content

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​

EventFires when
strings.publishedA version of the project's keys is published or restored.
content.publishedA 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:

RouteEffect
GET /webhooksLists 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-secretIssues a new secret.

Verify the signature​

Each delivery is a POST with a JSON body and three headers:

HeaderValue
X-Easyi18n-Signaturet=<unix seconds>,v1=<signature>
X-Easyi18n-Event-IdThe event ID, the same as id in the body.
X-Easyi18n-Webhook-IdThe 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.