Tested on Python 3.12, FastAPI 0.156, Uvicorn 0.54 and google-api-python-client 2.201, on Windows 10
I keep my personal to-dos in Google Tasks, and I wanted my scripts — and my AI assistant — to be able to read and write them. The official client library works, but every script would need its own OAuth flow, its own token file and its own quirks.
So I put a small FastAPI service in front of it. One OAuth token, one place to handle errors, and a plain HTTP interface with a single API key for everything else on my machine.
Writing the endpoints took an afternoon. Getting it to the point where it starts with Windows and runs unattended took a few more evenings. These are the six things that cost me time along the way.
The setup
scripts / AI assistant ──(HTTP + X-API-Key)──> google-tasks-api ──(OAuth)──> Google Tasks API
127.0.0.1:8001
| Endpoint | What it does |
|---|---|
GET/POST /tasklists |
list / create task lists |
GET/PATCH/DELETE /tasklists/{id} |
read / rename / delete a list |
GET/POST /tasklists/{id}/tasks |
list / create tasks (parent for subtasks) |
GET/PATCH/DELETE /tasklists/{id}/tasks/{task} |
read / edit / delete |
POST .../{task}/complete |
mark as done |
POST .../{task}/move |
reorder, nest under a parent, or move to another list |
GET /health |
liveness, no key required |
@default works as a list ID for the user's default list. Adding a task from any script is one request:
curl -X POST http://127.0.0.1:8001/tasklists/@default/tasks \
-H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \
-d '{"title": "Renew passport", "due": "2026-11-30"}'
The service itself is about 300 lines. The interesting part was everything around it.
1 — The API has no "due time". Only a date.
In the Google Tasks app you can set a time on a task. Through the API, you can't. The documentation says it plainly once you find it: the time portion of due is discarded when you write it, and it is never returned when you read it. A task with a time set in the app comes back from the API as midnight UTC.
If the API accepts a datetime, users will send 2026-10-22T15:00, get a 200 OK, and silently lose the 15:00. So the input model only accepts a date:
class TaskIn(BaseModel):
title: str = Field(min_length=1, max_length=1024)
due: date | None = Field(None, description="Date only: the Google Tasks API discards the time.")
def due_to_rfc3339(d: date) -> str:
return f"{d.isoformat()}T00:00:00.000Z"
Now "22/10/2026" or "tomorrow" fail with a 422 before anything reaches Google, and nobody is promised a precision the backend doesn't have. If you really need a time, that's a calendar event, not a task.
2 — "Internal" vs "External" decides whether your token dies every week
For a personal tool, the OAuth consent screen looks like bureaucracy. It isn't. With an External app in Testing status, Google issues refresh tokens that expire after 7 days. Your "unattended" service stops working every week until you click through the browser again. You also get the "Google hasn't verified this app" warning every time.
If the account belongs to a Google Workspace organization, choose Internal instead. No test-user list, no unverified-app warning, and the refresh token doesn't have the 7-day limit.
The client type matters too. It's Desktop app: the authorization flow opens a browser and receives the code on a temporary localhost port. A "Web application" client would require registered redirect URIs and doesn't fit this flow.
3 — The OAuth loopback flow has a stopwatch
The first authorization run timed out while I was still in the console. By the time I approved access, the browser landed on:
localhost:62838/?state=...&code=4/0AX...
ERR_CONNECTION_REFUSED
The approval itself had worked: the code is right there in the URL. But the tiny local server that was supposed to receive it had already given up, so the code went nowhere.
Two lessons:
- Have the console set up before you run the authorization script. Approve right away.
- If you see
ERR_CONNECTION_REFUSEDon alocalhostURL that has acode=in it, nothing is broken. Just run the authorization script again.
Then test the part everyone forgets: expiry. I edited the token file's expiry to 2020 and called the API. It answered 200, and token.json came back with a fresh expiry. The refresh path works without anyone touching it, which is the whole point of an unattended service.
4 — pythonw has no stdout, and Uvicorn's logging needs one
To start the API at login without a console window, the obvious choice on Windows is a scheduled task running pythonw.exe -m uvicorn ....
The task registered fine, started, and showed Ready a second later. Nothing was listening on the port. Last run result: 1.
pythonw gives the process no sys.stdout or sys.stderr; they're None. Uvicorn's default logging configuration creates stream handlers on them and fails during startup, before the app ever loads. You can reproduce it in one line:
import sys; sys.stdout = sys.stderr = None
import logging.config, uvicorn.config
logging.config.dictConfig(uvicorn.config.LOGGING_CONFIG) # exits with an error
The fix is a tiny launcher that gives the process somewhere to write. As a bonus, you finally get a log file:
# scripts/serve.py
sys.stdout = sys.stderr = open(log_dir / "api.log", "a", encoding="utf-8", buffering=1)
uvicorn.run("app.main:app", host=settings.host, port=settings.port)
The scheduled task now runs pythonw -m scripts.serve, with no --reload. Reload belongs to development, not to a service.
5 — "Protect every route" also protected /health, and still left /docs open
My first version added the API key check at the application level:
app = FastAPI(dependencies=[Depends(require_api_key)])
That looked like the safe default. A test showed two surprises:
-
/healthrequired the key. Any monitor would need a secret just to ask "are you alive?". The health endpoint returns{"status": "ok"}: no data, no Google call, no token check. Leaving it open costs nothing. -
/docsand/openapi.jsondid not require the key. FastAPI serves the documentation routes outside your dependencies. Anyone who can reach the port sees the full map of the API, even though they can't call it.
So the key moved to the routers that hold data, and /health stays deliberately open:
app.include_router(tasklists.router, dependencies=[Depends(require_api_key)])
app.include_router(tasks.router, dependencies=[Depends(require_api_key)])
While the service listens on 127.0.0.1 only, an open /docs is acceptable. The day it moves to a home lab server, /docs gets protected or disabled, and HTTPS goes in front. That's written down as a checklist item, not left to memory.
The security review before publishing found one more thing: the check itself failed open. With API_KEY empty, it simply returned, so anyone who cloned the repo and forgot the .env exposed their whole Google Tasks. Now it fails closed:
def require_api_key(key: str | None = Security(_header)) -> None:
if not settings.api_key:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "API_KEY is not configured in .env")
if not key or not secrets.compare_digest(key, settings.api_key):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid X-API-Key")
A missing configuration should stop the service, not open it.
6 — The real API disagrees with your mocks in small ways
The test suite has 31 tests with the Google client mocked: every route, plus 401, 404, 422 and 503 paths. One thing helped a lot: a fresh mock per test. My first version shared a single MagicMock across tests, so one test's side_effect could leak into the next.
@pytest.fixture(autouse=True)
def svc(monkeypatch):
mock = MagicMock()
app.dependency_overrides[tasks_service] = lambda: mock
monkeypatch.setattr(settings, "api_key", KEY)
yield mock
app.dependency_overrides.clear()
Then I ran the same scenarios against my real account, on a throwaway list deleted at the end. The real API had two behaviors the mocks didn't:
-
A malformed task ID returns
400, not404. The service just forwards Google's status, so the client sees exactly what Google said. My mock had assumed404. -
Moving a parent task to another list takes its subtasks with it. My next call tried to edit a subtask in the old list and got a
404. That's correct behavior, but you only learn it by trying.
Mocks prove that your code calls the API the way you think it does. Only the real API tells you what that call actually does.
Takeaways
- Model what the backend can store, not what users wish it stored: a date is a
date. - On Workspace, an Internal consent screen is the difference between a service and a weekly chore.
- If the OAuth redirect hits
ERR_CONNECTION_REFUSEDwith acode=in the URL, the local listener timed out. Run the authorization again. - Windows +
pythonw+ Uvicorn needs a real stdout. Give it a log file. - Put auth on the routers that hold data, keep
/healthopen, and remember that/docsis open by default. - Make the auth check fail closed: no key configured means
503, not an open API. - Run your scenarios once against the real API. That's where the
400instead of404shows up.
The code is on GitHub: manoelmartins1980/google-tasks-api (MIT, with installation and operations guides). My AI assistant already uses it through a small CLI: it reads my lists freely and only adds or completes tasks after I confirm.
Top comments (0)