google-ads-api: The Node.js Client That Actually Gets the Google Ads API Right

google-ads-api: The Node.js Client That Actually Gets the Google Ads API Right

google-ads-api is an unofficial Node.js client for the Google Ads API, maintained by Opteo — a Google Ads optimization tool. It wraps the official gRPC/protobuf interface in something you can actually work with in TypeScript.

Why I starred it

The official Google Ads API is one of the more painful APIs in existence. You're dealing with protobuf-encoded responses, GAQL (Google Ads Query Language) strings you have to construct manually, enums that arrive as integers, and a REST/gRPC hybrid that requires careful handling of streaming responses. The official Node.js client from Google wraps some of this but doesn't give you the ergonomics you'd want for a real application.

Opteo built this because they use it in production for their ads platform. That context shows in the design — this isn't a hobbyist wrapper, it's the library you reach for when you're building something serious on top of Google Ads.

How it works

The entry point is src/client.ts, which exposes GoogleAdsApi. Calling client.Customer(...) returns a Customer instance (src/customer.ts) that extends ServiceFactory — an auto-generated class in src/protos/autogen/serviceFactory that exposes typed accessors for every Google Ads service (campaigns, adGroups, keywords, etc).

The interesting part is src/query.ts. The buildQuery function takes a ReportOptions object and assembles a typed GAQL string. The return type is:

type Query =
  `${SelectClause}${FromClause}${WhereClause}${OrderClause}${LimitClause}${ParametersClause}`;

Each clause is its own template literal type. TypeScript enforces the shape of the query string at compile time — not just the object passed in, but the string itself. That's the kind of thing you don't notice until you try to construct a GAQL query that's missing a required segment and the compiler catches it before you ship.

The constraint system in buildWhereClause handles three input styles: plain objects ({ "campaign.status": enums.CampaignStatus.ENABLED }), { key, op, val } triples for complex conditions, and raw strings when you need to escape the abstraction. When you pass an enum value like 2, convertNumericEnumToString in src/query.ts looks up the string representation via fields.enumFields so GAQL gets "ENABLED" instead of a numeric literal that would be rejected.

The streaming story is genuinely well-thought-out. reportStream returns an async generator — you iterate rows one at a time. reportStreamRaw hands you the raw gRPC CancellableStream for manual event handling. Internally, src/customer.ts uses stream-json to parse the REST response as a streaming JSON pipeline, so 10,000-row chunks don't materialize the entire payload in memory before parsing begins.

Error handling for streams is a particularly gnarly problem — when a stream errors, the error itself arrives as another stream. The handleStreamError method in src/customer.ts chains stream-json's parser() and streamArray() into a pipeline, reads the first error object, converts it to a GoogleAdsFailure, and rejects the promise. Most wrappers I've seen just let this silently swallow the actual error message.

The decamelizeKeys implementation in src/parserRest.ts runs a Map-based cache for key transformations (decamelizeCache) and a separate fieldTypeCache for value parsing. Pre-splitting field strings before iterating rows (noted in a comment: "increases speed by ~5x for large number of rows") is in src/parser.ts. These are the micro-decisions that only show up when you're processing millions of rows.

The hooks system (src/hooks.ts) is cleanly typed. OnQueryStart, OnQueryError, OnQueryEnd — each gets the appropriate subset of BaseRequestHookArgs. The cancel function signature changes depending on hook type: start hooks accept an optional return value to use as the cached alternative result, end hooks get a resolve override. It's the right design for caching middleware.

Using it

import { GoogleAdsApi, enums } from "google-ads-api";

const client = new GoogleAdsApi({
  client_id: process.env.CLIENT_ID,
  client_secret: process.env.CLIENT_SECRET,
  developer_token: process.env.DEVELOPER_TOKEN,
});

const customer = client.Customer({
  customer_id: "1234567890",
  refresh_token: process.env.REFRESH_TOKEN,
});

// High-level report method — compiles to GAQL internally
const campaigns = await customer.report({
  entity: "campaign",
  attributes: ["campaign.id", "campaign.name"],
  metrics: ["metrics.cost_micros", "metrics.clicks"],
  constraints: { "campaign.status": enums.CampaignStatus.ENABLED },
  limit: 100,
});

// Or stream large datasets row by row
const stream = customer.reportStream({
  entity: "ad_group_criterion",
  attributes: ["ad_group_criterion.keyword.text"],
  constraints: { "ad_group_criterion.type": enums.CriterionType.KEYWORD },
});

for await (const row of stream) {
  console.log(row.ad_group_criterion?.keyword?.text);
}

Atomic mutations across multiple resource types work through mutateResources with temporary resource IDs — you reference a campaign_budget with ID -1 and create the budget and campaign in the same request. The API resolves them in order.

Rough edges

The library tracks Google Ads API versions with major releases — it's currently on v23. That's both a feature and a maintenance burden: Opteo has to regenerate the protobufs and service factories on every Google release, and there's no way around that. If Opteo stops maintaining this (they've been active through at least January 2026), you'd be stuck on an old API version until someone forks it.

The generated files in src/protos/autogen/ are enormous. serviceFactory alone is thousands of lines of repetitive TypeScript. That's inherent to the approach — generated from protobuf definitions — but it means bundle size is non-trivial if you're not tree-shaking carefully.

There's no built-in rate limiting or retry logic. Google Ads has per-account quotas and transient errors are common; you'll need to wrap the hooks to handle those yourself.

The compile step in package.json requires environment variables to be set for the code generation scripts. The README doesn't explain what those are. If you ever need to regenerate protos from scratch, you're reading the scripts/ directory and guessing.

Bottom line

If you're building anything serious on the Google Ads API in Node.js, this is the library. The type-safe GAQL builder, the streaming support, and the hooks system are all better designed than you'd expect from an unofficial wrapper. Just be aware you're taking a dependency on Opteo's continued maintenance.

Opteo/google-ads-api on GitHub
Opteo/google-ads-api