Configuring webhook

Setup webhook to automatically import Phrase translations back into Uniform.

You would need Phrase integration to be installed and configured.

You can find a full example of implementation in this examples repo

Lets create a webhook that can handle composition or entry translations coming back from Phrase TMS.

  1. First we need Phrase and Uniform credentials:

    # Authentication to Phrase # https://cloud.memsource.com or https://us.cloud.memsource.com PHRASE_API_HOST= PHRASE_PROJECT_UID= PHRASE_USER_NAME= PHRASE_USER_PASSWORD= PHRASE_WEBHOOK_SECRET_TOKEN= # Authentication to Uniform UNIFORM_API_KEY=

Uniform API Keys

Here you can find how to create Uniform API Keys. Phrase integration requires at least Create and Update permissions for Compositions / Entries / Assets

  1. We would also need Uniform dependencies

    npm i @uniformdev/canvas @uniformdev/tms-sdk @uniformdev/tms-phrase
  2. Lets validate Phrase webhook

    const token = req.headers['x-memsource-token']; if (token !== process.env.PHRASE_WEBHOOK_SECRET_TOKEN) { // eslint-disable-next-line no-console console.log('Secret token is invalid'); res.status(403).json({ message: 'Secret token is invalid' }); return; } const payload = req.body as PhraseWebhookPayload; if (payload.event !== 'JOB_STATUS_CHANGED') { // eslint-disable-next-line no-console console.log(`skip: event !== 'JOB_STATUS_CHANGED'`); res.status(200).json({ updated: false }); return; } const job = payload.jobParts.at(0); if (!job || !shouldProcessJob(job)) { res.status(200).json({ updated: false }); return; } const shouldProcessJob = (job: PhraseWebhookPayload['jobParts'][number]): boolean => { if (job.status !== 'COMPLETED_BY_LINGUIST') { // eslint-disable-next-line no-console console.log('skip: job status !== COMPLETED_BY_LINGUIST'); return false; } const phraseProjectUid = job.project.uid; if (!process.env.PHRASE_PROJECT_UID || phraseProjectUid !== process.env.PHRASE_PROJECT_UID) { if (!process.env.PHRASE_PROJECT_UID) { // eslint-disable-next-line no-console console.log(`skip: missing 'PHRASE_PROJECT_UID' env`); } else { // eslint-disable-next-line no-console console.log( `skip: Phrase project id mismatch (expected: ${process.env.PHRASE_PROJECT_UID}, current: ${phraseProjectUid})` ); } return false; } return true; };
  3. Lets retrieve target file from the job

    const phraseClient = new PhraseTmsClient({ apiHost: process.env.PHRASE_API_HOST || assert('missing PHRASE_API_HOST'), userName: process.env.PHRASE_USER_NAME || assert('missing PHRASE_USER_NAME'), password: process.env.PHRASE_USER_PASSWORD || assert('missing PHRASE_USER_PASSWORD'), }); const translationPayload = job.uid ? await phraseClient.downloadTargetFile<TranslationPayload>({ projectUid: job.project.uid, jobUid: job.uid, }) : null; if (!translationPayload) { // eslint-disable-next-line no-console console.log('skip: no translation payload'); res.status(200).json({ updated: false }); return; } function assert(msg: string): never { throw new Error(msg); }
  4. Now lets merge translation into Uniform entities

    const uniformProjectId = translationPayload.metadata.uniformProjectId; const uniformReleaseId = translationPayload.metadata.uniformReleaseId; const uniformEntityType = translationPayload.metadata.entityType; const uniformEntityId = translationPayload.metadata.entity.id; const canvasClient = new CanvasClient({ projectId: uniformProjectId, apiKey: process.env.UNIFORM_API_KEY || assert('missing UNIFORM_API_KEY'), bypassCache: true, }); const contentClient = new ContentClient({ projectId: uniformProjectId, apiKey: process.env.UNIFORM_API_KEY || assert('missing UNIFORM_API_KEY'), bypassCache: true, }); const { translationMerged } = await mergeTranslationToUniform({ canvasClient, contentClient, translationPayload, updateComposition: async ({ canvasClient, composition }) => { // eslint-disable-next-line no-console console.log('update composition: start'); await canvasClient.updateComposition(composition); // eslint-disable-next-line no-console console.log('update composition: done'); return true; }, updateEntry: async ({ contentClient, entry }) => { // eslint-disable-next-line no-console console.log('update entry: start'); await contentClient.upsertEntry(entry); // eslint-disable-next-line no-console console.log('update entry: done'); return true; }, onNotFound: ({ translationPayload }) => { const entityType = translationPayload.metadata.entityType; const entityId = translationPayload.metadata.entity.id; // eslint-disable-next-line no-console console.log(`skip: can not find ${entityType} (${entityId})`); }, onNotTranslatedResult: ({ updated, errorKind, errorText }) => { if (errorKind !== undefined) { // eslint-disable-next-line no-console console.warn(errorText || 'Unknown error'); } else if (!updated) { // eslint-disable-next-line no-console console.log('Translation has no updates'); } }, });
  5. If everything goes fine - lets mark Phrase job as delivered:

    if (translationMerged) { // eslint-disable-next-line no-console console.log('update job status: start'); await phraseClient.setJobStatus({ projectUid: job.project.uid, jobUid: job.uid, jobStatus: 'DELIVERED', }); // eslint-disable-next-line no-console console.log('update job status: done'); }
  1. Open Phrase TMS dashdoard
  2. Go to Settings and click Webhooks
    phrase-webhooks-settings-location
    Phrase webhook settings location.
  3. Click Add webhook
  4. Define webjook's name
  5. Provide publicly avaiable url where Phrase is going to push job status notifications
  6. Define webhook's secret token to validate income requests
  7. Select Job status changed event
    phrase-webhooks-create
    Create Phrase webhook.