{"slug": "i-stopped-blaming-gpt-5-for-my-ads-api-401-oauth-mess-and-fixed-the-4-auth-that", "title": "I stopped blaming GPT-5 for my Ads API 401 OAuth mess and fixed the 4 auth states that were actually broken", "summary": "A developer traced recurring 2 a.m. failures in AI-driven ad automation — 401 errors, invalid_grant responses, and broken Make and Meta syncs — to OAuth misconfiguration rather than the model layer, identifying four broken auth states. The writeup notes that Google Ads API requests often require a login-customer-id header for manager-account access, that Google refresh tokens can expire in 7 days when an External OAuth consent screen is left in Testing mode, and that unattended Meta Marketing API jobs should use system user tokens instead of short-lived user tokens. The developer recommends treating auth failures as state transitions rather than transient errors, since blind retries only burn time when credentials are invalid.", "body_md": "I lost a stupid amount of time blaming GPT-5 for an automation failure that had nothing to do with GPT-5.\n\nThe setup looked normal enough:\n\nInstead, the job kept dying around 2 a.m. with a 401.\n\nThen a Make scenario started failing on a Meta sync.\n\nThen a Python worker started returning `invalid_grant`.\n\nThen I did what a lot of us do when an \"AI workflow\" breaks:\n\nI blamed the model layer.\n\nMaybe GPT-5 was acting weird.\n\nMaybe Claude had generated a bad payload.\n\nMaybe the prompt had drifted.\n\nMaybe my routing logic was flaky.\n\nNope.\n\nThe real problem was much more boring: auth.\n\nAnd honestly, that's useful news, because auth failures are easier to fix than model behavior.\n\nIf you're running AI agents or automations against ad platforms, here's the thing I wish I'd internalized earlier:\n\n**Most Ads API 401s happen before your model, agent, or prompt ever gets a turn.**\n\nFor Google Ads API, a valid access token is only part of the story.\n\nYou usually need all of these lined up at the same time:\n\n`login-customer-id` when going through a manager account\nMiss one, and your \"AI failure\" is really just a request getting rejected at the gate.\n\nThat `login-customer-id` detail is especially good at wasting afternoons.\n\nIf the authenticated identity is accessing a client account through a Google Ads manager account, you often need to send the manager account's 10-digit customer ID as `login-customer-id`.\n\nWithout it, the token can be valid and the request can still fail.\n\nWhen I suspect a dead credential, I test refresh outside the app first:\n\n```\ncurl -s -X POST https://oauth2.googleapis.com/token \\\n  -d client_id=\"$GOOGLE_CLIENT_ID\" \\\n  -d client_secret=\"$GOOGLE_CLIENT_SECRET\" \\\n  -d refresh_token=\"$GOOGLE_REFRESH_TOKEN\" \\\n  -d grant_type=refresh_token\n```\n\nIf this fails with `invalid_grant`, stop debugging your prompt.\n\n```\n{\n  \"developer_token\": \"YOUR_DEVELOPER_TOKEN\",\n  \"login_customer_id\": \"1234567890\"\n}\nAuthorization: Bearer ACCESS_TOKEN\ndeveloper-token: YOUR_DEVELOPER_TOKEN\nlogin-customer-id: 1234567890\n```\n\nNot glamorous. But this is the layer that decides whether your agent runs at all.\n\nThis was the part that bit me hardest.\n\nA lot of teams treat Google refresh tokens like long-lived infrastructure. They are not.\n\nGoogle refresh tokens can stop working for a bunch of reasons, including:\n\nThat last one is brutal.\n\nIf your OAuth consent screen is set to **Testing** and your app is **External**, refresh tokens can expire in **7 days** unless you're only using basic identity scopes.\n\nSeven days.\n\nSo you can build a workflow on Friday, watch it pass on Monday, and then spend the next week blaming GPT-5 for an auth lifecycle problem.\n\nI've seen postmortems call this:\n\nBut the root cause was a 7-day refresh token timeout.\n\nThat's not an LLM issue. That's plumbing.\n\nBecause several different failures collapse into the same symptom.\n\nA 401-looking Google Ads failure can mean:\n\nThis is why blind retries are mostly useless.\n\nIf the auth state is wrong, retries just burn time and make logs noisier.\n\nThe mental model that helped me was this:\n\n**Treat auth failures as state transitions, not transient errors.**\n\nOnce I started doing that, the failures stopped feeling random.\n\nDifferent API, same class of problem.\n\nMeta Marketing API gives you multiple token types, and a lot of overnight failures come from choosing the wrong one.\n\n| API or pattern | What actually matters | \n|---|---|\n| Google Ads API | OAuth 2.0 is required, a developer token is also required, and `login-customer-id` is often required for manager-account access | \n| Google user refresh token vs service account | User refresh tokens can expire or be revoked; service accounts avoid human re-consent but still need Google Ads access granted in the UI | \n| Meta Marketing API | Short-lived user tokens are bad for unattended jobs; long-lived user tokens are better; system user tokens are usually the right fit for business automation | \n\nIf your Make or Zapier scenario is using a short-lived user token for an unattended sync job, that is not AI unreliability.\n\nThat's just the wrong credential strategy.\n\nFor some Google Ads automations: yes, absolutely.\n\nIf the automation is server-side and operating on accounts your team already controls, service accounts are often the cleaner setup.\n\nTwo immediate benefits:\n\nThat second one matters more than people admit.\n\nI've seen critical automations effectively authenticated as \"someone from paid media who left three months ago.\" That's not architecture. That's a trap.\n\nA Google service account does not automatically have Google Ads access.\n\nYou still have to add it inside the Google Ads UI.\n\nPath:\n\n``` php\nGoogle Ads UI -> Admin -> Access and security -> Users -> + -> add service account email\n```\n\nThen assign an appropriate role.\n\nIf you skip that step, the service account exists, the code looks fine, and the automation still fails.\n\nService accounts are not a universal answer.\n\nIf you're building a multi-tenant SaaS product acting on behalf of outside advertisers, you still need a proper OAuth user-consent flow and a decent re-auth UX.\n\nYou can't service-account your way out of delegated access.\n\nThis is the boring part.\n\nIt's also the part that worked.\n\nI now use the same auth hardening rules whether the workflow runs in n8n, Make, a Python worker, or a Node service.\n\nKeep all of this:\n\nIf your database column is just `token_blob`, you're making future-you miserable.\n\nDon't wait for a live request to discover that auth is dead.\n\nRefresh proactively when expiry is near.\n\nFor example:\n\n``` python\nfrom datetime import datetime, timedelta, timezone\n\ndef should_refresh(expires_at: datetime, buffer_minutes: int = 10) -> bool:\n    now = datetime.now(timezone.utc)\n    return now >= (expires_at - timedelta(minutes=buffer_minutes))\n```\n\nMove auth volatility out of the critical path.\n\nThis one causes a lot of fake mystery bugs.\n\nIf multiple workers try to refresh the same expired credential at once, they can stomp on each other:\n\nNow everyone thinks the API is flaky.\n\nUse a lock per credential.\n\nExample with Redis-style locking pseudocode:\n\n``` python\ndef refresh_with_lock(credential_id):\n    lock_key = f\"refresh-lock:{credential_id}\"\n\n    if not acquire_lock(lock_key, ttl=30):\n        raise RuntimeError(\"refresh already in progress\")\n\n    try:\n        credential = load_credential(credential_id)\n        if not should_refresh(credential.expires_at):\n            return credential\n\n        new_token = refresh_token(credential.refresh_token)\n        save_credential(credential_id, new_token)\n        return new_token\n    finally:\n        release_lock(lock_key)\n```\n\nOne credential, one refresh path.\n\nIf you get:\n\n`401`` invalid_grant``OAuthException`\nbranch into auth recovery logic immediately.\n\nThat can mean:\n\nWhat you should not do is retry the same broken state six times and call that resilience.\n\nExample pattern:\n\n``` python\ndef call_ads_api(request, credential):\n    response = send_request(request, credential.access_token)\n\n    if response.status_code != 401:\n        return response\n\n    refreshed = try_refresh(credential)\n    if not refreshed:\n        mark_reauth_required(credential.id)\n        raise RuntimeError(\"reauth required\")\n\n    retry_response = send_request(request, refreshed.access_token)\n    return retry_response\n```\n\nWhen an ad automation fails, I check these in order:\n\n```\n1. Can the refresh token still mint a new access token?\n2. Is the developer token present and valid?\n3. Is login-customer-id required here?\n4. Does the principal actually have access to the target customer account?\n5. Is this tied to a user token that should really be a service account?\n6. Is the OAuth app still in Testing mode?\n1. What token type is this?\n2. Is it short-lived, long-lived, or system user?\n3. Is this job unattended?\n4. If yes, why am I still using a user token?\n```\n\nOnly after auth checks pass do I start looking at prompts, model routing, or agent behavior.\n\nThat order matters.\n\nThis matters even more when you're running agents continuously.\n\nOnce you have n8n flows, Make scenarios, Python workers, and scheduled jobs all calling APIs around the clock, per-request billing is not your only source of pain.\n\nCredential drift becomes its own tax:\n\nAnd when you're also paying per token for the model layer, bad auth hurts twice:\n\nThat's one reason I like using Standard Compute for agent and automation workloads. If I'm iterating on routing, retries, classification prompts, or recovery logic across GPT-5.4, Claude Opus 4.6, and Grok 4.20, I don't want token accounting to become part of the debugging stress.\n\nFlat-rate, OpenAI-compatible compute is a much better fit for this kind of always-on workflow work than watching every model call like a taxi meter.\n\nEspecially when the real bug is often outside the model layer anyway.\n\nThat was the whole lesson for me.\n\nWe spend a lot of time comparing GPT-5, Claude, Grok, Qwen, Llama, routers, prompts, and agent frameworks.\n\nBut the smartest component in your stack is still downstream of the dumbest failure mode.\n\nIf Google Ads rejects the request because:\n\nthen the model never had a chance to succeed or fail.\n\nAnd weirdly, that's good news.\n\nModel problems can be slippery.\n\nAuth problems are usually concrete.\n\nSo if your ad automation keeps \"mysteriously\" dying, stop staring at GPT traces for a minute.\n\nCheck the credential type.\n\nCheck the refresh lifecycle.\n\nCheck whether the OAuth app is still in Testing.\n\nCheck whether the principal actually has account access.\n\nCheck `login-customer-id`.\n\nMost of the pain is up there, before the agent ever runs.\n\nFix that layer first.\n\nThen your AI stack suddenly looks a lot smarter.", "url": "https://wpnews.pro/news/i-stopped-blaming-gpt-5-for-my-ads-api-401-oauth-mess-and-fixed-the-4-auth-that", "canonical_source": "https://dev.to/lars_winstand/i-stopped-blaming-gpt-5-for-my-ads-api-401-oauth-mess-and-fixed-the-4-auth-states-that-were-1ee4", "published_at": "2026-10-19 22:09:55+00:00", "updated_at": "2026-10-19 22:15:03.439098+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools"], "entities": ["Google", "Meta", "GPT-5", "Claude", "Google Ads API", "Meta Marketing API", "Make", "Zapier"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/i-stopped-blaming-gpt-5-for-my-ads-api-401-oauth-mess-and-fixed-the-4-auth-that", "markdown": "https://wpnews.pro/news/i-stopped-blaming-gpt-5-for-my-ads-api-401-oauth-mess-and-fixed-the-4-auth-that.md", "text": "https://wpnews.pro/news/i-stopped-blaming-gpt-5-for-my-ads-api-401-oauth-mess-and-fixed-the-4-auth-that.txt", "jsonld": "https://wpnews.pro/news/i-stopped-blaming-gpt-5-for-my-ads-api-401-oauth-mess-and-fixed-the-4-auth-that.jsonld"}}