Connecting Crowdin API to GitHub Pull Requests

Wiring Crowdin to GitHub means translated strings land back in your repo as a reviewable pull request on a dedicated branch, not as a manual export-and-commit. The hard part is not the API call — it is making the round trip deterministic: source strings flow up on every merge to main, finished translations flow down onto an l10n branch, and the resulting PR carries a stable identity so CI never opens a second one. Get the branch and token model wrong and you get duplicate PRs, force-push wars, and 403s from a token that cannot trigger downstream checks.

This page shows the recommended setup using the official Crowdin GitHub Action, why a GitHub App token beats a personal access token (PAT) for the download PR, and how to keep the l10n branch in sync with main without merge-conflict churn. It assumes you have already done the broader Crowdin Integration for Dev Teams wiring (project ID, crowdin.yml file mappings) and now want the two-way GitHub bridge specifically.

Crowdin to GitHub two-way sync flow Merging to main uploads sources to Crowdin. Translators work. The Crowdin Action downloads translations onto the l10n branch and opens or updates a single pull request, which CI gates before merge. merge → main source strings change Crowdin Action upload_sources: true Crowdin project translators work download_translations approved strings l10n branch l10n branch Pull request single, idempotent CI gate on the PR JSON/ICU validation · placeholder parity · required check before merge → main
One branch, one PR: sources flow up on every merge, approved translations flow down and are gated by CI before they reach main.

Root Cause: Why Naive Sync Produces Duplicate PRs and 403s

Three behaviours bite teams who script this by hand instead of using the official action.

The branch is not idempotent. The Crowdin GitHub Action keys its download PR off a fixed localization_branch_name (default l10n) and pull_request_title. If you instead generate a new branch per run, every sync opens a fresh PR and the old ones rot. The fix is to always target the same branch and let the action update it in place — it commits new translations onto that branch and reuses the existing open PR.

A GITHUB_TOKEN cannot trigger your CI. Pushes and PRs made with the default secrets.GITHUB_TOKEN deliberately do not fire push or pull_request workflow events — GitHub blocks this to prevent recursive runs. So your translation PR opens but its required status checks never start, and the PR can never satisfy branch protection. This is the single most common reason a Crowdin PR “looks stuck.” The fix is to authenticate the download with a GitHub App installation token (or, less ideally, a PAT). Events triggered by an App token do start workflows.

Sources and translations race. If you upload sources and download translations in the same job against main, a merge to main that happens mid-run leaves the l10n branch behind main, and the next PR carries phantom conflicts. Keep the l10n branch rebased on main and let the action manage the diff.

Minimal Reproducible Setup

A single scheduled job that both uploads source strings and pulls finished translations into a PR. This is the smallest config that exercises the full round trip and the token edge case.

name: Crowdin Sync
on:
  push:
    branches: [main]      # upload sources whenever main changes
  schedule:
    - cron: '0 */6 * * *' # pull translations every 6h
  workflow_dispatch:

permissions:
  contents: write
  pull-requests: write

jobs:
  crowdin:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: crowdin/github-action@v2
        with:
          upload_sources: true
          download_translations: true
          localization_branch_name: l10n
          create_pull_request: true
          pull_request_title: 'New Crowdin translations'
          pull_request_base_branch_name: main
          token: ${{ secrets.GITHUB_TOKEN }}   # ← the bug: CI won't run on this PR
        env:
          CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
          CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

This works — the PR appears — but because it is created with GITHUB_TOKEN, none of your i18n CI gates (the kind described in GitHub Actions i18n CI Gates) ever run on it. Under branch protection, the PR is unmergeable.

The Fix: GitHub App Token + Idempotent l10n Branch

Mint a short-lived installation token from a GitHub App and hand that to the action’s token. An App token is treated as a first-class actor, so the PR it opens triggers pull_request workflows normally. CROWDIN_PERSONAL_TOKEN stays the Crowdin-side credential; the two are separate and must not be confused.

name: Crowdin Sync
on:
  push:
    branches: [main]
  schedule:
    - cron: '0 */6 * * *'
  workflow_dispatch:

permissions:
  contents: write
  pull-requests: write

jobs:
  crowdin:
    runs-on: ubuntu-latest
    steps:
      # Exchange App private key for a 1h installation token
      - uses: actions/create-github-app-token@v1
        id: app-token
        with:
          app-id: ${{ secrets.L10N_APP_ID }}
          private-key: ${{ secrets.L10N_APP_PRIVATE_KEY }}

      - uses: actions/checkout@v4
        with:
          token: ${{ steps.app-token.outputs.token }}
          fetch-depth: 0          # full history so the l10n branch rebases cleanly

      - uses: crowdin/github-action@v2
        with:
          upload_sources: true
          download_translations: true
          # one stable branch → one stable PR, updated in place every run
          localization_branch_name: l10n
          create_pull_request: true
          pull_request_title: 'New Crowdin translations'
          pull_request_labels: 'l10n,automated'
          pull_request_base_branch_name: main
          # commit only approved strings; leave fuzzy/untranslated out of the PR
          skip_untranslated_strings: false
          export_only_approved: true
          # App token: PR now fires required CI checks
          token: ${{ steps.app-token.outputs.token }}
        env:
          CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
          CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

