> ## Documentation Index
> Fetch the complete documentation index at: https://developers.phrase.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Build integration workflows in Orchestrator

> Connect a third-party system to Phrase with Phrase Orchestrator, using webhooks, Connector API actions, Phrase Language AI, and Phrase TMS. Uses the Knak integration as a worked example.

> [!NOTE]
>
> This guide shows how to build an **integration workflow** in Phrase Orchestrator: a workflow that receives an event from a third-party system, fetches content through the [Connectors API](https://developers.phrase.com/en/api/connectors/introduction.md), translates it with Phrase Language AI or Phrase TMS, and sends the translation back. The examples use the **Knak** integration, but the same building blocks apply to any connector that exposes download, upload, and (optionally) preview operations.

## When to use this pattern

Use an Orchestrator integration workflow when a third-party system can notify Phrase about new content (usually through a webhook) and you want translation to happen automatically, without anyone exporting or importing files by hand.

There are two common shapes:

| Pattern | Translation | Workflows | Typical use |
| - | - | - | - |
| **Machine translation (Language AI)** | Phrase Language AI, optionally with the AI Translation Agent and Content Groups | One workflow: event in, translation back in a few minutes | Fast, fully automated translation |
| **Human translation (TMS)** | A Phrase TMS project and job, translated and reviewed by people, with a live preview of the content in the editor | Two workflows: one creates the project and job, one sends the translation back when the job is completed | Content that needs review or a full TMS workflow |

The rest of this guide builds the machine translation pattern step by step, then shows how the TMS pattern differs.

## Prerequisites

| Requirement | Notes |
| - | - |
| Access to Phrase Orchestrator | You need permission to create, publish, and run workflows in your organization. |
| A connector connection for the third-party system | Created in the Phrase Ecosystem. You need its **connection UID** and your **organization UID**. |
| Phrase Language AI credentials (machine translation pattern) | Stored as workflow variables (this guide uses `plai_username` and `plai_password`). |
| A Phrase TMS project template (TMS pattern) | Configures the workflow steps and file import settings for projects the workflow creates. |

## Key concepts

| Term | Definition |
| - | - |
| Trigger | The block that starts a workflow. For integrations this is usually a **Webhook** trigger with its own URL. |
| Action | A step in the workflow. **Phrase Connector API** actions (for example **Download raw content synchronously**) and **Phrase TMS** actions call Phrase APIs and handle authentication for you; **Send HTTP Request v2** calls any URL; **Transform JSON with jq** reshapes data. |
| Source | The earlier step a field reads its value from. In expressions like `{{ $.id }}`, `$` refers to the selected source step's output. The source shows as a tag next to the field. |
| `{{ @raw }}` | The whole output of the source step, as is. |
| `{{ @file.path }}` | The source step's output treated as a file. Use it whenever a step needs to send a file. |
| `{{ @var.<name> }}` | A workflow variable, for example a stored credential. |
| Condition | A rule on an action that decides whether the action (and everything after it) runs. |

> [!NOTE]
>
> Use the dedicated **Phrase Connector API** actions wherever one exists for your connector. They authenticate with the Connectors API for you, so you don't need a token step. Search the action picker for the connector's name (for example `knak`): several connectors have an action with the same name, such as **Upload raw content synchronously**. Phrase Language AI has no actions yet, so its calls use **Send HTTP Request v2**.

## The example workflow

The machine translation workflow for Knak looks like this. Each block is explained in the steps below.

```mermaid theme={null}
flowchart TD
  A[Webhook trigger<br/>translation request created] --> B[Read request<br/>jq: id, target language, source language]
  B --> C[Download raw content synchronously<br/>Connector API action]
  C --> D[Log in to Language AI<br/>HTTP]
  D --> E[Start file translation<br/>HTTP]
  E --> F[Wait]
  F --> G[Download translated file<br/>HTTP]
  G --> H[Validate translation<br/>XML to JSON + jq]
  H --> I[Upload file to file storage synchronously<br/>Connector API action]
  I --> J[Upload raw content synchronously<br/>Connector API action]
```

## 1. Receive the event with a webhook trigger

1. In the workflow editor, open **Triggers**, search for `webhook`, and add the **Webhook** trigger.
2. Copy the trigger's webhook URL and register it in the third-party system. For Knak this is a webhook for the `translation_request.created` event.
3. Open **Edit webhook** and paste an **example payload**. Orchestrator uses it as a schema, so later steps can pick fields from the trigger. Without it, the trigger's fields aren't available in the field picker.

An example Knak payload, with only the fields the workflow uses:

```json theme={null}
{
  "event_type": "translation_request.created",
  "event_id": "example-001",
  "version": "1.0",
  "data": {
    "asset": { "id": "<asset-id>", "name": "Example email" },
    "translation_request": {
      "id": "<translation-request-id>",
      "language_code": "fr",
      "source_language_code": "en",
      "status": "requested"
    }
  }
}
```

> [!TIP]
>
> A workflow only receives webhook calls while it is **published**. To test before connecting the real system, publish the workflow and send the example payload (with a real request ID) to the webhook URL yourself, for example with `curl -X POST <webhook-url> -H 'Content-Type: application/json' --data @payload.json`. The run then shows up under **Executions**.

> [!NOTE]
>
> In Knak, a translation request only reaches Phrase (and fires `translation_request.created`) when Phrase is the translation source. In **Request Translation → Source Selection**, choose **Manual Translation** and check that Phrase appears on the confirmation step. **AI Translation** is translated by Knak itself and sends no event.

## 2. Extract the values you need

Add a **Transform JSON with jq** action right after the trigger, so later steps can use short names instead of long payload paths.

* **Input type:** `JSON`
* **Input:** `{{ @raw }}`, with the **Webhook** trigger as the source
* **jq:**

  ```
  {id: .data.translation_request.id, lang: .data.translation_request.language_code, src: .data.translation_request.source_language_code}
  ```

Later steps can now use `{{ $.id }}`, `{{ $.lang }}` and `{{ $.src }}` with this step as their source.

### Limit the workflow with a condition

A third-party webhook is often account-wide: it fires for every asset, not just the ones you're testing with. Add a condition to this jq step so the workflow only continues for the content you expect. For example, on the **Conditions** tab:

| Field | Value |
| - | - |
| variable | `{{ $.data.asset.id }}`, with the **Webhook** trigger as the source |
| compare | `StringEquals` |
| value | `<asset-id>` |

> [!WARNING]
>
> Two easy mistakes here:
>
> * Use **`StringEquals`** for IDs that contain letters. `IntegerEquals` never matches them.
> * The **variable** field must contain only the expression. If the field also contains the literal text `Webhook` (for example `Webhook{{ $.data.asset.id }}`), the value resolves to `Webhook<asset-id>` and the condition always fails. The source should appear as a tag next to the field, not as text inside it.

## 3. Download the source file

Add the connector's **Download raw content synchronously** action (search for `knak`):

| Field | Value | Source |
| - | - | - |
| credentials → connectionUid | `<connection-uid>` | |
| credentials → organizationUid | `<organization-uid>` | |
| path → pathType | `TRANSLATION_REQUEST` | |
| path → id | `{{ $.id }}` | jq step |
| sourceLocale | `{{ $.src }}` | jq step |
| X-ResponseType | `OBJECT` | |

`X-ResponseType: OBJECT` makes the action return the file itself (for Knak, an XLIFF 2.0 file) instead of a storage ID, so later steps can use it directly as `{{ @file.path }}`.

> [!WARNING]
>
> The `organizationUid` must be the organization the connection belongs to. A mismatch returns `403` with a "connection not accessible" error.

## 4. Translate with Phrase Language AI

Translating a file with Phrase Language AI takes three calls: log in, start the file translation, and download the result. Language AI has no Orchestrator actions yet, so use **Send HTTP Request v2** for all three.

### Log in

* **Method:** `POST`
* **URL:** `https://eu.phrase.com/smt/api/v1/auth/login`
* **Body:** raw JSON:

  ```json theme={null}
  {"userName": "{{ @var.plai_username }}", "password": "{{ @var.plai_password }}", "organization": {"uid": "<organization-uid>"}}
  ```

The response contains a `token`. Use it as the bearer token (`{{ $.token }}`) for the next two calls.

> [!WARNING]
>
> Always include `organization.uid`. If the user belongs to more than one organization and the body doesn't say which one, the login returns `401`.

### Start the file translation

* **Method:** `POST`
* **URL:** `https://eu.phrase.com/smt/api/v1/fileTranslations`
* **Authentication:** Bearer token `{{ $.token }}` (source: the login step)
* **Body:** form data with two parts:

  | Part name | Content | Notes |
  | - | - | - |
  | `metadata` | JSON (see below) | Set the part's content type to `application/json`, otherwise the request fails with `415`. |
  | `file` | `{{ @file.path }}` from the download step | Give the part a filename, for example `source.xlf`. |

  The `metadata` part, with the jq step as its source:

  ```json theme={null}
  {"sourceLang":{"code":"{{ $.src }}"},"targetLangs":[{"code":"{{ $.lang }}"}],"actionTypes":["MT_GENERIC_PRETRANSLATE"],"mtSettings":{"profile":{"uid":"<mt-profile-uid>"}},"contentGroup":{"uid":"<content-group-uid>"}}
  ```

To translate with the **AI Translation Agent**, use an MT profile of that type in `mtSettings.profile.uid`. **Content Groups** (`contentGroup.uid`) only apply to AI Translation Agent profiles; use them for rules such as formality or how to treat links and merge tags.

The response contains the translation's `uid`.

> [!TIP]
>
> When you pick a source for a field, the editor may insert `{{ @raw }}` into the field. In a JSON body that leaves invalid JSON such as `...}"}{{ @raw }}`. After selecting the source, check that the field contains only the value you typed.

### Wait, then download the result

File translation is asynchronous. The simplest approach is a **Sleep** action (for example 90 seconds for a short email) before downloading:

* **Method:** `GET`
* **URL:** `https://eu.phrase.com/smt/api/v1/fileTranslations/{uid}/MT_GENERIC_PRETRANSLATE/{lang}`
* **Path parameters:** `uid` = `{{ $.uid }}` (source: the translation step), `lang` = `{{ $.lang }}` (source: the jq step)
* **Header:** `Accept: application/octet-stream`

> [!WARNING]
>
> Placeholders like `{uid}` in the URL are only replaced when you add a matching entry under **path parameters**. If one is missing, the request is sent with the literal `{uid}` in the URL.

## 5. Validate the translation

Machine translation can occasionally drop or change markup, for example the closing tag of a link. Before sending a translation back, check that every unit kept the same HTML tags and merge tags as its source, and fail the run if not.

Add a **Convert XML to JSON Format** action with the downloaded translation (`{{ @file.path }}`) as input, and this jq expression:

```
def items: if type == "array" then .[] else . end;
def tags: [scan("</?[a-zA-Z][^>]*>") | gsub("\\s+title=\"[^\"]*\""; "")];
def merges: [scan("\\{\\{[^}]+\\}\\}")];
[ .xliff.file.unit | items | .segment | items
  | ((.source // "") | tostring) as $s
  | ((.target // "") | tostring) as $t
  | select($t == "" or ($s | tags) != ($t | tags) or ($s | merges) != ($t | merges))
  | ."@id" ]
| if length == 0 then {ok: true}
  else error("Translation validation failed - HTML tags or merge tags changed in units: " + join(", ")) end
```

It compares the tags of each source and target segment (ignoring translated `title` attributes) and the `{{merge tags}}`, and fails with the IDs of any units that changed or are empty. A failed run stops before anything is uploaded, so broken content never reaches the third-party system.

> [!TIP]
>
> If a check fails repeatedly for the same kind of markup, add a rule to the Content Group rather than relaxing the check. For example, a rule such as "Always keep the visible text of every link and its closing `</a>` tag; translate the link text, never delete it" fixed missing link text for some target languages in the Knak workflow.

## 6. Send the translation back

Uploading a translation through the Connectors API is a two-step process: store the file, then tell the connector where to put it.

### Store the translated file

Add **Upload file to file storage synchronously** (Phrase Connector API):

* **file:** `{{ @file.path }}` from the step that downloaded the translated file

The output contains the stored file's `uid`.

### Upload

Add the connector's **Upload raw content synchronously** action (search for `knak`):

| Field | Value | Source |
| - | - | - |
| credentials → connectionUid | `<connection-uid>` | |
| credentials → organizationUid | `<organization-uid>` | |
| path → pathType | `TRANSLATION_REQUEST` | |
| path → id | `{{ $.id }}` | jq step |
| storageId | `{{ $.uid }}` | the file storage step |
| targetLocale | `{{ $.lang }}` | jq step |

Each field has its own source, so you don't need an extra step to combine values. The translation now appears in the third-party system.

## Test and publish

1. **Publish** the workflow. Webhook-triggered workflows only receive calls while published.
2. Create a real request in the third-party system, or send an example payload to the webhook URL yourself.
3. Open **Executions**, select the run, and click each block to see its **Input**, **Output**, **Headers** and **Response Status**.
4. Fix the first failing block, publish again, and resend. Each publish creates a new revision; you can export any revision under **Revisions → Export revision as file** as a backup.

### Changing an existing workflow

* **To add a block between two others**, hover over the arrow between them and click **+**.
* **To remove a block in the middle**, delete it, then reconnect the chain: deleting a block attaches the blocks below it directly to the trigger. Drag from the small handle at the bottom of the block above the gap onto the block below it. To remove a leftover connection, click it and then click the circle in its middle.
* **To replace a block**, add the new block first, point the next block's fields to it, and only then delete the old one.

> [!TIP]
>
> If two workflows listen to the same third-party webhook (for example a machine translation and a TMS version), both run for every event. Give each workflow its own condition, such as a different test asset, or point the third-party webhook at only one of them.

## The TMS pattern

When translations need people (translators, reviewers, or a full TMS workflow), replace the Language AI steps with a Phrase TMS project and job. Because human translation takes longer than a single workflow run, this pattern uses **two workflows**. The native Phrase TMS actions authenticate to TMS for you, so no login step is needed.

**Workflow 1: request to TMS job, with live preview**

```mermaid theme={null}
flowchart TD
  A[Webhook trigger] --> B[Read request<br/>jq + condition]
  B --> C[Download raw content synchronously<br/>Connector API action]
  C --> D[Create project<br/>Phrase TMS]
  D --> E[Generate preview package synchronously<br/>Connector API action]
  E --> F[Upload Job Preview Package<br/>Phrase TMS]
  F --> G[Create job<br/>Phrase TMS]
```

1. Webhook trigger, jq step, and condition, as in [steps 1–2](#1-receive-the-event-with-a-webhook-trigger).
2. **Download raw content synchronously**, as in [step 3](#3-download-the-source-file).
3. **Create project** (Phrase TMS action): `templateUid` set to your project template, `name` for example `Knak {{ $.id }}`, `sourceLang` `{{ $.src }}`, and one `targetLangs` item `{{ $.lang }}` (all from the jq step).
4. **Generate preview package synchronously** (Connector API action): the same `credentials` and `path` as the download, and `X-ResponseType` `OBJECT`, so the action returns the preview ZIP itself.
5. **Upload Job Preview Package** (Phrase TMS action): `projectUid` `{{ $.uid }}` from the project step, and `file` `{{ @file.path }}` from the preview step. The output contains a `fileUid`.
6. **Create job** (Phrase TMS action):

   | Field | Value | Source |
   | - | - | - |
   | projectUid | `{{ $.uid }}` | project step |
   | body | `{{ @file.path }}` (the file to translate) | download step |
   | Content-Disposition | `filename="knak-{{ $.id }}.xlf"` | jq step |
   | Memsource → targetLangs | `{{ $.lang }}` | jq step |
   | Memsource → useProjectFileImportSettings | `true` | |
   | Memsource → jobPreviewPackageFileUidRef → uid | `{{ $.fileUid }}` | preview package upload |

The job then shows a live preview of the content in the TMS editor. Use `fileUid` from the preview package upload, not its top-level `uid`.

> [!TIP]
>
> Put the third-party request ID into the job's filename (here `knak-<id>.xlf`). The second workflow needs it to know where to send the translation, and the job filename is the simplest place to carry it.

> [!NOTE]
>
> Configure the file import settings once in the **project template** rather than per job. For Knak's XLIFF 2.0 files, enabling the HTML subfilter and converting `{{...}}` merge tags to Phrase tags (regular expression `\{\{[^}]+\}\}`) makes HTML and merge tags appear as protected tags in the editor. Changing the template, rather than the organization's default import settings, keeps the change limited to these projects.

**Workflow 2: completed job to translation**

A second workflow starts when the TMS job is completed, exports and downloads the target file, and sends it back with the same two upload actions as in [step 6](#6-send-the-translation-back).

```mermaid theme={null}
flowchart TD
  A[Phrase TMS trigger<br/>job.status.changed] --> B[Job info<br/>jq: job, project, request ID, language]
  B -->|condition: last workflow step| C[Prepare target file<br/>Phrase TMS]
  C --> D[Wait]
  D --> E[Download target file<br/>Phrase TMS]
  E --> F[Upload file to file storage synchronously<br/>Connector API action]
  F --> G[Upload raw content synchronously<br/>Connector API action]
```

1. **Trigger:** on the **Triggers** tab, search for `job` and add the Phrase TMS **`job.status.changed`** trigger. Add filters so it only runs for completed jobs in the projects this integration creates, for example a project name that starts with `Knak`. Without filters, the workflow runs for every job status change in the organization.
2. **Job info** (**Transform JSON with jq**, input `{{ @raw }}` from the trigger): extract the job UID, the project UID, the target language, whether the job is at the **last workflow step**, and the third-party request ID from the job's filename (`knak-<id>.xlf`). The exact field names come from the trigger's payload; check them in the trigger's output under **Executions**.
3. **Prepare target file** (Phrase TMS action): `projectUid` and `jobUid` from **Job info**. Add a condition so it only runs when the job is at the last workflow step (for example `{{ $.lastStep }}` equals `true`). A job with several workflow steps (translation, then review) is completed once per step, and only the last one should send the translation back.
4. **Wait** with a **Sleep** action. Preparing the target file is asynchronous; a short wait (for example 20 seconds for an email) is enough for small files. Increase it for larger content.
5. **Download target file** (Phrase TMS action): `projectUid` and `jobUid` from **Job info**, and `asyncRequestId` `{{ $.asyncRequest.id }}` from the prepare step.
6. **Upload file to file storage synchronously**: `file` is `{{ @file.path }}` from the download step.
7. **Upload raw content synchronously**, as in [step 6](#upload), with `path → id` and `targetLocale` from **Job info** (`{{ $.id }}`, `{{ $.lang }}`) and `storageId` from the file storage step (`{{ $.uid }}`).

> [!TIP]
>
> Check that the target language in TMS matches the language code the third-party system expects. Simple codes such as `de` or `fr` usually match; regional variants (for example `zh_cn` in TMS and `zh-CN` elsewhere) may need mapping in the **Job info** step.

## Calling the Connectors API without an action

If your connector has no Connector API actions yet, call its endpoints with **Send HTTP Request v2** and a Connectors API token:

1. **Get a token** with an IDM token exchange: `POST https://eu.phrase.com/idm/oauth/token`, URL-encoded body with `grant_type` = `urn:ietf:params:oauth:grant-type:token-exchange` and `subject_token` = a Phrase platform token stored as a workflow variable. The response contains an `access_token`.
2. **Call the connector**, for example `POST https://eu.phrase.com/connectors/<connector>/v1/sync/download-raw-file`, with **Bearer token** `{{ $.access_token }}` and the same JSON fields the actions use (`credentials`, `path`, and so on) as a raw JSON body.
3. To combine values from several steps into one request body, use **Transform JSON with jq v2**, which takes two inputs as `.[0]` and `.[1]`.

## Troubleshooting

| Symptom | Likely cause | Fix |
| - | - | - |
| Trigger fields can't be selected in later steps | The Webhook trigger has no example payload | Paste an example payload under **Edit webhook**. |
| Nothing happens when the third-party system sends events | Workflow not published, the third-party webhook points to another workflow, or (Knak) the request didn't use Phrase as its translation source | Publish the workflow, check the webhook URL, and in Knak choose **Manual Translation** so the request goes to Phrase. |
| Condition always fails, even for the right content | Wrong comparison type, or literal text in the variable field | Use `StringEquals` for IDs with letters, and make sure the variable field contains only `{{ ... }}`. See [Limit the workflow with a condition](#limit-the-workflow-with-a-condition). |
| `403` "connection not accessible" | `organizationUid` doesn't match the connection's organization | Use the organization the connection was created in. |
| A later step gets a storage ID instead of a file | `X-ResponseType` isn't `OBJECT` on the download or preview action | Set `X-ResponseType` to `OBJECT`. |
| `401` from the Language AI login | The user belongs to several organizations and none was specified | Add `"organization": {"uid": "..."}` to the login body. |
| `415` from `fileTranslations` | The `metadata` form part has no content type | Set the `metadata` part's content type to `application/json`. |
| A file upload in an HTTP request is rejected as missing or with the wrong type | The file part has no filename or content type | Set a filename (with the right extension) and content type on the form part. |
| URL still contains `{uid}` or `{lang}` | No matching path parameter | Add an entry for each placeholder under **path parameters**. |
| `The field "targetLangs" can't be empty` from Create job | The job settings were put into `body` instead of the **Memsource** fields | Put `targetLangs` and the other job settings in the **Memsource** fields; `body` is the file to translate. |
| `TargetLangs ... must match project settings` from Create job | The job's target language isn't one of the project's target languages | Use the same `{{ $.lang }}` for the project's and the job's target languages. |
| Invalid JSON body, ending in `{{ @raw }}` | The editor inserted `{{ @raw }}` when the source was selected | Remove it, keeping only your JSON. |
| "Expression or source is not valid" on a field with `{{ $... }}` | The field has no source step linked, so `$` can't be resolved | Put the cursor where the value goes, type `{{`, and pick the source step and property. The source then shows as a tag next to the field. |
| The field picker only lists **Variables**, not earlier steps | The block was just added and not saved yet | Fill in the other fields, save the block, then open the picker again. |
| After deleting a block, the blocks below it hang off the trigger | Deleting a block doesn't reconnect the chain | Drag from the bottom handle of the block above the gap onto the next block, and delete the leftover connection from the trigger. See [Changing an existing workflow](#changing-an-existing-workflow). |
| Validation fails for some languages only | The engine changed markup for those languages | Add a Content Group rule for that markup, then rerun. |

## Related

* [Connectors API](https://developers.phrase.com/en/api/connectors/introduction.md)
* [Manage TMS connectors](https://developers.phrase.com/en/guides/managing-connectors.md)
* [Manage Automated Project Creation (APC)](https://developers.phrase.com/en/guides/managing-automated-project-creation.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.