You issued a fresh API key, and the same error comes back. You recreated the key, recreated the project, checked billing, and API key expired. Please renew the API key. is still there.
There is an issue like that in the gemini-cli repository on GitHub, opened in April 2026. Running an agent through ADK failed every time with this error. A maintainer replied that the key had probably expired, and the reporter answered that a brand-new key in a brand-new project behaved the same. About a week later the reporter wrote "resolved for me" and closed it. The cause was never written down.
I can't recover a cause nobody recorded, so I checked what I could check by hand. I called the real endpoint with dummy keys, and I read the google-genai SDK source (version 2.33.0) to follow where a key comes from and how it gets sent. The short version: I found no way to read "expired" out of the response. What I did end up with is a one-minute check to run before you renew anything.
The response doesn't separate "expired" from "just not a valid key"
The response pasted in the issue was an HTTP 400. Not 401, not 403, but INVALID_ARGUMENT, with reason: API_KEY_INVALID inside the ErrorInfo detail.
The same shape came back for a made-up dummy key. Only the message changed, to API key not valid. Please pass a valid API key. I varied the key's form and sent hi to gemini-3.8-flash:
| Key sent | Result |
|---|---|
| Made-up key, as is | 400 INVALID_ARGUMENT, reason API_KEY_INVALID |
| Leading/trailing space | Same |
| Trailing newline (LF, CRLF) | Same |
| Wrapped in quotes | Same |
| Three characters | Same |
| Empty string or unset | ValueError locally, before any request |
reason only says "this key is not accepted." Expiry, revocation, a truncated copy, and a different key slipping in all look identical here. Don't try to guess the cause from the response; rule out what you can see on your own machine first. That is the order I settled on.
If you decide "it expired" and regenerate, a cause that lives on your side will reject the new key in exactly the same way.
When a new key doesn't help: two environment variables
The SDK source (_api_client.py) has a function that reads the key from the environment. Its docstring says it prioritizes GOOGLE_API_KEY, so when both variables are set, GEMINI_API_KEY is never read.
I tested it with two dummy keys: one run with the new key only in GEMINI_API_KEY, and one with the old key still sitting in GOOGLE_API_KEY. The first used the new key; the second used the old one. In the second case a single warning line appears, Both GOOGLE_API_KEY and GEMINI_API_KEY are set. Using GOOGLE_API_KEY., but it is one WARNING among everything else a server or an ADK run prints, and I can see it slipping past.
As an indie developer, I tend to leave keys in both my shell profile and each project's .env. In that setup the older one wins. You can paste a new key into .env and still send the stale GOOGLE_API_KEY from the shell.
I can't say whether the issue's reporter hit this, since the cause was never written down. But as a candidate for "a new key doesn't fix it," it is the first thing worth ruling out. I understand ADK to use google-genai underneath, though I did not run ADK itself for this. A companion piece written the same day approaches the same error from the ADK side (My ADK Agent Never Ran and Said 'API key expired' — Reading the Reason Code Gave Me a Triage Order); that one works from the official pages and the issue, while this one rests on runs I did by hand.
Whitespace, newlines, quotes: what the SDK fixes for you
Client(api_key="AIzaNL\n") held 'AIzaNL'. The source has the line self.api_key = self.api_key.strip(). Leading and trailing whitespace is not a cause with this SDK version.
Quotes are not removed, though. Depending on how your .env is loaded, KEY="AIza..." can arrive with the quotes inside the value. And the stripping is specific to this SDK at this version; if you hand the same value to curl or a client in another language, you have to check that yourself.
A diagnostic script: compare fingerprints, never print the key
This is a script of about 70 lines to run before you regenerate a key. It never prints the key; it shows the first 8 hex digits of a SHA-256 hash. It lines up the two environment variables and the key actually used, then calls models.list for a single item to reach a verdict. models.list involves no generation, so a check costs no tokens.
"""Gemini API key check: see which key is actually sent before you renew anything."""
import hashlib
import os
import sys
from google import genai
from google.genai import errors
def fingerprint(key: str | None) -> str:
"""Never print the key itself; compare identity by an 8-char hash prefix."""
if not key:
return "(not set)"
return "sha256:" + hashlib.sha256(key.encode()).hexdigest()[:8]
def shape_problems(key: str) -> list[str]:
"""Problems visible before any request, including ones the SDK quietly fixes."""
problems = []
if key != key.strip():
problems.append("leading/trailing whitespace or newline (this SDK strips it; other clients may not)")
if key.strip().startswith(('"', "'")) or key.strip().endswith(('"', "'")):
problems.append("quote characters left in the value (depends on how your .env is loaded)")
if not key.isascii():
problems.append("non-ASCII characters (full-width or invisible characters from copy/paste)")
return problems
def reason_of(e: errors.APIError) -> str | None:
"""e.details is the whole response body (a dict), not the inner details list."""
body = e.details if isinstance(e.details, dict) else {}
for d in body.get("error", {}).get("details", []):
if d.get("@type", "").endswith("google.rpc.ErrorInfo"):
return d.get("reason")
return None
def classify(e: errors.APIError) -> str:
reason = reason_of(e)
if reason == "API_KEY_INVALID":
return "key" # do not retry; suspect the key being sent
if e.code == 429:
return "quota" # wait, or revisit limits
if e.code >= 500:
return "transient" # safe to retry with backoff
return "other" # other 4xx: fix the request itself
def main() -> int:
google_k, gemini_k = os.environ.get("GOOGLE_API_KEY"), os.environ.get("GEMINI_API_KEY")
print(f"GOOGLE_API_KEY : {fingerprint(google_k)}")
print(f"GEMINI_API_KEY : {fingerprint(gemini_k)}")
if google_k and gemini_k and google_k != gemini_k:
print("-> The two variables differ. The SDK uses GOOGLE_API_KEY.")
client = genai.Client() # no arguments = same resolution order as production
print(f"Key actually used: {fingerprint(client._api_client.api_key)}")
for name, raw in (("GOOGLE_API_KEY", google_k), ("GEMINI_API_KEY", gemini_k)):
for p in shape_problems(raw or "x"):
print(f"Shape problem ({name}):", p)
try:
next(iter(client.models.list(config={"page_size": 1}))) # no generation involved
except errors.APIError as e:
print(f"Verdict: {classify(e)} / HTTP {e.code} {e.status} / reason={reason_of(e)}")
return 1
print("Verdict: ok (models.list succeeded with this key)")
return 0
if __name__ == "__main__":
sys.exit(main())Here is what it printed with dummy keys. First, the old key left in GOOGLE_API_KEY while the new one went into GEMINI_API_KEY:
GOOGLE_API_KEY : sha256:40b5137b
GEMINI_API_KEY : sha256:2725a3cf
-> The two variables differ. The SDK uses GOOGLE_API_KEY.
Key actually used: sha256:40b5137b
Verdict: key / HTTP 400 INVALID_ARGUMENT / reason=API_KEY_INVALIDThe fingerprint of "Key actually used" matches GOOGLE_API_KEY. You thought you had switched keys, and this shows the old one is still the one being sent.
Next, a trailing space left in GEMINI_API_KEY:
GOOGLE_API_KEY : (not set)
GEMINI_API_KEY : sha256:7d3158e4
Key actually used: sha256:a6d9ddc3
Shape problem (GEMINI_API_KEY): leading/trailing whitespace or newline (this SDK strips it; other clients may not)
Verdict: key / HTTP 400 INVALID_ARGUMENT / reason=API_KEY_INVALIDThe environment variable's fingerprint and the "actually used" fingerprint differ. That means the SDK repaired the value locally before sending it, and the gap itself is the signal that something extra is attached to the value.
Branch on reason, not on the HTTP status
Two traps for when you wire this into error handling.
The first is what e.details holds. The name suggests a list of details, but it is the whole response body as a dict, with a single top-level key, error. Writing e.details[0] never reaches the ErrorInfo you want. Walk error, then details, then ErrorInfo, as reason_of does.
The second is the retry decision. A 400 is usually "your request is wrong," so leaning toward no retry is sensible, but API_KEY_INVALID is about the key being sent, not the request body. In an unattended job such as a nightly batch, splitting on reason lets you route a revoked key to "stop and notify." I wrote about splitting retryable failures in The Nightly Batch Sat Stalled Until Morning — Splitting Retryable Failures by gemini-api Exit Code and Classify Gemini API Errors by Status — Handling 429, SAFETY, and Token Limits in Production. For the other "check it the day you notice" kind of setting, see New paid projects now start with request storage switched on. Three places to check the day you notice.
What I haven't verified
Since I'm writing this down, I should say where it stops.
- A successful call with a valid key. I don't keep a live key on this machine, so the
okbranch has never run. It is my reading of the code. - The response for a genuinely expired key. I couldn't reproduce it. The word "expired" above is quoted from the issue.
- SDK version. I looked only at 2.33.0. Precedence and stripping may change between versions.
- Anything through ADK. I did not run it.
The next time I see this error, I'll run keycheck.py before I renew the key. If the fingerprints disagree, the thing to fix is the environment, not the key.