Automating Chrome Web Store Uploads with the API

Publish to the Chrome Web Store from CI — OAuth credentials for the API, uploading a package, submitting for review with a staged rollout percentage, and reading the resulting status.

Published September 18, 2026 Updated September 18, 2026 7 min read
Table of Contents

Uploading a zip through the developer dashboard is fine for the first release and a liability by the tenth: someone uploads the wrong build, forgets to bump the version, or publishes to 100% when the plan was a 10% rollout. The Chrome Web Store API lets CI do the upload and submission with the exact artifact the pipeline built and tested. Setting it up involves an OAuth dance that is awkward once and invisible afterwards. This guide is part of CI and release automation.

What the pipeline does

An automated Chrome Web Store releaseCI exchanges a stored refresh token for an access token, uploads the built zip to the item, submits it for review with a rollout percentage, and polls the item status.CI jobGoogle OAuthWeb Store APIReviewrefresh_token → access_tokenaccess_token (1 h)upload extension.zipuploadState: SUCCESSpublish (deployPercentage: 10)queued for review
The only long-lived secret is the refresh token; everything else is minted per run.

Step-by-step

1. Create API credentials once

In Google Cloud, create a project, enable the Chrome Web Store API, and create an OAuth client of type Desktop app. Then obtain a refresh token for the Google account that owns the item by running the consent flow once, locally.

1# One-time: exchange an auth code (from the consent URL) for a refresh token.
2curl -s https://oauth2.googleapis.com/token \
3  -d client_id="$CWS_CLIENT_ID" \
4  -d client_secret="$CWS_CLIENT_SECRET" \
5  -d code="$AUTH_CODE" \
6  -d grant_type=authorization_code \
7  -d redirect_uri=urn:ietf:wg:oauth:2.0:oob | jq -r .refresh_token

Execution context: your local shell, once. The refresh token is the credential CI will use indefinitely; store it with the client id and secret in your CI secret store and nowhere else — the handling rules are in managing store credentials in CI secrets. The consent URL uses the https://www.googleapis.com/auth/chromewebstore scope.

2. Mint an access token per run

1ACCESS_TOKEN=$(curl -s https://oauth2.googleapis.com/token \
2  -d client_id="$CWS_CLIENT_ID" \
3  -d client_secret="$CWS_CLIENT_SECRET" \
4  -d refresh_token="$CWS_REFRESH_TOKEN" \
5  -d grant_type=refresh_token | jq -r .access_token)
6test -n "$ACCESS_TOKEN" && test "$ACCESS_TOKEN" != "null"

Execution context: the CI job. Access tokens last about an hour, which is ample for an upload and submit. The test line fails the job immediately if the refresh token has been revoked, rather than letting a later step fail with a confusing 401.

3. Upload the built package

1curl -sf -X PUT \
2  -H "Authorization: Bearer $ACCESS_TOKEN" \
3  -H "x-goog-api-version: 2" \
4  -T dist/extension.zip \
5  "https://www.googleapis.com/upload/chromewebstore/v1.1/items/$CWS_ITEM_ID" | tee upload.json
6
7jq -e '.uploadState == "SUCCESS"' upload.json

Execution context: the CI job, after the build and test stages. uploadState can also be IN_PROGRESS for large packages or FAILURE with an itemError array explaining why — most often a version number that is not higher than the published one. jq -e fails the job unless the upload succeeded.

4. Submit with a staged rollout

1curl -sf -X POST \
2  -H "Authorization: Bearer $ACCESS_TOKEN" \
3  -H "x-goog-api-version: 2" \
4  -H "Content-Length: 0" \
5  "https://www.googleapis.com/chromewebstore/v1.1/items/$CWS_ITEM_ID/publish?deployPercentage=10" | tee publish.json
6
7jq -e '.status | index("OK") or index("ITEM_PENDING_REVIEW")' publish.json

Execution context: the CI job. deployPercentage starts a partial rollout once review passes, which is the single most valuable safety net for a bad release — covered in rolling back a bad extension release. publishTarget=trustedTesters publishes to the trusted-tester group instead of the public.

5. Make it a pipeline stage with a manual gate

 1# .github/workflows/release.yml (excerpt)
 2  publish-chrome:
 3    needs: [build, test]
 4    runs-on: ubuntu-latest
 5    environment: chrome-web-store        # requires a reviewer's approval
 6    steps:
 7      - uses: actions/download-artifact@v4
 8        with: { name: extension-chrome, path: dist }
 9      - run: ./scripts/cws-publish.sh
