Workflow Step Type
This module allows your app to register a custom workflow step type in a Crowdin Enterprise organization. Once the app is installed, the new step type appears in the workflow designer alongside the built-in steps (Translate, Proofread, TM Pre-translate, Custom Code, etc.). Project managers can add it to any advanced workflow, and at runtime every string that reaches the step is handed over to your app, which implements the step’s condition of done: it decides — asynchronously, via the API — when a string is done on the step and which output branch it leaves through.
Typical use cases: an external review or compliance gate, a scheduling/delay step, or any custom condition of done and routing logic that should run as part of the localization workflow.
How It Works
Section titled “How It Works”Unlike UI modules, a workflow step is a long-lived participant in a project. The complete lifecycle looks like this:
-
Install — an organization admin installs the app. Crowdin validates the manifest and creates a dedicated bot user (the Agent) for the app. The Apps SDK stores the agent credentials and handles the token exchange automatically.
-
Design time — a project manager invites the Agent user as a manager to the project and adds your step to a workflow in the designer. Crowdin calls your app’s settings endpoint (the
onStepSettingsSavefunction) with the step configuration. -
Runtime — when a string reaches your step, Crowdin sets its step status to
NEED_PROCESSand sends thestring.status_on_step.recalculation_triggeredwebhook to your app. -
Condition of done — your app evaluates its condition of done for the string (this can take arbitrarily long) and reports the result via the API by setting the string’s output port. Crowdin then routes the string into whatever step is connected to that output.
The platform handles routing, status bookkeeping, progress counters, and the editor; your app implements only the condition of done and routing logic. This is similar to the built-in Custom Code step, except the logic runs in your app instead of a script.
Authentication
Section titled “Authentication”A workflow step needs an identity that can act inside projects on its own — read strings on the step, move them forward, and appear in the project history — independently of any human session. The crowdin_agent authentication type provides exactly that:
- When the app is installed, Crowdin creates a dedicated bot user (the Agent) for it.
- All of the app’s API calls authenticate as that user. Project access is governed by the Agent’s own project memberships — the Agent must be a project manager wherever the step is used. A project manager has to invite the Agent user as a manager to the project before your step can be used in its workflow.
- Uninstalling the app removes the Agent user.
The Apps SDK takes care of the crowdin_agent token exchange for you: the client you receive in webhooks, module functions, and hooks is already authenticated as the Agent.
Agent Configuration
Section titled “Agent Configuration”| Parameter | Type | Required | Description |
|---|---|---|---|
username | string | Yes | Login for the Agent user. If it’s already taken, Crowdin appends a random suffix. |
name | string | No | Display name. The Agent appears as "{name} Agent". Defaults to the app name. |
avatarUrl | string | No | Relative URL to the Agent’s avatar, resolved against the app’s baseUrl. Defaults to the app logo. |
const configuration = { // ... authenticationType: 'crowdin_agent', agent: { name: 'Reviewer', username: 'sample-app-agent' }};Sample
Section titled “Sample”The example below registers a “Review Gate” step with two output branches. The webhook callback receives strings that arrive on the step and routes each one through an output port — replace this with your own condition of done (call an external system, check conditions, delay, etc.).
import crowdinModule from '@crowdin/app-project-module';
const app = crowdinModule.express();
const configuration = { baseUrl: 'https://123.ngrok.io', clientId: 'clientId', clientSecret: 'clientSecret', name: 'Sample App', identifier: 'sample-app', description: 'Sample App description', dbFolder: import.meta.dirname, imagePath: import.meta.dirname + '/logo.png', authenticationType: 'crowdin_agent', agent: { name: 'Reviewer', username: 'sample-app-agent' }, workflowStepType: [ { name: 'Review Gate', description: 'Sends translated strings to an external review system', boundaries: { input: { title: 'Translated', ports: ['translated'] }, outputs: [ { title: 'Approved', port: 'approved' }, { title: 'Rejected', port: 'untranslated' } ], }, settingsUiModule: { formSchema: { "title": "Workflow Step Settings", "type": "object", "required": ["reviewProfile"], "properties": { "reviewProfile": { "type": "string", "title": "Review profile", "default": "default" } } } }, onStepSettingsSave: async ({ organizationId, projectId, stepId, workflowId, settings, context, client }) => { await crowdinApp.saveMetadata({ id: `form-data-${organizationId}-${projectId}-${stepId}`, metadata: settings, crowdinId: String(organizationId), }); }, onDeleteStep: async ({ organizationId, projectId, stepId, workflowId, context, client }) => { await crowdinApp.deleteMetadata(`form-data-${organizationId}-${projectId}-${stepId}`); } } ], webhooks: [ { events: ['string.status_on_step.recalculation_triggered'], async callback({ client, events }) { for (const { stringStatus } of events) { if (stringStatus.status !== 'NEED_PROCESS') { continue; } // Evaluate your condition of done here and pick an output port await client.workflowsApi.updateWorkflowStepStringStatus( stringStatus.translation.project.id, stringStatus.workflowStep.id, stringStatus.affectedLanguage.id, [{ op: 'replace', path: `/${stringStatus.translation.id}/output`, value: 'approved' }] ); } } } ]};
const crowdinApp = crowdinModule.addCrowdinEndpoints(app, configuration);
app.listen(3000, () => console.log('Crowdin app started'));import crowdinModule from '@crowdin/app-project-module';import type { ClientConfig } from '@crowdin/app-project-module';
const app = crowdinModule.express();
const configuration: ClientConfig = { baseUrl: 'https://123.ngrok.io', clientId: 'clientId', clientSecret: 'clientSecret', name: 'Sample App', identifier: 'sample-app', description: 'Sample App description', dbFolder: import.meta.dirname, imagePath: import.meta.dirname + '/logo.png', authenticationType: crowdinModule.AuthenticationType.AGENT, agent: { name: 'Reviewer', username: 'sample-app-agent' }, workflowStepType: [ { name: 'Review Gate', description: 'Sends translated strings to an external review system', boundaries: { input: { title: 'Translated', ports: ['translated'] }, outputs: [ { title: 'Approved', port: 'approved' }, { title: 'Rejected', port: 'untranslated' } ], }, settingsUiModule: { formSchema: { "title": "Workflow Step Settings", "type": "object", "required": ["reviewProfile"], "properties": { "reviewProfile": { "type": "string", "title": "Review profile", "default": "default" } } } }, onStepSettingsSave: async ({ organizationId, projectId, stepId, workflowId, settings, context, client }) => { await crowdinApp.saveMetadata({ id: `form-data-${organizationId}-${projectId}-${stepId}`, metadata: settings, crowdinId: String(organizationId), }); }, onDeleteStep: async ({ organizationId, projectId, stepId, workflowId, context, client }) => { await crowdinApp.deleteMetadata(`form-data-${organizationId}-${projectId}-${stepId}`); } } ], webhooks: [ { events: ['string.status_on_step.recalculation_triggered'], async callback({ client, events }) { for (const { stringStatus } of events) { if (stringStatus.status !== 'NEED_PROCESS') { continue; } // Evaluate your condition of done here and pick an output port await client.workflowsApi.updateWorkflowStepStringStatus( stringStatus.translation.project.id, stringStatus.workflowStep.id, stringStatus.affectedLanguage.id, [{ op: 'replace', path: `/${stringStatus.translation.id}/output`, value: 'approved' }] ); } } } ]};
const crowdinApp = crowdinModule.addCrowdinEndpoints(app, configuration);
app.listen(3000, () => console.log('Crowdin app started'));Configuration
Section titled “Configuration”| Parameter | Type | Required | Description |
|---|---|---|---|
key | string | No | Module key. Defaults to {identifier}-{step_name_in_snake_case}. |
name | string | Yes | Workflow step name shown in the workflow designer. |
description | string | No | Workflow step description. |
imagePath/imageUrl | string | No | Step logo shown in the workflow designer (a local file path or an external URL). |
boundaries | object | Yes | Defines the input and output ports for the workflow step. See Boundaries. |
editorMode | string | No | Editor mode for viewing strings. See Editor Mode. |
settingsUiModule | object | No | Settings UI module configuration. See Settings UI Module. |
onStepSettingsSave | function | No | Called after saving a workflow that contains the step. See details below. |
onDeleteStep | function | No | Called after the step is deleted from a workflow. See details below. |
Boundaries
Section titled “Boundaries”Defines the step’s connectors in the workflow graph:
input— exactly one input group:{ title, ports }, whereportslists the string states the step accepts.outputs— one or two outputs (two is the maximum), each{ title, port }.
Port and output titles must be 3–30 characters long.
Ports describe the state of a string’s content, not the step it came from. An output of one step connects to an input of another when their ports match, or when either side is all. Available port values:
| Port | Meaning |
|---|---|
initial | Raw string coming straight from the Start step. Input only — using it as an output fails the manifest validation during installation. |
untranslated | String has no accepted translation. |
translated | String has an accepted translation. |
approved | String has been approved (proofread). |
skipped | String was bypassed (e.g. not sent to a vendor; applicable only for vendor blocks). |
true / false | Generic boolean branch pair (as used by Custom Code steps). |
all | Wildcard — connects to anything on either side. |
The conventional shape is one “success” output (translated, approved, or true) and one “failure/bypass” output (untranslated, skipped, or false).
Editor Mode
Section titled “Editor Mode”Defines the editor mode for the workflow step. To allow viewing strings in the editor, this attribute should be defined. Read more about the editor modes.
Available values:
comfortable- Comfortable mode.side-by-side- Side-by-side mode.multilingual- Multilingual mode.
Settings UI Module
Section titled “Settings UI Module”Object with the settings UI module configuration. This optional parameter allows you to define a UI for workflow step settings. The UI opens in an iframe when a project manager configures the step in the workflow designer, and the saved form data becomes the settings object your app receives in onStepSettingsSave.
You can use either a low-code approach with formSchema and formUiSchema, or provide your own custom UI with uiPath and fileName. Read more in the User Interface documentation.
onStepSettingsSave Function
Section titled “onStepSettingsSave Function”Called when a project manager saves a workflow that contains your step (after adding or editing it). This is your registry of live steps — persist the received identifiers and settings so you know which steps exist and how they are configured.
If you don’t define this function, the SDK automatically stores the settings in the metadata storage under the key form-data-{crowdinId}-{projectId}-{stepId}. Define your own handlers if your step can be used in workflow templates — the default storage key includes projectId, which is absent for templates. Note that the TypeScript definitions currently declare both onStepSettingsSave and onDeleteStep as required, so TypeScript apps have to provide them either way.
Parameters
Section titled “Parameters”organizationId- Crowdin organization ID.projectId- Crowdin project ID. Not provided when the step is configured in a workflow template rather than a project workflow.stepId- Workflow step ID.workflowId- Workflow ID.settings- Form data saved in the step settings UI.context- Context object.client- Crowdin API client (authenticated as the Agent).
onDeleteStep Function
Section titled “onDeleteStep Function”Called when your step is deleted from a workflow, allowing you to clean up any per-step state. If you don’t define this function, the SDK removes the automatically stored settings.
Also treat app uninstallation as the deletion of all steps — Crowdin deactivates them and removes the Agent user.
Parameters
Section titled “Parameters”organizationId- Crowdin organization ID.projectId- Crowdin project ID. Not provided when the step is deleted from a workflow template.stepId- Workflow step ID.workflowId- Workflow ID.context- Context object.client- Crowdin API client (authenticated as the Agent).
Webhooks
Section titled “Webhooks”When a string lands on your step (added to the project, moved there by an upstream step, or re-triggered), Crowdin sets its status on the step to NEED_PROCESS and sends the string.status_on_step.recalculation_triggered event. One event is generated per string per target language.
Implementation Example
Section titled “Implementation Example”webhooks: [ { events: ['string.status_on_step.recalculation_triggered'], callback({client, events, webhookContext}) { console.log('String status on step event:', events); } }]Learn more about webhooks configuration.
Delivery Guarantees
Section titled “Delivery Guarantees”Webhook delivery is best-effort, not a reliable queue. Design your app accordingly:
- Events are dispatched asynchronously — expect a delay of a minute or more after the trigger — and arrive batched (the
eventsarray can contain many events). - Delivery is retried only a limited number of times and only for transient errors. There is no durable retry queue.
- If delivery fails, the affected strings are marked
FAILEDon the step and are not re-sent automatically. A project manager can re-trigger them from the project’s workflow step context (“trigger failed strings”). - Respond with a
2xxstatus quickly and process the events asynchronously — don’t do the actual work inside the request. - Because events can be missed, periodically reconcile using the API (see Processing Strings): list strings with status
NEED_PROCESSon your steps and process any your app doesn’t know about.
The event is not sent when the recalculation was caused by your own status update (so your app doesn’t receive echoes of its own decisions), when the string was deleted, or when the string is already FAILED on the step.
Events Payload
Section titled “Events Payload”Payload Structure
Section titled “Payload Structure”Each event in the events array contains the following structure:
| Field | Type | Description |
|---|---|---|
event | string | Event name: string.status_on_step.recalculation_triggered |
stringStatus.status | string | Current step status. See Status Values |
stringStatus.output | string | Output from the workflow step when the status is DONE |
stringStatus.originEvent | string | Action that triggered the recalculation. See Origin Events |
stringStatus.organizationId | string | Crowdin organization ID |
stringStatus.translation | object | Source string details (id, key, text, file, project, etc.) |
stringStatus.sourceLanguage | object | Source language information |
stringStatus.affectedLanguage | object | Target language affected by this step |
stringStatus.workflowStep | object | Workflow step that triggered the event |
stringStatus.user | object | User who triggered the action |
Event Example
Section titled “Event Example”Below is an example of string.status_on_step.recalculation_triggered event payload:
{ "event": "string.status_on_step.recalculation_triggered", "stringStatus": { "status": "NEED_PROCESS", "output": "", "originEvent": "string.added", "organizationId": "200000001", "translation": { "id": 1000001,15 collapsed lines
"identifier": "abc123def456", "key": "unread_notifications", "text": "{count} unread notifications", "type": "text", "context": "homepage", "maxLength": "0", "isHidden": false, "isDuplicate": false, "masterStringId": null, "revision": 1, "hasPlurals": true, "plurals": { "one": "1 unread notification", "other": "{count} unread notifications" }, "labelIds": [], "url": "https://example.crowdin.com/editor/100/500/en-uk#1000001",13 collapsed lines
"createdAt": "2026-01-21T12:29:56+00:00", "updatedAt": null, "file": { "id": 500, "name": "strings.xml", "title": null, "type": "android", "path": "/strings.xml", "status": "active", "revision": "1", "branch": { "id": null }, "directory": { "id": null },19 collapsed lines
"project": null }, "project": { "id": 100, "userId": 1, "sourceLanguageId": "en", "targetLanguageIds": ["uk", "de", "fr"], "identifier": "abc123def456", "name": "Example Project", "createdAt": "2026-01-01T10:00:00+00:00", "updatedAt": "2026-01-21T12:25:25+00:00", "lastActivity": "2026-01-21T12:29:56+00:00", "description": "Sample project description", "url": "https://example.crowdin.com/u/projects/100", "cname": null, "languageAccessPolicy": null, "visibility": null, "publicDownloads": null, "logo": "data:image/png;base64,iVBORw0KGg...", "isExternal": false, "externalType": null, "hasCrowdsourcing": false, "groupId": "10"10 collapsed lines
} }, "sourceLanguage": { "id": "en", "name": "English", "editorCode": "en", "twoLettersCode": "en", "threeLettersCode": "eng", "locale": "en-US", "androidCode": "en-rUS", "osxCode": "en.lproj", "osxLocale": "en", "textDirection": "ltr",10 collapsed lines
"dialectOf": null }, "affectedLanguage": { "id": "uk", "name": "Ukrainian", "editorCode": "uk", "twoLettersCode": "uk", "threeLettersCode": "ukr", "locale": "uk-UA", "androidCode": "uk-rUA", "osxCode": "uk.lproj", "osxLocale": "uk", "textDirection": "ltr",7 collapsed lines
"dialectOf": null }, "workflowStep": { "id": 1000, "title": "Review Gate", "type": "Application", "languages": ["uk", "de", "fr"], "applicationModule": { "applicationIdentifier": "sample-app", "moduleKey": "sample-app-review_gate"3 collapsed lines
} }, "user": { "id": "1", "username": "john_doe", "fullName": "John Doe", "avatarUrl": "https://example.crowdin.com/avatar/1/small/avatar_default.png" } }}Status Values
Section titled “Status Values”The stringStatus.status field indicates the current state of the string in the workflow step:
| Status | Description |
|---|---|
NEED_PROCESS | String needs to be handled by the application |
TODO | String is awaiting action (the app explicitly parked it) |
DONE | String was successfully handled and routed to an output |
FAILED | Webhook delivery for this string failed; it must be re-triggered manually |
INCOMPLETE | String was previously available in the workflow step but is now missing |
Origin Events
Section titled “Origin Events”The stringStatus.originEvent field indicates what action triggered the workflow step recalculation. Possible values include:
String Events:
string.added- A new source string was addedstring.updated- A source string was modifiedstring.deleted- A source string was deletedstring.restored- A deleted string was restoredstring.triggered- A string was triggered in the workflowstring.triggered.after_file_update- A string was triggered after file update
Asset Events:
asset.added- A new asset was addedasset.triggered- An asset was triggered in the workflowasset.triggered.after_file_update- An asset was triggered after file update
Duplicate String Events:
duplicate.string.triggered- A duplicate string was triggered in the workflow
Suggestion Events:
suggestion.added- A new translation suggestion was addedsuggestion.updated- A translation suggestion was modifiedsuggestion.deleted- A translation suggestion was deletedsuggestion.restored- A deleted suggestion was restoredsuggestion.approved- A suggestion was approvedsuggestion.disapproved- A suggestion was disapprovedsuggestion.voted- A vote was added to a suggestionsuggestion.voteCanceled- A vote was removed from a suggestion
File Events:
file.language_exclude- A language was excluded from a filefile.branch_protection.changed- Branch protection settings were changed
Processing Strings
Section titled “Processing Strings”Your app moves strings through the step using two API endpoints, available on the Crowdin API client as client.workflowsApi. The client you receive in webhook callbacks and module functions is already authenticated as the Agent; both endpoints require the Agent to have manager access to the project (see Authentication).
List String Statuses
Section titled “List String Statuses”Returns the statuses of strings on the step for a given target language. Use it to reconcile — for example, to find strings in NEED_PROCESS whose webhook your app missed.
const statuses = await client.workflowsApi.getWorkflowStepStringStatus( projectId, stepId, languageId, // language code, e.g. 'uk' { limit: 500 });// statuses.data[i].data => { stringId, languageId, stepId, status, output }A maximum of 500 items is returned per request — use limit/offset to paginate.
Update String Statuses
Section titled “Update String Statuses”Reports the result of your condition of done by setting the string’s output. The status is derived from the output — the app never sets a status directly:
""(empty string) → the string is parked on the step asTODO(visible as pending work);- any of your step’s declared output ports → the string becomes
DONE, and Crowdin immediately routes it into whatever step is connected to that output.
await client.workflowsApi.updateWorkflowStepStringStatus( projectId, stepId, languageId, [ { op: 'replace', path: '/2814/output', value: 'approved' }, { op: 'replace', path: '/2815/output', value: 'untranslated' } ]);Rules to keep in mind:
- Only the
replaceoperation is supported; the patch path is/{stringId}/output. valuemust be one of the step’s declaredboundaries.outputs[].portvalues, or"".languageIdmust be one of the step’s target languages (seeworkflowStep.languagesin the webhook payload).- Strings currently
PENDINGon the step can’t be patched; setting an unchanged value is a no-op. - There is no processing deadline: a string stays in
NEED_PROCESSuntil your app reports a decision — your app owns liveness.
Limitations
Section titled “Limitations”- Crowdin Enterprise only — the step works only with advanced workflows. On crowdin.com the module is inactive.
crowdin_agentauthentication is mandatory (see Authentication); theagentconfiguration object is required with it.- A companion
webhooksmodule is required, subscribed tostring.status_on_step.recalculation_triggeredin the same app. - At most 2 outputs and exactly 1 input group;
initialcan be used only as an input port. - Webhook delivery is best-effort — see Delivery Guarantees. Reconcile via the API.
- The Agent must have manager access to every project that uses the step. A project manager has to invite the Agent user as a manager; without this access, workflow publishing fails and API calls return
403. - Adding this module to an already-installed app requires re-approval by an organization admin.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Cause | Fix |
|---|---|---|
Installation fails: Only crowdin_agent authentication type is allowed for workflow-step-type module type | The manifest uses the default crowdin_app authentication type (or authorization_code/none) | Set authenticationType: 'crowdin_agent' and add the agent object to the configuration |
Installation fails: Requested scopes exceed the access level specified in the OAuth app | The scopes in the configuration are broader than the OAuth app’s scopes | Align the configured scopes with the OAuth app registration |
| Step type doesn’t appear in the workflow designer | App installed on crowdin.com (the module is Enterprise-only), or the webhooks module subscription is missing | Install on Crowdin Enterprise; add a webhooks module subscribed to string.status_on_step.recalculation_triggered |
| Workflow publish error: “…must use an agent authentication type” | The app was previously installed with a different authentication type | Reinstall the app with crowdin_agent authentication |
| Workflow publish error: “Step … requires manager permissions for …” | The Agent user was not invited to the project or lost manager access | Invite the Agent user as a project manager |
403 Forbidden on the string status API | The request is not authenticated as the Agent, or the Agent is not a manager on the project | Use the client provided by the SDK; verify the Agent’s project role |
404 on the string status API | languageId is not among the step’s target languages, or the step is not an active application step | Use the language codes from the webhook’s workflowStep.languages |
400 when updating a string status | The output value is not one of the step’s declared output ports | Send one of the module’s boundaries.outputs[].port values or "" |
| Strings shown as “failed words” in the project | Webhook delivery to the app failed | Fix the app’s availability, then use the “trigger failed strings” action on the workflow step |
| Webhook never arrives | The project uses a basic workflow; the workflow is delayed; the event name is misspelled; or the webhook belongs to a different app than the step | Use an advanced workflow; subscribe to exactly string.status_on_step.recalculation_triggered in the same app |
| Webhook arrives with a delay | By design: events are queued and dispatched asynchronously | Design the app for asynchronous processing |