HeySales lets a user connect their Google account so it can read and send Gmail, import contacts and add meetings to their calendar. HeySales runs on Cloudflare Workers and is built with vinext. Every attempt to connect a Google account was failing. The fix went live on 23 October 2026. Here is what went wrong, how we fixed it, and what to check if you call Google from a Worker.
The symptom
The user went through Google's consent screen and came back to HeySales. The server then swapped the authorisation code for tokens, and that step failed with "Failed to get access token from Google".
The code at that point used Google's official Node client, googleapis, at version 162.
const oauth2Client = new google.auth.OAuth2(clientId, clientSecret, redirectUri);
const { tokens } = await oauth2Client.getToken(input.code);
if (!tokens.access_token) {
throw new Error("Failed to get access token from Google");
}
Finding the cause
We built the main branch, ran it under local wrangler dev, and sent a made-up code to Google's real token endpoint. A bad code should come back as a readable error from Google. Instead, the message was "Google OAuth error: " followed by binary bytes starting 1f 8b. Those two bytes are the gzip magic number.
Google compresses its JSON replies. googleapis makes its requests through a library called gaxios, and in our vinext bundle gaxios used node-fetch on top of node:https. That path handed the compressed body back as it was. With a valid code the success reply suffered the same fate, so the parsed tokens had no access_token and the user saw the error above.
A minimal Worker bundled with wrangler's own esbuild and the same googleapis version decoded the replies correctly. The fault lay in how our build bundled it. We could have chased that into the bundler, but every Google call in HeySales went through the same HTTP client, so Gmail, People and Calendar would have failed in the same way. We chose to remove the dependency.
Why plain fetch works
A Worker's global fetch is the runtime's own HTTP client. It reads the Content-Encoding header and gives your code the decoded body, so response.json() sees plain JSON. Google's APIs are ordinary REST endpoints. The calls we make need a bearer token, some query parameters and a JSON body.
We wrote one module, src/server/lib/google.ts, of about 330 lines. It builds the consent URL, exchanges and refreshes tokens, and covers the calls we use. Those are Gmail messages and attachments, People connections and Calendar events. The token request is a form-encoded POST.
async function tokenRequest(params: Record<string, string>) {
const { clientId, clientSecret } = credentials();
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
...params,
}),
});
if (!response.ok) {
throw new GoogleApiError(await errorMessage(response), response.status);
}
return (await response.json()) as GoogleTokenResponse;
}
Exchanging a code passes grant_type: "authorization_code" with the code and redirect URI. A refresh passes grant_type: "refresh_token". Every failure becomes a GoogleApiError whose code is the HTTP status. googleapis errors carried the status in the same field, so our routers' handling of 401 and 403 kept working unchanged.
One detail did change. Google's token endpoint returns expires_in in seconds. googleapis turned that into an absolute expiry_date for us, so we now work it out when we save the tokens.
Removing googleapis also took gaxios, google-auth-library, googleapis-common and node-fetch out of our lockfile.
Refreshing tokens and saving them
The old code had a quieter problem too. It loaded the stored tokens into the OAuth2 client with setCredentials. When the access token had expired, googleapis refreshed it in memory, and nothing wrote the new token back. The database kept the expired one.
Each call now goes through googleApi(db, user). It refreshes five minutes before the stored expiry, the same margin googleapis used. If Google rejects a token with a 401 anyway, it refreshes once and retries.
let response = await send(await currentToken());
if (response.status === 401 && refreshToken) {
response = await send(await refresh());
}
if (!response.ok) {
throw new GoogleApiError(await errorMessage(response), response.status);
}
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
The 204 check is for Calendar deletes, which return no body. The refresh writes straight back to the user's row.
accessToken = tokens.access_token;
expiry = tokens.expires_in
? new Date(Date.now() + tokens.expires_in * 1000)
: null;
refreshToken = tokens.refresh_token ?? refreshToken;
await db
.update(schema.users)
.set({
googleAccessToken: accessToken,
googleRefreshToken: refreshToken,
googleTokenExpiry: expiry,
})
.where(eq(schema.users.id, user.id));
A refresh reply may not include a new refresh token, so we keep the old one when it is missing. We treat a 4xx from the refresh as a revoked or expired refresh token and report it as a 401. Callers already turn a 401 into "Google authentication expired. Please reconnect your account."
How we tested it
We wrote a small fake Google in Node. It serves the token, Gmail, People and Calendar endpoints and gzips every JSON reply, as Google does. We copied the built Worker bundle, pointed only the Google base URLs at the fake, and ran a script of 54 checks against a fresh local D1 database. They cover the OAuth state checks, a bad code, the stored tokens, contacts, Gmail read, sync and send, calendar events, refresh on expiry, refresh after a 401, and a revoked token. All 54 passed.
HeySales sets Cloudflare's global_fetch_strictly_public flag. In local testing it stopped the Worker reaching the fake server on 127.0.0.1, so we removed it from the test copy only.
A checklist for Google APIs on Workers
- Run your OAuth flow in the Workers runtime with wrangler dev before you ship. A library that passes in Node can still fail once bundled for Workers.
- Send a made-up code to the real token endpoint. Bytes starting
1f 8bin the message mean a gzipped reply is not being decoded. - Use the global
fetchfor Google's REST APIs. It decodes gzip for you. - Convert
expires_into an absolute time when you save tokens. - Save refreshed tokens, and keep the old refresh token when the reply has none.
- Refresh a few minutes before expiry, and once more after a 401. Treat a failed refresh as "please reconnect".
- Keep the HTTP status on your errors, so callers can tell expired access (401) from missing permissions or a disabled API (403).
- Test against a fake server that gzips its replies, so you exercise the same decoding path as production.
- Make no network calls at module top level, which Workers don't allow.