---
title: "How we build a Google Workspace add-on"
description: "We build each add-on on Apps Script and CardService with the narrowest scope that does the job, then take it through consent, verification and review. Approval proves little: what kept our add-ons working was testing on a clean account, testing what they write, and counting people rather than seats."
date: 2026-10-11
lang: en
canonical: https://goingstellar.ro/methods/how-we-build-a-google-workspace-add-on/
author: Going Stellar
publisher: GOING STELLAR S.R.L.
topic: Apps / Google Workspace
license: CC BY 4.0
alternate_ro: https://goingstellar.ro/ro/metode/cum-construim-un-add-on-google-workspace/
---

# How we build a Google Workspace add-on

*We build each add-on on Apps Script and CardService with the narrowest scope that does the job, then take it through consent, verification and review. Approval proves little: what kept our add-ons working was testing on a clean account, testing what they write, and counting people rather than seats.*

> Apps · Google Workspace · 2026-10-11 · https://goingstellar.ro/methods/how-we-build-a-google-workspace-add-on/

## Key points

- For a Google Workspace add-on, the scope family decides the review tier, not the number of scopes, so the classification should be read in the first hour of a build.
- Under a currentonly scope an add-on may read the open file's content but not its Drive metadata, a difference that is invisible in the code and fatal at runtime.
- A Marketplace install grants the add-on's scopes, but Apps Script separately requires the script to be authorised, and the resulting error blames the first service call attempted.
- Marketplace approval checks a listing and a video, not whether a stranger can use the add-on, so the real test is an install from the store page on a fresh profile with an account that has never seen it.
- The public user count on the Google Workspace Marketplace counts domain seats, so seats and people who installed the add-on themselves must be read separately.

## What this is for

We build small add-ons for Google Docs, Sheets, Slides and Gmail: a side panel that does one job on the open file. By early September we had built sixteen. This is our method from the first line of code to a live listing, and the measurements after it.

Most of it was learned after approval. Approval checks a listing and a video, not whether a stranger can use the product or whether it leaves their data alone.

## Build: Apps Script, CardService and the narrowest scope

**1. Apps Script with CardService.** The panel is built from CardService widgets, not from HtmlService. The HTML route needs `script.container.ui`, which is a sensitive scope. The native panel is narrow and plain, and we accept that.

**2. Choose the scope family before the feature.** Google has three tiers. Non-sensitive scopes, such as `documents.currentonly`, need no OAuth verification. Sensitive ones, such as `gmail.send`, need a verification round. Restricted ones, which read a whole mailbox or Drive, add an external security assessment repeated every year. The family decides the tier, not the count: one of ours carries three `currentonly` scopes and went live without verification. We read the classification in the first hour of a build.

**3. Know what the narrow scope does not give you.** Under `*.currentonly` the file's content is allowed, its Drive metadata is not. `Document.getName()` throws; `Sheet.getName()`, a tab name, works. The difference is invisible in the code and fatal at runtime.

**4. No backend.** Licences are signed keys checked inside the add-on and exception logging is off, so nothing about the user leaves their account. Our only view of usage is Apps Script's execution log.

## Consent screen, verification and listing

Use a standard Google Cloud project; the default one Apps Script creates cannot publish. Set the consent screen to *In production*: one add-on was rejected for that alone, because on *Testing* the reviewer got *error 403: access denied*. Our submission tool now refuses while the status is Testing.

Scopes must be identical in three places: the manifest, the Marketplace SDK configuration and the consent screen. The SDK keeps its own hand-typed list. The name must match in four: the listing, its web page, the consent screen and the SDK listing.

For a sensitive scope the order is fixed: branding verified and published, then OAuth verification, then Marketplace submission. A verified brand lapses after 7 days unless you also press *Publish branding*, and renaming the consent screen silently resets it.

