guides
Webhooks
Have wxrks call your server when projects, tasks, work units, org units or users change, instead of polling for changes.
Create a webhook
Create it in the app under Settings โ Account settings โ Webhooks, or with Create Webhook:
{
"url": "https://example.com/wxrks/webhooks",
"type": "WORK_UNIT_STATUS_CHANGE",
"secret": "<a long random value>"
}
- A webhook receives one event type. Create one webhook per event type you need; several webhooks can share a URL.
- The URL must use
https://. - The secret is optional, and recommended: wxrks signs every request with it, so your server can check that a request comes from wxrks and was not altered (see Verifying the signature). It is stored encrypted and never returned.
Right after you create it, wxrks sends a validation request to the URL. The webhook becomes active when your server answers it with any 2xx status within 3 seconds; wxrks tries up to three times. Otherwise the webhook stays inactive: fix the endpoint and create the webhook again. The validation request has this body:
{
"event_type": "WEBHOOK_VALIDATION",
"message": "This is a test event to validate your webhook endpoint",
"timestamp": 1791674000000,
"webhook_uuid": "<webhook uuid>",
"attempt": 1,
"events": ["WORK_UNIT_STATUS_CHANGE"],
"expected_response": "Please respond with an HTTP 2xx status to activate this webhook"
}
Turn on events for your org units
Project, task, work unit and org unit events are sent only for org units where Enable Webhook Events is on, in the org unit's Notifications tab. It is off by default. Organization and user events do not depend on it.
Event types
type | Sent when |
|---|---|
NEW_PROJECT | A project is created. |
NEW_EMPTY_PROJECT | An empty project is created. |
PROJECT_STATUS_CHANGE | A project's status changes. |
PROJECT_UPDATE | A project is updated. |
PROJECT_INGESTION_COMPLETED | A project's files are ingested. |
PROJECT_TRANSLATION_FINISHED | A project's translation is finished. |
TASK_ASSIGNED | A task is assigned. |
TASK_STATUS_CHANGE | A task's status changes. |
TASK_UPDATE | A task is updated. |
WORK_UNIT_STATUS_CHANGE | A work unit's status changes. |
WORK_UNIT_TRANSLATION_FILE_READY | A work unit's translated file is ready. |
WORK_UNIT_IMPORT_FINISHED | An import of a bilingual file into a work unit ends, with IMPORTED or ERROR. |
TRANSLATION_KIT_JOB_FINISHED | A translation kit job ends, with COMPLETED or FAILED: an export kit, translated files, source files, or a bulk import. |
NEW_ORG_UNIT, ORG_UNIT_UPDATE | An org unit is created or updated. |
NEW_ORG, ORG_UPDATE | An organization is created or updated. |
NEW_USER, USER_UPDATE | A user is created or updated. |
What a request looks like
Every request is a POST with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type | application/json; charset=UTF-8 |
User-Agent | wxrks |
X-BWX-Event-ID | The event's id. Retries keep it. |
X-BWX-Webhook-ID | The id of the webhook, which tells you the event type. |
X-BWX-Timestamp | When the request was signed, in Unix seconds. Only when the webhook has a secret. |
X-BWX-Signature | v1= followed by the lowercase hex HMAC-SHA256 of {X-BWX-Timestamp}.{raw body}, keyed with the webhook's secret. Only when the webhook has a secret. |
X-BWX-Webhook-Secret | The webhook's secret in plain text, when it has one. Kept for receivers written before signatures; check the signature instead. |
Verifying the signature
Give the webhook a secret when you create it, then accept a request only when its signature matches:
- Read the raw body exactly as received, before parsing the JSON.
- Reject the request when
X-BWX-TimestamporX-BWX-Signatureis missing, or when the timestamp is more than 5 minutes away from your clock. - Compute the HMAC-SHA256 of the timestamp, a dot (
.), and the raw body, with your secret as the key (UTF-8). Write it in lowercase hexadecimal and putv1=in front. - Compare it with
X-BWX-Signaturein constant time.
const crypto = require('crypto')
function isValidWxrksSignature (rawBody, timestamp, signature, secret) {
if (!timestamp || !signature || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return false
}
const expected = Buffer.from('v1=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex'))
const received = Buffer.from(signature)
return expected.length === received.length && crypto.timingSafeEqual(expected, received)
}
To test your implementation: with the secret s3cret, the timestamp 1700000000 and the body {"id":1}, the signature is v1=ee0658aa4e37018df69c24227df01e0f680eb3b87c7f1f9bd936e283cfe01d9b.
Every attempt, retries included, is signed again with a new timestamp. A secret cannot be changed on an existing webhook: to rotate it, create a new webhook with the new secret, accept both secrets while you switch, then delete the old webhook.
The body describes the project, task, work unit, org unit or user the event is about; its fields depend on the event type. wxrks adds a few fields for some events:
| Events | Added fields |
|---|---|
| events about a continuous project | ci_tag: the project's CI tag |
PROJECT_STATUS_CHANGE, PROJECT_INGESTION_COMPLETED | workflows, target_locales |
WORK_UNIT_STATUS_CHANGE, WORK_UNIT_TRANSLATION_FILE_READY | workflow, is_last_workflow |
WORK_UNIT_STATUS_CHANGE | previous_status and new_status: status codes, such as READY_TO_TRANSLATE and DELIVERED |
WORK_UNIT_TRANSLATION_FILE_READY | details.download: the work unit download call, which returns a link to the translated file |
WORK_UNIT_IMPORT_FINISHED | workflow, is_last_workflow; details.import: the import call |
TRANSLATION_KIT_JOB_FINISHED | details.job: the job call, which returns the download link once the job has completed |
TASK_ASSIGNED, TASK_STATUS_CHANGE | name and reference of the task's project |
TASK_STATUS_CHANGE, PROJECT_STATUS_CHANGE | previous_status and new_status: status codes, such as PENDING_ACCEPTANCE and DELIVERED |
Import and export events
These two events end a wait on an import or a job, so you need not poll.
WORK_UNIT_IMPORT_FINISHED carries the import: id (the work unit), import_id, project_uuid, org_unit_uuid, import_type (XLIFF or XLSX), status (IMPORTED or ERROR), error (the reason code, or null), the segment counts segments_in_file, segments_without_id, segments_updated, segments_confirmed and segments_locked, and creation_date and ending_date in milliseconds. The fields mean the same as in Get Work Unit Import.
TRANSLATION_KIT_JOB_FINISHED carries the job: id (the job), project_uuid, project_name, org_unit_uuid, translation_kit_type (LOC_KIT for an export kit, TRANSLATED_FILES, SOURCE_FILES, IMPORT_XLIFF or IMPORT_XLSX for a bulk import), status (COMPLETED or FAILED), creation_date and ending_date in milliseconds, and work_unit_uuids when the job was limited to some work units. It carries no download link: call details.job for one.
Answering and retries
Answer with a 2xx status within 3 seconds, then do any slow work: wxrks does not wait longer.
- When your server answers
5xx,408or429, or does not answer, wxrks retries after 10 seconds, 1 minute and 2 minutes, then gives up. Other4xxanswers are not retried. - Retries keep the same
X-BWX-Event-ID: store the ids you have handled and ignore one you have already seen. - A pending retry is dropped when the webhook is deactivated or deleted.
The Events tab next to Webhooks in the app lists recent deliveries with the status your server answered, and can retry a delivery. A delivery retried from there carries an X-BWX-Retry-Of header with the id of the original delivery, and its body gains retryOf and retryRequestedAt.