I was reading a developer's post on the Gemini API forum. Billing was enabled. The Generative Language API was enabled. The key was valid. Listing models came back with 200. And yet every generation request returned 403 with the code permission_denied and the message "Your project has been denied access. Please contact support."
The thread had no resolution.
My stomach dropped a little as I finished it. If this happened on one of my own projects, I suspect I would spend the first hour regenerating keys, wrapping calls in retries, and bouncing between settings pages. As an indie developer calling the Gemini API from my own apps, I decided it was better to write down the triage order before the symptom shows up.
This article is that order. I should say up front that I have not hit this exact 403 myself. What follows is assembled from the official docs and the forum report.
First, the thing I most want to say: don't retry
The official troubleshooting page says not to retry client errors such as 403. The reasons it lists are an invalid API key, depleted Prepay credits, or a malformed request — problems that waiting will not fix. Retries are for transient errors like 429 and 5xx.
The error reference describes permission_denied as "Your API key does not have permission for this resource," with the suggested action to check key permissions and project access. The fix lives outside your code.
| HTTP | Code | Fixed by waiting? | Look here first |
|---|---|---|---|
| 400 | failed_precondition | No | Is billing disabled? |
| 402 | payment_required | No | Prepay balance |
| 403 | permission_denied | No | Key permissions, project status |
| 429 | rate_limit_exceeded | Yes | Back off and retry |
| 503 | service_unavailable | Yes | Back off and retry |
For the 429 side, I wrote a separate piece: Don't Retry Every Gemini 429 — Telling Rate Limits Apart From Spend Cap Exhaustion.
A 200 from /models does not prove generation will work
A successful model listing only tells you that the key is recognized and the project exists. Generation goes through a separate decision for that project. So "the list came back, so the key must be fine" is a conclusion drawn too early.
The order I settled on has three steps:
- Key: Is it a key for the project you think it is? Does it carry restrictions such as an HTTP referrer limit?
- Project: What state does the project show on the AI Studio Projects page?
- Billing: Is it linked to a billing account? If you use Prepay, is there a balance?
If the key is the culprit, Why HTTP Referrer Restrictions on Your Gemini API Key Cause 403 Errors in Production covers it. That one is a merciful 403: change a setting and it goes away.
The harder case is when the key and billing look fine from where you stand, yet the project itself is being refused. The forum post had exactly that shape.
A small script that checks the three steps mechanically
Doing this by hand, I change my mind midway. So I wrote a script that calls the model list and a tiny generation with the same key and prints the results side by side. Pass several keys and they line up in one table.
The goal is a single sheet showing which key, which endpoint, which status. It doubles as the material you hand to support. I have only syntax-checked it here and have not run it against a live key, so treat the model ID as something to adjust.
import json
import os
import sys
import urllib.error
import urllib.request
BASE = "https://generativelanguage.googleapis.com/v1beta"
# Replace as needed. Pass the model ID you want to test via the environment.
MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.8-flash")
def call(method, path, key, body=None):
"""Call once. No retries (a 403 will not be fixed by waiting)."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
f"{BASE}{path}",
data=data,
method=method,
headers={"x-goog-api-key": key, "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=30) as res:
return res.status, ""
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", "replace")
try:
err = json.loads(raw).get("error", {})
return e.code, f'{err.get("status", "")} {err.get("message", "")}'[:120]
except json.JSONDecodeError:
return e.code, raw[:120]
def probe(label, key):
models = call("GET", "/models?pageSize=1", key)
gen = call(
"POST",
f"/models/{MODEL}:generateContent",
key,
{"contents": [{"parts": [{"text": "ping"}]}],
"generationConfig": {"maxOutputTokens": 8}},
)
print(f"{label:<14} models={models[0]} generate={gen[0]} {gen[1]}")
if __name__ == "__main__":
# Usage: python probe.py KEY_A_ENV KEY_B_ENV
# Arguments are the NAMES of environment variables holding keys, not the keys.
for name in sys.argv[1:]:
value = os.environ.get(name)
if not value:
print(f"{name:<14} environment variable is empty")
continue
probe(name, value)I pass environment variable names instead of the keys themselves so nothing sensitive lands in shell history. The generation request caps maxOutputTokens at 8, so the cost of the check stays tiny.
There are three ways to read the output:
| models | generate | What it tells you |
|---|---|---|
| 200 | 200 | This key works. Look at your calling code or the model ID |
| 200 | 403 | Key recognized, generation refused. Walk the three steps |
| 403 | 403 | The key itself is refused. It may have been disabled after a leak |
A key that has been exposed publicly can be blocked. The message in that case is "Your API key was reported as leaked. Please use another API key," and the remedy is to issue a new key in AI Studio.
Put a second project's key next to it
The step I find most useful comes last: line up one more key from a different project.
Give the script both the key from a project that works and the key that fails. If the first shows generate=200 and the second generate=403, it is not your code, the model ID, or the network. The problem sits with the failing project. Asking support after narrowing it this far, rather than before, probably changes how quickly you get an answer.
The billing structure also feeds into this. According to the official billing page, tiers and limits are set per billing account, and a project inherits the tier of the account it is linked to. A key has no billing settings of its own and inherits its project's status. When a Prepay balance hits $0, every key in every project on that billing account stops at once.
That gives a rule of thumb: if only one key is down, suspect the project; if everything is down, suspect the balance or the billing account.
Billing changes take time to show
One more caution applies right after you enable billing. The docs say billing signals and updates are not always real-time. Tier upgrades usually show within about ten minutes, and some payment methods, such as bank transfers, can take days.
If you get a 403 straight after changing a setting, don't conclude anything yet. But if more than ten-odd minutes pass with no change, and the AI Studio Billing and Projects pages show no prompt such as "Set up billing" or "No credits," you have moved past a settings problem and into suspecting an administrative restriction on the project.
The forum's guidance for paid tiers was to check the Projects and Billing pages for warning banners and appeal links, and, if the project or billing account is suspended, to file an appeal through the Google Cloud Console. Also check the inbox tied to the project.
What to hand to support
Once you have narrowed it down, bundle these into one message:
- The failing and succeeding endpoints (models 200, generateContent 403)
- The error body,
status, andcode, verbatim - The script output (key names only, never the keys)
- The fact that a key from another project succeeds
- When you enabled billing, and the state the Billing page shows
In the forum post, not having a support plan meant the author could not reach Cloud Support, which was part of why they got stuck. The remaining routes are a public forum question and the guidance in AI Studio, so putting every fact into the first message ends up being the shortest path.
The line I drew
The one line that stays with me from this exercise:
When I see a 403, I line up two keys before I touch any code.
Retries and regenerating keys come after. I plan to keep this script as a small check command next to the Prepay-funded parts of my apps, such as image classification for wallpapers, so that on the night something stops I am not working out the order with tired hands.
As a first step, run the script once against your own project's key and confirm that both models and generate return 200. Keep that output from a day when nothing is broken; it makes the bad day much easier to compare against.