In the listing, every Google mark carries ™, including Gmail, Calendar and Drive. Keep the short description to about 100 characters. Upload more than one screenshot at 1280×800; we were flagged for having one. Do not promise one permission: the install screen shows three lines, because Google attaches email and basic profile to every Marketplace add-on.

## The review

After submission an email asks for three things: test credentials, an end-to-end screen recording showing how each requested scope is used, and verification for sensitive scopes. We reply in the thread: no credentials, because there is no login; an unlisted video; how the scope is used; and a licence key for the paid features.

In our first round verdicts came in 3 to 8 hours. Of 9 add-ons submitted on 18 and 19 August, 4 were approved, 2 rejected and 3 still in review. Both rejections were about form: a missing ™ and a consent screen that did not match the listing.

The rejection email arrives about 40 minutes before the console changes, so wait. While a draft is in review the SDK configuration is silently frozen: Save succeeds and the change is gone after a reload. A code fix needs no new review: a new version plus an update to the published deployment reaches users at once. Pushing code alone changes only the head version, which users never run.

## The trap: installing from the store does not authorise the script

A Marketplace install grants the add-on's scopes; Apps Script separately requires the script to be authorised. While that is missing every service call is refused, and the error names the first call attempted, so it blames `DocumentApp` even when that scope is granted. Our rule that the panel must never crash made it worse: a `try/catch` swallowed the exception Google uses to draw the authorise prompt.

Our fix went through four versions in 19 days. The first passed the script's authorisation URL with no account hint, so people signed into several Google accounts landed on the script file, and we received 12 requests for access to our source code from 7 users across 5 add-ons in two days. The second left the URL out and the platform refused the card: a dead end on all 16 add-ons for 17 days. The third added the user's address, which is empty outside our domain. The fourth throws nothing for Google's own scopes; the documentation says that exception is meant for third-party services.

No test caught the three bad versions: each test browser had one signed-in account, and the mock drew a button regardless. The inbox caught them. We now install through the real store page on a fresh browser profile, with an account that has never seen the add-on, and check the effect on the document. That test showed a new user was never affected: the refusal hit existing grants after we changed scopes. Any scope change now requires a fresh authorisation before a fix counts as done.

## Test the writes for real

Four times a bug reached users because our mock was kinder than the platform. It accepted a trigger interval under one hour, a method that exists only on another widget (a reviewer saw *Add-on error*), and `getName()`. That last one hid a dead add-on: Outline had 13 installs and zero opens in 10 days, because one unused field threw on every document.

Writes were worse, because a mock with no state cannot see them. Change Case read cells with `getValues()`, which returns a formula's result, and wrote the block back with `setValues()`, replacing formulas with results. The panel said those cells were left alone; Undo reported three restored cells and restored nothing that mattered. The mocked `setValues` did nothing. In Alphabetize, 53 checks were green while the sort path had never run, because the check accepted any non-empty message, including the error.

The rules now: the mock throws where the platform throws, with a method whitelist per widget; anything that writes is tested against a stateful model of the destination; assertions check the shape of the success message; and every fix is proved by mutation, putting the bug back and watching the suite fail. Verification ends in a real document, not in the panel.

## Measuring usage, and seats against people

The public user count counts domain seats: when an administrator installs for an organisation, every employee is added. On 31 August our public total was 2,209; the console's *Individual End User Installs* chart, which counts people who installed it themselves, gave 434. Change Case showed 1,892 users: 2 organisations, 1,875 seats and 32 people. Our ranking was upside down.

Usage comes from the Apps Script executions API, which keeps about 10 days of history, not 30, so our 30-day label turned ‘not this week’ into ‘never’. Functions the report did not recognise looked like buttons nobody pressed. The tool now prints the window it covered and reads button handlers from the published code.

We split ‘installs but no use’ into three cases: never opened, opened but nothing done, used but not paid. Only the third is about the offer. And before trusting a zero, prove the instrument can see a one: open the add-on yourself and check your execution appears.

## Where we were wrong