Key points on the non-obvious lines:

  • actions/create-github-app-token@v1 returns a token scoped to the App’s installation, expiring in ~1 hour — far safer than a long-lived PAT in secrets.
  • fetch-depth: 0 gives the action full history; without it the l10n branch can fail to rebase onto main and the PR shows spurious diffs.
  • export_only_approved: true keeps in-progress strings out of the PR, so a merge never ships half-translated UI. Pair it with the validation gate so placeholder integrity ({count}, {{name}}, ICU {n, plural, …}) is checked before merge — see the related work on enforcing glossary terms in CI.

For the Crowdin App token specifically, the App needs the Contents and Pull requests repository permissions (read/write) and must be installed on the target repo. Anything less and the action 403s when it tries to push the l10n branch.

Configuration Reference

Action input Type Description / default
upload_sources boolean Push source files to Crowdin per crowdin.yml. Default true.
download_translations boolean Pull translated files back into the repo. Default false.
localization_branch_name string The branch the download PR targets. Keep it stable (e.g. l10n) for idempotency.
create_pull_request boolean Open/update a PR for downloaded translations. Default true.
pull_request_base_branch_name string Base for the translation PR. Usually main.
export_only_approved boolean Commit only approved strings. Default false.
token string GitHub token used to push and open the PR. Use an App installation token so CI runs.
CROWDIN_PROJECT_ID / CROWDIN_PERSONAL_TOKEN env Crowdin-side numeric project ID and API token. Distinct from token.

Verification

Confirm the round trip in three checks. First, prove the PR was opened by your App actor (not github-actions[bot]), which is what lets CI run:

gh pr view "New Crowdin translations" --json author,headRefName,labels
# expect: author.login = "<your-app>[bot]", headRefName = "l10n"

Second, assert the required check actually started on the PR — the symptom of the GITHUB_TOKEN bug is an empty list here:

gh pr checks l10n
# expect at least one "i18n-validate" (or similar) check, not "no checks reported"

Third, a fast placeholder-parity guard you can drop into the PR’s CI so a broken interpolation never merges:

# fail if any target file is missing a {placeholder} present in the source
node -e '
const fs=require("fs");
const src=JSON.parse(fs.readFileSync("locales/en/common.json"));
const ph=s=>[...String(s).matchAll(/\{[^}]+\}/g)].map(m=>m[0]).sort().join();
for (const lang of fs.readdirSync("locales")) {
  if (lang==="en") continue;
  const t=JSON.parse(fs.readFileSync(`locales/${lang}/common.json`));
  for (const k in src) if (t[k] && ph(src[k])!==ph(t[k])) {
    console.error(`placeholder mismatch ${lang}:${k}`); process.exit(1);
  }
}'

When to Escalate

If translations still fail to land after switching to an App token, the problem has usually moved upstream to Crowdin itself — a webhook returning non-2xx on branch push, a project that is not actually building the export, or a crowdin.yml path that does not match your repo layout. A 422 on the inbound webhook in particular points at branch handling in the project, not the GitHub side; that path is covered in Crowdin webhook 422 on branch push. If you are weighing whether to keep this GitHub-hosted flow at all versus a self-hosted server, compare it against Crowdin vs Weblate for self-hosted teams. For the full project setup that this page builds on, return to Crowdin Integration for Dev Teams.

FAQ

Why does my Crowdin translation PR show no status checks?

Because it was created with the default secrets.GITHUB_TOKEN. GitHub intentionally does not fire push or pull_request events for activity from that token, so your required CI checks never start and branch protection blocks the merge. Re-run the action with a GitHub App installation token (via actions/create-github-app-token) or a PAT, and the PR will trigger workflows normally.

Should I use a GitHub App token or a personal access token?

Prefer a GitHub App token. It is minted per run, expires in about an hour, and is scoped to the App’s installation permissions (Contents + Pull requests), so a leak has a tiny blast radius. A PAT works but is long-lived, tied to a human account, and counts against that user’s rate limits — avoid it for shared CI.

How do I stop Crowdin from opening a new PR every sync?

Keep localization_branch_name and pull_request_title constant across runs. The action then commits new translations onto the same branch and updates the existing open PR in place instead of creating another. Generating a per-run branch name is what produces the pile of duplicate PRs.

Part of Crowdin Integration for Dev Teams.