Crowdin Webhook 422 Unprocessable Entity on Branch Push
A Crowdin webhook that returns 422 Unprocessable Entity when you push a new branch almost always means the API v2 request body was syntactically valid but failed Crowdin’s own validation — a branch name containing slashes or other illegal characters, a source path in your request that no crowdin.yml mapping matches, or a branch/file that already exists. The 422 is not a network or auth problem (that would be 401/403/404); it is Crowdin telling you the contents of the call are unacceptable. This page reads the 422 payload field by field and maps each error key back to the exact fix in your branch naming or crowdin.yml.
The trap is that the HTTP status alone tells you almost nothing — every one of these distinct causes surfaces as the same 422. The diagnostic lever is the JSON errors array Crowdin returns in the response body, where each entry names the offending field and its violated constraint. This page assumes you already have the broader Crowdin Integration for Dev Teams wiring in place (project ID, API token, crowdin.yml) and that a CI step or the Crowdin GitHub Action is pushing a branch on your behalf.
Root Cause: What 422 Actually Means in the Crowdin API v2
Crowdin’s REST API v2 returns 422 from POST /projects/{projectId}/branches and the file/storage endpoints when a request passes schema validation (so it is not a 400 malformed body) but violates a semantic constraint. Per the API v2 error contract, the body carries an errors array; each element has a key (the field that failed) and an errors sub-array of {code, message} objects. The code is the machine-readable reason, and it is the only field you should branch your debugging on. Three codes account for nearly every branch-push 422:
notValidName— the branchnamecontains characters Crowdin forbids. Crowdin branch names may not contain/ \ : * ? " < > |. Git branch names routinely contain/(e.g.feature/login,release/2.1), so a rawgitref passed straight through fails. This is the single most common cause.notExists— a referenced entity the request depends on is absent. On file creation this is usually astorageIdthat was never uploaded, or a source path in your call that nocrowdin.ymlfiles:mapping resolves to, so Crowdin has nothing to attach the branch’s strings to.notUnique— the branch (or a file within it) already exists. Re-running a non-idempotent “create branch” step after a previous partial success trips this; the branch is there from the first attempt.
Because all three share the 422 status, the workflow is always the same: capture the full response body, read errors[0].key and errors[0].errors[0].code, then apply the matching fix below. Treating 422 as a single failure and blindly retrying just reproduces it.
Minimal Reproducible Example
The smallest call that triggers the notValidName variant is creating a branch whose name is a raw Git ref with a slash:
# Reproduces 422 notValidName: Git ref "feature/login" is illegal as a Crowdin branch name
curl -sS -X POST "https://api.crowdin.com/api/v2/projects/${CROWDIN_PROJECT_ID}/branches" \
-H "Authorization: Bearer ${CROWDIN_PERSONAL_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"name":"feature/login"}'
The response is a 422 whose body pinpoints the field and reason:
{
"errors": [
{
"key": "name",
"errors": [
{ "code": "notValidName",
"message": "The branch name can't contain \\ / : * ? \" < > | symbols" }
]
}
]
}
The notExists variant looks the same at the HTTP layer but names a different key — typically storageId on POST .../files, or the source path when the Crowdin GitHub Action uploads a file whose repo path matches no crowdin.yml mapping. The notUnique variant returns {"code":"notUnique"} on the name key when that branch already exists.
The Fix: Sanitize the Name and Align the Mapping
Address each code at its source. For notValidName, normalize the Git ref into a legal Crowdin branch name before the API call — replace every forbidden character with a safe separator and keep the mapping reversible so downloads route back correctly:
# Map a Git ref to a Crowdin-legal branch name, then create idempotently
GIT_REF="feature/login"
# Replace / \ : * ? " < > | with a single dash (Crowdin-legal charset)
CROWDIN_BRANCH=$(printf '%s' "$GIT_REF" | tr '/\\:*?"<>|' '-') # → feature-login
# Look up first; only create when absent → avoids the notUnique 422
EXISTING=$(curl -sS "https://api.crowdin.com/api/v2/projects/${CROWDIN_PROJECT_ID}/branches?name=${CROWDIN_BRANCH}" \
-H "Authorization: Bearer ${CROWDIN_PERSONAL_TOKEN}" | grep -c '"id"')
if [ "$EXISTING" -eq 0 ]; then
curl -sS -X POST "https://api.crowdin.com/api/v2/projects/${CROWDIN_PROJECT_ID}/branches" \
-H "Authorization: Bearer ${CROWDIN_PERSONAL_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"name\":\"${CROWDIN_BRANCH}\"}" # name now contains no forbidden chars
fi
If you use the Crowdin GitHub Action rather than raw curl, you do not hand-sanitize: the action derives the Crowdin branch from the Git ref automatically. The 422 then comes from notExists instead — a source path in the push has no matching crowdin.yml rule. Fix the mapping so every source file the push touches resolves to a files: entry:
# crowdin.yml — the source glob MUST match the repo paths your branch actually pushes
project_id_env: CROWDIN_PROJECT_ID
api_token_env: CROWDIN_PERSONAL_TOKEN
preserve_hierarchy: true # keep repo folder structure → predictable source paths
files:
- source: /locales/en/**/*.json # must match where en sources really live
translation: /locales/%two_letters_code%/**/%original_file_name%
# If sources moved to /apps/web/locales, the OLD glob matches nothing → notExists 422
Key points on the non-obvious lines:
tr '/\\:*?"<>|' '-'collapses the entire forbidden set to a dash in one pass;feature/loginbecomesfeature-login, which Crowdin accepts.- The pre-flight
GET ...?name=makes branch creation idempotent: a retried CI job finds the existing branch and skips thePOST, so thenotUnique422 never fires. preserve_hierarchy: truekeeps the source path Crowdin computes identical to your repo layout, so thenotExistsmismatch does not reappear after a refactor.
Verification
Confirm the fix by asserting that the create call now returns 201, not 422, and that the branch name is legal. This snippet fails loudly if the API still rejects the body:
# Pass when create returns 2xx; print the errors[].code on any 422 so it is greppable in CI
RESP=$(curl -sS -w '\n%{http_code}' -X POST \
"https://api.crowdin.com/api/v2/projects/${CROWDIN_PROJECT_ID}/branches" \
-H "Authorization: Bearer ${CROWDIN_PERSONAL_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"name\":\"${CROWDIN_BRANCH}\"}")
CODE=$(printf '%s' "$RESP" | tail -1)
BODY=$(printf '%s' "$RESP" | sed '$d')
if [ "$CODE" = "201" ] || [ "$CODE" = "200" ]; then
echo "OK: branch created"
elif [ "$CODE" = "422" ]; then
echo "$BODY" | grep -o '"code":"[^"]*"' ; exit 1 # surfaces notValidName / notExists / notUnique
else
echo "Unexpected $CODE: $BODY" ; exit 1
fi
A clean local guard that catches an illegal name before it ever hits the API — run it in the same job that builds the branch name:
case "$CROWDIN_BRANCH" in
*[/\\:*?\"\<\>\|]*) echo "branch name has forbidden chars: $CROWDIN_BRANCH"; exit 1 ;;
*) echo "branch name is Crowdin-legal" ;;
esac
When to Escalate
If the response code is notExists on a storageId you are certain you uploaded, the failure has moved past naming and mapping into the two-step upload protocol: Crowdin requires you to POST the file bytes to /storages first and then reference the returned storageId within minutes before it expires, so a slow or retried job can reference a storage that has already been garbage-collected. That is an upload-ordering problem, not a crowdin.yml problem. Likewise, if the 422 only appears for the outbound download leg into GitHub rather than the inbound branch push, the issue is on the GitHub PR side, covered in connecting the Crowdin API to GitHub pull requests. For the project-level configuration these fixes build on — token model, project ID, and the full crowdin.yml reference — return to Crowdin Integration for Dev Teams. If you are reconsidering the platform entirely after repeated sync pain, weigh it against Crowdin vs Weblate for self-hosted teams.
FAQ
Why does Crowdin return 422 instead of 400 for a bad branch name?
A 400 means the request body was malformed JSON or violated the schema (wrong type, missing required field). A 422 Unprocessable Entity means the body was well-formed and schema-valid but failed a semantic rule — here, the name field is a valid string yet contains characters (/ \ : * ? " < > |) that Crowdin forbids in branch names. The distinction matters because retrying without changing the value will always reproduce the 422.
How do I read which cause produced the 422?
Parse the response body’s errors array. Each entry has a key (the failing field, e.g. name or storageId) and an errors sub-array of {code, message}. Branch your fix on the code: notValidName means sanitize the branch name, notExists means align the crowdin.yml source mapping or re-upload the storage, and notUnique means the branch already exists so reuse it instead of creating it.
My CI retries the job and now gets notUnique instead — what changed?
The first attempt actually created the branch before failing on a later step, so the retry’s create call hits an existing branch and returns notUnique. Make branch creation idempotent: GET /branches?name= first and only POST when the branch is absent, or let the Crowdin GitHub Action manage the branch, which reuses an existing one in place.
Related
- Crowdin Integration for Dev Teams — the project ID, token, and
crowdin.ymlsetup these fixes build on. - Connecting Crowdin API to GitHub pull requests — the outbound leg where a different 422 can appear on the download PR.
- GitHub Actions i18n CI Gates — adding the branch-name and validation guards that catch this before it hits the API.
- Crowdin vs Weblate for self-hosted teams — choosing the platform behind this sync pipeline.
Part of Crowdin Integration for Dev Teams.