External QA Check
The External QA Check module lets your app validate translations with your own rules: placeholders, forbidden words, brand terms, a terminology service, or anything else that can be expressed as “this translation passes or fails, and here is why”. Crowdin shows the result to translators as a QA issue next to the built-in checks, and a check can even refuse a translation before it is saved.
How It Works
Section titled “How It Works”-
Install — an organization admin installs the app. Each
externalQaCheckitem in your configuration becomes a separate QA check in the organization, named after the item’sname. -
Enable in a project — a project manager switches the check on in the project QA settings. Switching it on re-checks every current translation of the project, in all target languages, so your app receives a burst of requests at that moment.
-
Crowdin calls your app — whenever a translation needs to be checked, Crowdin sends a batch of translations to your app over HTTP. One request always carries one check, one target language, and one file.
-
Your app answers — the
validatefunction returns one validation per translation in the HTTP response. There is no callback to deliver a validation later. -
Crowdin shows the result — a failed validation appears in the editor as a QA issue under the check’s name, with your message and optional suggested fixes. A passed validation clears the previous issue of that check.
Crowdin can call a check in two ways. Every check can be called from a queued job after the save. A check that declares supportsSynchronousRun: true is called inside the save request first, and the queued job asks it again only when the save did not get a validation:
| Asynchronous (default) | Synchronous | |
|---|---|---|
| How the app opts in | nothing | supportsSynchronousRun: true |
| When the check runs | after the translation is saved, from a queued job | inside the save request, before the translation is stored |
| When the translator sees the result | seconds or minutes after the save | immediately, in the save response |
| Can it refuse a save | never | yes, if the project marks the check as blocking |
| Time Crowdin waits for one request | up to 15 minutes | about 10 seconds for one translation, about 60 seconds for a batch |
| If the app does not answer | the job retries later | the translation is saved with an “Unable to complete” issue; the queued job asks again later |
See Synchronous checks and Errors and retries for details.
Sample
Section titled “Sample”import crowdinModule from '@crowdin/app-project-module';
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', externalQaCheck: [ { name: 'Placeholder Check', description: 'Checks that every {placeholder} of the source text is present in the translation', batchSize: 500, validate: async ({ client, file, project, sourceLanguage, strings, targetLanguage, translations, context }) => { const validations = translations.map((translation) => { const string = strings.find((string) => string.id === translation.stringId); const sourceText = typeof string?.text === 'string' ? string.text : ''; const missing = (sourceText.match(/\{\w+\}/g) || []) .filter((placeholder) => !translation.text.includes(placeholder));
if (missing.length === 0) { return { translationId: translation.id, passed: true }; }
return { translationId: translation.id, passed: false, error: { message: `Missing placeholders: ${missing.join(', ')}` } }; });
return { validations }; } } ]};
crowdinModule.createApp(configuration);import crowdinModule from '@crowdin/app-project-module';import type { ClientConfig } from '@crowdin/app-project-module';
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', externalQaCheck: [ { name: 'Placeholder Check', description: 'Checks that every {placeholder} of the source text is present in the translation', batchSize: 500, validate: async ({ client, file, project, sourceLanguage, strings, targetLanguage, translations, context }) => { const validations = translations.map((translation) => { const string = strings.find((string) => string.id === translation.stringId); const sourceText = typeof string?.text === 'string' ? string.text : ''; const missing = (sourceText.match(/\{\w+\}/g) || []) .filter((placeholder) => !translation.text.includes(placeholder));
if (missing.length === 0) { return { translationId: translation.id, passed: true }; }
return { translationId: translation.id, passed: false, error: { message: `Missing placeholders: ${missing.join(', ')}` } }; });
return { validations }; } } ]};
crowdinModule.createApp(configuration);Configuration
Section titled “Configuration”| Parameter | Description | Allowed values | Default value |
|---|---|---|---|
name | The check name shown in the project QA settings, in QA issues, and in import reports. | String | The app name |
description | Shown in the project QA settings. | String | The app description |
key | The module key. Used only when the app registers several checks. | String | Derived from name |
batchSize | The maximum number of translations Crowdin sends in one request. See Batching. | Integer | 500 (set by Crowdin) |
supportsSynchronousRun | Declares that the check answers within the save request. See Synchronous checks. | Boolean | false |
settingsUiModule | An optional UI module rendered as the settings page of the check. | UI module object | - |
validate | The function that produces the validations. See below. | Function | - |
validate Function
Section titled “validate Function”Crowdin calls this function once per request, with a batch of translations that share one target language and one file. It must return a validation for every translation in the batch.
Parameters
Section titled “Parameters”translations- Array of translations to check. Each item hasid,stringId,languageId,text,userId,provider(the MT engine or TM that produced the text, ornull),pluralCategoryName(nullfor a non-plural string), andisPreTranslated. A plural string arrives as one translation per plural form.strings- Array of source strings referenced by the translations, withid,key,text,context,maxLength,fields(custom fields), and timestamps. For a plural string,textis an object with one entry per plural form.sourceLanguage- Source language object.targetLanguage- Target language object of this batch.project- Project object.file- File object. Absent for string-based projects.client- Crowdin API client, authenticated as the app.context- Context object. The token is issued for the organization, not for a project or an end user; take the project and the author from the request body.
The fields listed above are what Crowdin sends. The TypeScript types of translations and strings follow the Crowdin API client models, which do not declare every wire field (for example translation.userId and string.key); read those fields through a cast.
Return Value
Section titled “Return Value”{ validations: [ { translationId: number, passed: boolean, error?: { message: string, suggestedFixes?: [ { indexStart: number, indexEnd: number, replacement: string } ] } } ]}- Return one validation for every translation in the request, with the
translationIdtaken fromtranslation.id. - A passed translation is
{ translationId, passed: true }. Do not add anerrorproperty to it, not evenerror: null. - A failed translation is
{ translationId, passed: false, error: { message } }. Theerrorobject is required, andmessageis what the translator reads in the editor. suggestedFixesare optional. Each fix replaces the part of the translation text from character offsetindexStarttoindexEndwithreplacement. The editor offers them to the translator as one-click fixes.- Do not add properties that are not listed above. Crowdin validates the response strictly and rejects the whole batch when it finds an unknown property.
Failing the whole batch
Section titled “Failing the whole batch”When validate throws, the module answers with an error body instead of the validations. When it returns an error string next to validations ({ validations, error: 'message' }), the module adds a top-level error to the response. Crowdin rejects both responses as invalid: the validations are discarded, even the correct ones, none of the translations in that batch get a validation from your check, and the failure counts towards the circuit breaker. See Errors and retries. Use it only when the whole batch genuinely could not be checked, for example when a backing service is down. A failed rule for a single translation is a passed: false validation, not an error.
Multiple QA Checks
Section titled “Multiple QA Checks”An app can register several checks by adding several items to the externalQaCheck array. Each item becomes a separate check in the organization, which project managers enable and configure independently.
const configuration = { // ... other configuration externalQaCheck: [ { key: 'placeholders', name: 'Placeholder Check', validate: async ({ strings, translations }) => ({ validations: translations.map((t) => ({ translationId: t.id, passed: true })) }) }, { key: 'forbidden-words', name: 'Forbidden Words Check', batchSize: 200, supportsSynchronousRun: true, validate: async ({ strings, translations }) => ({ validations: translations.map((t) => /forbidden/i.test(t.text) ? { translationId: t.id, passed: false, error: { message: 'The translation contains a forbidden word' } } : { translationId: t.id, passed: true } ) }) } ]};With several items, each check gets its own module key: the key you set, otherwise <identifier>-<kebab-cased name>, otherwise <identifier>-qa-check-<index>. The module serves each check under /qa-check/<key>/validate (and /qa-check/<key>/batch-size when batchSize is set). The module key is also the check’s identity in Crowdin, so set key explicitly and keep it stable: a renamed key is a new check, and projects that enabled the old one lose it. With a single item the key is always <identifier>-qa-check, served under /qa-check/validate; do not set key in that case.
Batching
Section titled “Batching”Crowdin never sends translations one by one. It groups the translations that need to be checked by target language and file, cuts each group into batches of at most batchSize translations, and sends the batches as separate requests, up to 10 of them in parallel.
- The default batch size is 500. Set
batchSizeto change it; the module then serves the value on a URL that Crowdin reads and caches for about 10 minutes, so a new size takes effect within that time. - How many translations one call brings depends on what triggered it. A single save in the editor or through the API sends one translation. A file upload, a pre-translation, or enabling the check in a project sends full batches.
- Enabling a check in a project re-checks every current translation of the project, in every target language. For a large project this is thousands of translations at once, and the app cannot control when a project manager does it. Size the app and any service behind it for a whole project, not for single saves.
- AI pre-translation with “retry on QA issues” sends the same text twice: once for the first AI output and once more for the final text.
- There is no rate limit on the Crowdin side. If your check calls a rate-limited service, put a queue and a rate limiter in your app, and prefer one call to the service per batch over one call per translation.
Synchronous checks
Section titled “Synchronous checks”By default a check runs asynchronously: Crowdin saves the translation first and calls your app afterwards from a queued job. The result arrives in the editor a little later, and the check can never block saving.
Set supportsSynchronousRun: true when your check can answer inside the save request. Crowdin then calls the check before it stores the translation on the paths that save translations: the editor, the API, file uploads, and pre-translation. The translator sees the validation in the save response.
Only a synchronous check can be marked as blocking. Whether it blocks is a project setting in the project QA settings; the app only declares that it is able to answer in time. When a blocking check fails a translation:
- the editor refuses the save, shows your message, and offers no “save anyway”;
- the API answers
400with your message; - an import or a pre-translation skips the translation and reports it as skipped by your check.
const configuration = { // ... other configuration externalQaCheck: [ { name: 'Forbidden Words Check', supportsSynchronousRun: true, validate: async ({ translations }) => ({ validations: translations.map((t) => ({ translationId: t.id, passed: !/forbidden/i.test(t.text), ...(/forbidden/i.test(t.text) ? { error: { message: 'The translation contains a forbidden word' } } : {}) })) }) } ]};A synchronous check has a time budget: about 10 seconds for a request with one translation and about 60 seconds for a batch. The budget covers the whole request, including network time and the module’s own subscription check, so the validate function should stay well below it. When your app does not answer within the budget, returns an error, or returns an invalid response, Crowdin fails open: the translation is saved anyway and a QA issue saying “Unable to complete check <check name>” is recorded, so the translator sees that the validation is missing. A blocking check therefore never refuses a save because your app is down. The queued job asks your app again later, with the asynchronous time budget, and replaces the issue with the real validation.
Because a synchronous check also receives the asynchronous calls, the same translation can reach your app twice: once inside the save and once more later if the save did not get a validation. Make the check deterministic: the same source text, translation, and target language must always give the same result, otherwise the validation flips between the two calls and confuses translators.
While you develop, the module logs a warning starting with Warning: synchronous external qa check took whenever validate runs longer than the budget. Treat it as a sign that Crowdin would have recorded the check as not completed.
Errors and retries
Section titled “Errors and retries”The validation must be in the HTTP response of the same request. Crowdin has no API for delivering a validation later, and it never retries a request immediately. What happens when your app fails depends on how the check was called:
| Call | On a timeout, a 5xx, an invalid response, or a batch error |
|---|---|
| Synchronous, inside a save | The translation is saved with an “Unable to complete check” issue. The queued job asks again later. |
| Asynchronous, from the queued job | The job retries after about 15 minutes and again about 2 hours later. After the third failure it records “Unable to complete check” for the affected translations. |
Re-check after enabling the check in a project, or the revalidate API | No retry. The affected translations get “Unable to complete check” at once. |
- “Unable to complete check” counts as a failed QA issue in filters and project statistics, and it never blocks. It stays until the check answers for that translation again: on the next save, on a source text change, or on a revalidation.
- When one request of a batch run fails, every translation of that run loses the validation of your check, including the translations in requests that succeeded.
- A
4xxresponse other than429is read as “the request was wrong”, not as “the app is down”. It fails the check for that run and is still retried by the queued job. Do not answer4xxto signal that your app is busy; answer5xxor429, or, for an asynchronous check, hold the request (see Answering later).
Circuit breaker
Section titled “Circuit breaker”Crowdin protects itself and your app with a circuit breaker per check and organization. After 5 failed requests within 60 seconds, Crowdin stops calling the check for 60 seconds. Saves made during that minute get “Unable to complete check” at once, and queued jobs wait for their retry. A blocking check does not block during the pause, in every project of the organization. When the pause ends, all traffic comes back at once. A 4xx response never opens the breaker.
Answering later
Section titled “Answering later”An asynchronous check may hold the request while it works: the queued job waits up to 15 minutes for one request. Use this for occasional slow checks, not as the normal answer time, because every waiting request holds a worker on the Crowdin side. A check that regularly needs more than a few seconds is a poor candidate for supportsSynchronousRun.
When the real work takes longer, or when your rules change, answer with the validation you have and re-check later: the POST /projects/{projectId}/qa-checks/revalidate endpoint of the Crowdin Enterprise API re-checks every translation of the checks listed in externalQaCheckIds in a project. It needs project manager rights, and only one revalidation per project can run at a time, so it suits rare, large updates rather than single translations.
Production checklist
Section titled “Production checklist”- Return one validation per translation, with exactly the properties listed in Return Value. No
error: null, no extra keys. - Fail a translation, not the batch.
passed: falsewith a message is the normal way to report a problem. Throwing or returningerrorhides the validations of the whole batch. - Never key a cache by
translationId. Use the source text, the translation text, and the target language. - Deduplicate inside a batch. Plural forms, repeated strings, and identical translations often share text.
- Own your rate limit. Queue and throttle calls to a backing service in the app. Crowdin sends up to 10 requests in parallel per run and does not slow down for you.
- Answer fast. A synchronous check has about 10 seconds for one translation and about 60 seconds for a batch, including network time. An asynchronous check should normally answer in seconds.
- Be deterministic. A validation stays until the next check of that translation; the same input must always give the same result.
- Expect bursts. Enabling the check in a project re-checks the whole project at once, and the app has no say in when that happens. Size the app for a whole project, not for single saves.
- Do not answer
passed: truewhen you could not check. Nobody will see that the check did not run. Fail the batch instead, so the translations get a visible “Unable to complete check” issue and a later retry. - Be careful with blocking. Declare
supportsSynchronousRunonly for a check that answers reliably within the budget; project managers will use it to make the check blocking. During an outage the check fails open and stops blocking anyway.
Limitations
Section titled “Limitations”- Crowdin Enterprise only — the module is not available on crowdin.com.
- No callback — the validation must be in the HTTP response. There is no API to deliver it later, and no “pending” state.
- No rate limit on the Crowdin side — up to 10 requests per run in parallel, with many runs at the same time across the organization.
- Strict response format — an unknown property, a missing
erroron a failed translation, or a non-integertranslationIdrejects the whole batch. - No immediate retry — a failed request is retried by the queued job after about 15 minutes and again about 2 hours later, never at once. Re-checks triggered by enabling the check or by the
revalidateendpoint are not retried. - A blocking check fails open — when the app cannot answer, the translation is saved with an “Unable to complete check” issue. Crowdin never refuses a save because your app is unavailable.
- Only synchronous checks can be blocking, and whether a check blocks is decided by the project, not by the app.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Cause | Fix |
|---|---|---|
| The check does not appear in the project QA settings | The app is installed on crowdin.com, or the app’s access level does not include the current user | Install on Crowdin Enterprise; check who may use the app in its installation settings |
Every translation of a batch gets “Unable to complete check”, although validate ran without errors | The response was rejected as invalid: an unknown property, error: null on a passed translation, a failed translation without error, or a translationId that is not an integer | Return exactly the structure from Return Value |
| Translators see “Unable to complete check” right after saving | The synchronous check exceeded its time budget, threw, or returned error | Make validate faster, or lower batchSize so one request carries less work; look for the Warning: synchronous external qa check took line in the app logs |
| “Unable to complete check” appears for a whole minute in every project | The circuit breaker opened after 5 failed requests within 60 seconds | Fix the app’s availability; the breaker closes on its own after 60 seconds |
| A translation with a failed blocking check was saved anyway | The app did not answer in time, so the check failed open; or the check is not marked as blocking in the project; or the translation came from a path where blocking does not apply, for example a translation copied by a workflow from a vendor project or restored from history | Check the app logs for the warning line; check the project QA settings |
| The validation for the same text differs between the save and the later QA issue | The check is not deterministic, or it caches by translationId | Make the result depend only on the texts and the language; cache by text |
A different batchSize does not take effect | Crowdin caches the batch size for about 10 minutes | Wait; the new size is picked up when the cache expires |
| The app receives a burst of requests after a project manager changes settings | Enabling the check re-checks the whole project | Expected; size the app for it |
| A project manager cannot mark the check as blocking | The module does not declare supportsSynchronousRun: true | Set the option and update the app in Crowdin; the change is active within about a minute |
| After renaming a check, projects lost it | The module key changed, so Crowdin sees a new check | Set key explicitly and keep it stable; the name does not affect the key once key is set |