We first concluded that every published add-on was unusable for everyone. The clean-account test and a five-star review refuted it: the failure hit a subset.

We reported the account-hint fix as done when it existed only on our machine. The next day brought six more access requests from five people on four add-ons, none from the one add-on we had actually deployed. Nothing is fixed until the served code is checked.

Our only one-star review said an add-on ‘doesn't even work’. The empty panel told the person to press Sort; the only button on screen was Refresh, because a side panel cannot see a selection made after it opened. They were right. The main action now exists in the empty state.

We also declared an add-on invisible after a correct install. It sat in the *More Add-ons* overflow; one screenshot settled it.

## Limits, and a checklist

This method gets an add-on approved and keeps it honest. It does not make people use it: why many installs never become use is still open for us. Consoles also change under automation without notice, so every saved setting is confirmed by a reload.

**Checklist.** Scope tier read in the first hour. No file metadata under `currentonly`. Consent screen in production, branding verified and published. Scopes identical in three places, name in four. ™ on every Google mark. Clean-profile install with an account that has never seen the add-on. Stateful mocks that throw like the platform, mutation-tested. Fix checked on the served version. Seats and people read separately, and every zero checked against your own execution.

## Questions and answers

### Which OAuth scopes need verification for a Google Workspace add-on?

OAuth verification for a Google Workspace add-on depends on the scope tier. Non-sensitive scopes, such as documents.currentonly, need no OAuth verification. Sensitive scopes, such as gmail.send, need a verification round. Restricted scopes, which read a whole mailbox or Drive, add an external security assessment repeated every year. The scope family decides the tier, not the number of scopes, so an add-on with several currentonly scopes can go live without verification.

### Why was my Google Workspace add-on rejected in Marketplace review?

Google Workspace add-on rejections are often about form rather than code. Common causes include a consent screen left on Testing, which gives the reviewer an access-denied error, a missing trademark symbol on a Google mark, and a consent screen name that does not match the listing. Scopes must be identical in the manifest, the Marketplace SDK configuration and the consent screen, and the name must match everywhere the listing appears.

### Why does my Apps Script add-on fail with a permission error after installing it from the Marketplace?

An Apps Script add-on can fail after a Marketplace install because the install grants the add-on's scopes while Apps Script separately requires the script itself to be authorised. Until that happens every service call is refused, and the error names the first call attempted, even if that scope was granted. A try/catch that swallows the exception also hides the authorise prompt. Any scope change needs a fresh authorisation test.

### How should I test a Google Workspace add-on before release?

Testing a Google Workspace add-on should end in a real document, not in the panel. Install it through the real store page on a fresh browser profile, with an account that has never seen the add-on. Mocks should throw where the platform throws, using a method whitelist per widget, and anything that writes should be tested against a stateful model of the destination. Every fix should be proved by mutation.

### Does the user count on a Google Workspace Marketplace listing show how many people use an add-on?

The user count on a Google Workspace Marketplace listing does not show how many people use an add-on. It counts domain seats, so when an administrator installs for an organisation, every employee is added. The console chart for individual end-user installs counts people who installed it themselves, and usage comes from the Apps Script execution log, which keeps only a short history. Seats and people should be read separately.

## How to cite

**Cite this as:** Going Stellar (2026). *How we build a Google Workspace add-on*. GOING STELLAR S.R.L.. https://goingstellar.ro/methods/how-we-build-a-google-workspace-add-on/

```bibtex
@misc{goingstellar2026how,
  title  = {How we build a Google Workspace add-on},
  author = {{Going Stellar}},
  year   = {2026},
  url    = {https://goingstellar.ro/methods/how-we-build-a-google-workspace-add-on/},
  note   = {2026-10-11}
}
```

Licence. Free to quote with attribution.

## Also available

- This page as HTML: https://goingstellar.ro/methods/how-we-build-a-google-workspace-add-on/
- Same article in Romanian: https://goingstellar.ro/ro/metode/cum-construim-un-add-on-google-workspace/