10        env:
11          CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }}
12          CWS_CLIENT_SECRET: ${{ secrets.CWS_CLIENT_SECRET }}
13          CWS_REFRESH_TOKEN: ${{ secrets.CWS_REFRESH_TOKEN }}
14          CWS_ITEM_ID: ${{ vars.CWS_ITEM_ID }}

Execution context: GitHub Actions. Publishing from the artifact the test job validated — not from a rebuild — guarantees the reviewed bytes are the tested bytes. The environment with required reviewers gives a human one click to confirm before anything reaches the store. The surrounding pipeline is in building a GitHub Actions pipeline for extensions.

Where the store upload sits in the pipelineThe build produces one artifact per target, tests run against it, a human approves the release environment, and only then does the job upload and submit to the Chrome Web Store with a rollout percentage.Builddist/extension.zipTestunit + e2e on the artifactApproveenvironment revieweronly after approvalMint access tokenfrom refresh tokenUploaduploadState SUCCESSPublish 10%review, then staged
The artifact is built once — the store receives exactly the bytes the tests exercised.

Failure modes worth handling

The API is reliable; the surrounding assumptions are where automated releases go wrong.

The version was not bumped. The upload fails with ITEM_NOT_UPDATABLE or a version error. Guard it before the upload by comparing the manifest version against the published one from the API’s GET on the item — a failed release discovered at the end of a long pipeline is more expensive than one caught in the first minute.

An item is already in review. Submitting while a previous version is pending replaces the pending submission. That is sometimes what you want and sometimes a surprise; the job should read the item’s current state and refuse to overwrite a pending review without an explicit flag.

The refresh token was revoked. Tokens are revoked when the owning account changes its password, when the OAuth client is deleted, or after long disuse in some configurations. The per-run minting step in step 2 surfaces this immediately; alerting on it rather than discovering it on release day is worth one scheduled job that mints a token weekly.

The rollout percentage is forgotten. A job that publishes at the store’s default goes to everyone once review passes. Make deployPercentage a required input of the release job, not a default, so nobody can publish without choosing it.

Store API responses and what to doCommon upload and publish outcomes from the Chrome Web Store API with their meaning and the pipeline's correct response.ResponseMeaningPipeline shoulduploadState SUCCESSPackage acceptedProceed to publishuploadState IN_PROGRESSStill processingPoll, then proceeduploadState FAILURERejected — see itemErrorFail with the errorpublish ITEM_PENDING_REVIEWSubmittedSucceed, record the versionHTTP 401Token invalidFail: refresh token revoked
Only the first row means proceed — every other outcome should stop the pipeline with a readable message.

Cross-browser variation

  • Chrome Web Store: the API described here, with upload, publish, deployPercentage and publishTarget. Edge Add-ons has its own separate API with a different authentication model.
  • Firefox (AMO): uses web-ext sign with an API key and secret rather than OAuth; the flow is covered in signing and publishing to AMO from CI.
  • Safari / App Store: releases go through App Store Connect, usually via xcrun altool or notarytool and Fastlane, with an App Store Connect API key.
  • All three: automate the upload, keep a human approval step before publication. Automated review submission is safe; automated publication to all users without a person looking is not.

Verification

  1. Run the script against a test item first — create an unlisted item for pipeline testing so a mistake never reaches real users.
  2. Confirm the item state after a run:
1curl -s -H "Authorization: Bearer $ACCESS_TOKEN" -H "x-goog-api-version: 2" \
2  "https://www.googleapis.com/chromewebstore/v1.1/items/$CWS_ITEM_ID?projection=DRAFT" | jq '{uploadState, crxVersion}'
3# { "uploadState": "SUCCESS", "crxVersion": "2.4.1" }

Execution context: your shell or CI. crxVersion should equal the manifest version the pipeline built.

  1. Revoke the refresh token in a test project and confirm the job fails at the minting step with a clear message.
  2. Confirm in the dashboard that the submission shows the chosen rollout percentage.

FAQ

Can I automate the store listing text and screenshots?

Not through this API — it handles packages and publication. Listing content is managed in the dashboard.

Should CI publish on every merge to main?

No. Publish from a tagged release with a human approval step. Uploading drafts on every merge is fine; submitting them for review is not.

Does a service account work instead of a user refresh token?

Historically the API required a user account that owns or has access to the item. Check current documentation — support for service accounts has been evolving — but a dedicated Google account for publishing, with 2-step verification, is the robust default.

How do I increase the rollout percentage later?

Call the publish endpoint again with a higher deployPercentage once the version is live. A small scheduled job that raises it — 10%, then 50%, then 100% — after checking error rates for the new version turns a staged rollout into a routine rather than a manual chore.

Other Testing, Debugging & Performance Optimization Resources