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.

Mapping a Crowdin 422 payload to its root cause A 422 response is parsed by its errors array key: a notValidName error points to an illegal branch name, a notExists error points to a missing crowdin.yml source mapping, and a notUnique error points to a branch or file that already exists. 422 Unprocessable read errors[].code notValidName branch has / or : ? * feat/login → feat-login notExists (storageId) source path unmapped fix crowdin.yml files: notUnique branch/file exists reuse, do not re-create The fix is in your request, not the transport Sanitize the branch name · align crowdin.yml source paths · make branch creation idempotent
One status code, three causes: the errors[].code field tells you which fix applies.

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 branch name contains characters Crowdin forbids. Crowdin branch names may not contain / \ : * ? " < > |. Git branch names routinely contain / (e.g. feature/login, release/2.1), so a raw git ref 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 a storageId that was never uploaded, or a source path in your call that no crowdin.yml files: 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/login becomes feature-login, which Crowdin accepts.
  • The pre-flight GET ...?name= makes branch creation idempotent: a retried CI job finds the existing branch and skips the POST, so the notUnique 422 never fires.
  • preserve_hierarchy: true keeps the source path Crowdin computes identical to your repo layout, so the notExists mismatch 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.

Part of Crowdin Integration for Dev Teams.