All documentation

Integrations

Connecting a Google account and mapping a property are two separate steps with two separate scopes, and why that distinction matters.

Connecting and mapping are different things

Integrations is where you connect a Google account and then choose which Search Console and Analytics properties feed which of your projects. Those are two separate steps with two separate scopes, and the difference between them is the common reason a freshly connected account can still leave a project's Performance screen empty, in the cases where the second step does not happen on its own.

Connecting stores one Google account's refresh token against your own user id, not against any project: the lookup behind every use of that connection finds it by matching your user id alone, with no project in the query at all, so the connection you make once covers every project you own, current and future (api/services/integrations/google/oauth.py#get_active_integration, #upsert_integration). Starting the OAuth handshake, for either product, checks nothing but who you are signed in as (api/routers/integrations.py#oauth_start, #ga4_oauth_start).

Mapping is the separate step underneath it, and it is scoped to one project at a time. A Search Console property or an Analytics property Google hands back after you connect is tied to a project by setting that property's own project field to that project's id, one property row, one project assignment (api/routers/integrations.py#map_property_to_project, #map_ga4_property_to_project).

Some of that mapping happens for you, at connect time, with nothing to click. Completing the handshake queues a discovery pass for whichever of the two products the returned scopes cover (api/routers/integrations.py#oauth_complete). That pass reconciles the properties Google reports and then, for each property that has no project yet, compares the hostnames it can read for that property against the domains of the projects you can write to: the ones you created, plus any project you hold an active owner or editor membership on (api/services/integrations/google/automap_common.py#load_writable_projects). A property is mapped only when one of those hostnames is exactly equal to one such project's domain, both sides first lowercased and stripped of any scheme, port, path and leading www., and only when exactly one project matches; two matching projects is an ambiguity it declines rather than guesses at (api/services/integrations/google/automap_common.py#match_property_to_project). Where each side's hostname comes from differs by product, and so does one extra condition: Search Console reads the property's own URL, and will not auto-map a project that already has a property attached to this connection, nor act at all when two properties match the same project; Analytics reads the hostnames of the property's web data streams, and leaves a property unmapped when it cannot read them (api/services/integrations/google/gsc_automap.py#sync_and_automap_gsc, api/services/integrations/google/ga4_automap.py#sync_and_automap). So in the ordinary case, one project whose domain is the property's host and no mapping on it yet, connecting is the only step there is.

Mapping by hand is what the screen is for when that does not happen: the hostnames differ, as a subdomain-only property does, the match is ambiguous, the automatic pass was never queued, or you simply want a different property on that project. Doing it by hand requires write access to the project you are mapping into: both map handlers run the same ownership and role check every project-scoped write in the product uses, so a viewer cannot map a property to a project (api/routers/integrations.py#map_property_to_project, #map_ga4_property_to_project). The screen reflects that check rather than letting the request go and fail: the "Map to a project" dropdown for each property lists every project you can open, but a project you can only read is shown marked "(view only)" and cannot be selected (frontend/src/routes/(app)/settings/integrations/google/+page.svelte#canMapTo). Unmapping asks the same question. Both unmap handlers first check that the property belongs to your own Google connection, and then, when that property currently has a project attached, run the same pair of checks the map handlers do against that project (api/routers/integrations.py#unmap_property, #unmap_ga4_property, api/dependencies.py#get_project_for_user, api/dependencies.py#require_write_access). So a viewer cannot detach a property, and neither can someone who has since been taken off the project they mapped it into, and on screen the Unmap button for a mapped property is disabled under that same canMapTo check, read against the project the property is currently attached to (frontend/src/routes/(app)/settings/integrations/google/+page.svelte#canMapTo). A property attached to no project reaches neither check, because there is no project to check against, and detaching it changes nothing in any case.

This is why an account-wide connection and a project with no data are not a contradiction: the account can be genuinely connected while this particular project simply has no property mapped to it yet. See Troubleshooting for what each of those states looks like on screen, and what to do about it.

Search Console and Analytics behave differently, but not in every direction

Mapping does not work the same way for the two products, and the difference sits in only one of the two directions a mapping can be read.

From a project looking down at its properties: a project may hold at most one Search Console property. Before completing a new mapping, the handler looks for any other property already holding the target project and clears that property's mapping first, an atomic swap that keeps a project pinned to one Search Console property no matter how many times you remap it (api/routers/integrations.py#map_property_to_project). Analytics carries no such rule: a project can hold as many mapped Analytics properties as you choose to give it, because the mapping handler sets the property's project field directly with no check for an existing mapping on that project at all (api/routers/integrations.py#map_ga4_property_to_project). The settings screen states this difference in its own copy, and the flag in the Search Console picker runs the other way round from what you might expect: what is marked is a project that already holds a property, so that choosing it tells you this mapping will move that project's existing one (frontend/src/routes/(app)/settings/integrations/google/+page.svelte#takenProjectIds).

From a property looking up at its project, the two products agree, and this is the direction worth checking before assuming the asymmetry runs both ways. Both the Search Console swap above and the Analytics mapping handler write a single project id onto the property being mapped, replacing whatever was there before rather than adding to a list (api/routers/integrations.py#map_property_to_project, #map_ga4_property_to_project), so a single property can be attached to only one project at a time in either system. Nothing about Analytics' looser rule above lets one Analytics property serve two projects at once; it only lets one project hold more than one property.

The scopes you are granting

Connecting asks Google for exactly two possible scopes, named as constants in the code you can check directly, and both are read-only. webmasters.readonly is the Search Console scope, and analytics.readonly is the Analytics one; neither string carries a write, edit, or manage permission, and nothing else in the OAuth flow ever requests a broader scope than these two (api/services/integrations/google/oauth.py#GSC_SCOPE, #GA4_SCOPE, #build_authorization_url). Which of the two you are asked to grant depends on which Connect button you use: the Search Console button requests only its own scope, while the Analytics button requests its own scope plus Search Console's too if you already granted that one, so one consent screen can cover both going forward (api/routers/integrations.py#oauth_start, #ga4_oauth_start). Every authorization request also sets Google's offline access and forces the consent screen again even for a returning user, specifically so a refresh token comes back on every connect rather than only the first time (api/services/integrations/google/oauth.py#build_authorization_url).

One control on this same screen reaches past the connection itself: naming the Analytics events that count as a conversion for a project. Changing that list requires the same write access mapping does (api/routers/integrations.py#update_conversion_events), and what naming an event actually changes about the numbers elsewhere in the product is AI traffic's to explain, not this page's.

Disconnecting, and reconnecting later

Disconnecting asks Google to revoke the stored refresh token, a best-effort call that is swallowed if it fails, and then marks the connection revoked by stamping a timestamp on it. It does not delete the row that holds the connection: the code sets revoked_at and commits, nothing more (api/routers/integrations.py#disconnect, api/services/integrations/google/oauth.py#revoke_refresh_token). Every property mapping you had is left exactly as it was: disconnecting never touches a property's project_id, and nothing keyed off revoked_at deletes a previously imported day of Search Console or Analytics data either. What actually stops is new data: the function every import route calls to get a working access token checks for a live, unrevoked connection first and returns nothing once one has been disconnected (api/services/integrations/google/oauth.py#get_active_integration, #get_access_token_for_user).

Reconnecting later reuses that same row rather than creating a new one: the upsert that runs at the end of a successful OAuth handshake finds the existing connection by your user id, overwrites its token and scopes, and clears revoked_at (api/services/integrations/google/oauth.py#upsert_integration). Because disconnecting never touched your mappings, every property you had mapped before is still mapped the moment the reconnected token starts working again, with nothing to redo.

When the callback fails

Google redirects your browser back to this screen after you respond to the consent prompt, and that redirect is the only path a broken connection attempt takes, so it is worth knowing exactly what each failure on it does. If Google's own redirect carries an error, or is missing the code or state it should carry, the page never calls the backend at all: it bounces straight back with a generic failure shown (frontend/src/routes/(app)/settings/integrations/google/callback/+server.js). Otherwise it hands the code and state to the backend, which can reject the attempt for one of three reasons: the state token does not match what was issued for you or has expired, Google refused to exchange the code, or Google did not return a refresh token at all, which happens when you had already granted access before and this attempt did not force a fresh consent screen (api/routers/integrations.py#oauth_complete). The frontend recognizes all three of those reasons by name (frontend/src/routes/(app)/settings/integrations/google/callback/+server.js#known), but only two get their own message: a rejected code and a missing refresh token each explain what happened and what to do next, while an expired state token and anything unrecognized both collapse into the same generic "try again" message. A successful callback carries no such detail: it redirects with a plain success flag that shows a one-time confirmation.

Not gated by plan

Neither connecting nor mapping checks your plan at any point. This screen calls twelve handlers: reading the connection's status, starting the handshake for either product, disconnecting, listing discovered Search Console properties, listing discovered Analytics properties, mapping and unmapping one of each, and reading and writing the conversion-event list. A thirteenth, completing the handshake, is reached from Google's redirect rather than from the screen itself. Every one of them reads your identity; mapping a property, unmapping one that currently has a project, and saving a conversion-event list additionally read your role on that project, and reading the conversion-event list requires membership on it. None of the thirteen reads a plan or a subscription at all (api/routers/integrations.py#integration_status, #oauth_start, #ga4_oauth_start, #oauth_complete, #disconnect, #list_properties, #list_ga4_properties, #map_property_to_project, #unmap_property, #map_ga4_property_to_project, #unmap_ga4_property, #get_conversion_events, #update_conversion_events). That puts this screen outside every gate described on Plans and limits: what you can connect and map here is the same regardless of which plan governs the project you are mapping into.

  • Search performance: what a mapped Search Console property actually feeds, once it has one
  • Traffic: what a mapped Analytics property feeds beyond the AI-referred slice
  • AI traffic: the AI-referral reading of the same Analytics connection, including how naming a conversion event changes it
  • Troubleshooting: the connected-but-unmapped and mapped-but-empty states this page's central distinction explains

Last verified 2026-08-11

Start monitoring your AI visibility.

See how AI search engines talk about your brand.

Free to start. No credit card required.