Skip to main content
This page is also available in Ukrainian.

Overview

This release adds two major groups of endpoints:
  1. Google Ads Management CRUD — create, update, and delete ad groups, ads (RSAs), keywords, ad schedule, and location targeting directly via the API
  2. Live Campaign Editing — read and update settings on already-published Google Ads campaigns
There are also a few changes to existing GET endpoints worth noting.

Changes to existing endpoints

login_customer_id parameter no longer needed

The login_customer_id query parameter is no longer used by any Google Ads endpoint. The backend now resolves it automatically from the database based on customer_id. If you’re currently passing login_customer_id, nothing will break — it’s simply ignored. However, we recommend removing it from your API calls to keep things clean, since it no longer has any effect.

New campaign_id filter on /ads endpoint

The GET /ads endpoint now accepts an optional campaign_id query parameter to filter ads by campaign. This is in addition to the existing ad_group_id filter.
GET /api/v1/google-ads/{customer_id}/ads?campaign_id=123456

Status filters relaxed

Data table endpoints that previously returned only ENABLED resources now also return PAUSED resources. This affects campaigns, ad groups, ads, and keywords. The frontend should be prepared to display both statuses.

Server-side search (?search=)

All data table GET endpoints now support a search query parameter. See the dedicated Table Search guide for the full API spec (searchable fields per table, parameter rules, etc.). This section focuses on how to implement search on the frontend, based on how admin.cattix.com already does it. The admin panel implements search at two levels:
  1. Per-table search bar — a text input above each data table that filters that specific table via ?search=<term>
  2. Global search dialog (Cmd+K) — fires the same ?search= query against all 7 tables in parallel, showing results grouped by category
You can implement either or both.

1. Debounce the input (300ms)

Don’t fire an API call on every keystroke. The admin uses a 300ms debounce:
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)

const onSearchChange = (value: string) => {
  setQuery(value)
  if (debounceRef.current) clearTimeout(debounceRef.current)

  if (!value.trim()) {
    // Clear results immediately when input is emptied
    resetResults()
    return
  }

  debounceRef.current = setTimeout(() => {
    fetchWithSearch(value)
  }, 300)
}

2. Prevent stale results (race condition guard)

If the user types “sho” then quickly changes to “brand”, the “sho” response may arrive after “brand”. Use a search-ID ref to discard stale responses:
const searchIdRef = useRef(0)

const fetchWithSearch = (query: string) => {
  const currentId = ++searchIdRef.current

  api.getCampaigns({ customer_id, search: query }).then((res) => {
    if (searchIdRef.current !== currentId) return // stale — discard
    setResults(res.data)
  })
}

3. For global search — fire parallel requests

The admin fires all 7 category requests independently (not with Promise.all), so results appear progressively as each category resolves:
const CATEGORIES = [
  "campaigns", "ad-groups", "ads", "keywords",
  "search-terms", "ad-schedule", "locations",
] as const

for (const category of CATEGORIES) {
  api[category]({ customer_id, search: query, limit: 5 })
    .then((res) => {
      if (searchIdRef.current !== currentId) return
      setState((prev) => ({
        ...prev,
        [category]: { loading: false, results: extract(category, res) },
      }))
    })
    .catch((err) => {
      if (searchIdRef.current !== currentId) return
      setState((prev) => ({
        ...prev,
        [category]: { loading: false, error: err.message, results: [] },
      }))
    })
}
This gives the UI a nice progressive-loading feel with a progress indicator (“Searching… 3/7 loaded”).

4. UI states to handle

StateWhat to show
No customer selectedDisabled input, prompt to select account
Empty queryPlaceholder text (e.g. “Search campaigns, ads, keywords…”)
LoadingSkeleton rows or spinner per category
Partial resultsShow resolved categories, skeleton for pending ones
No results”No results for ‘query‘“
ErrorPer-category error message

5. Navigate from global search to table

When a user selects a result in the global dialog, navigate to the data table with the search pre-filled:
const handleSelect = (category: string) => {
  router.push(`/google-ads-data?tab=${category}&search=${encodeURIComponent(query)}`)
  closeDialog()
}
On the data page, read search from URL params and auto-fetch on mount.

Reference implementation

The full working implementation lives in admin.cattix.com:
FilePurpose
src/hooks/use-global-search.tsSearch hook with debounce, parallel requests, race-condition guard
src/components/global-search-dialog.tsxCmd+K dialog UI with cmdk
src/lib/api/google-ads.tsAPI client that passes search param
src/app/admin/google-ads-data/page.tsxData page that reads ?search= from URL

All management endpoints are under the /api/v1/google-ads/ prefix and require a customer_id query parameter. Base URL pattern:
POST/PATCH/PUT/DELETE /api/v1/google-ads/{resource}?customer_id={customer_id}
All endpoints require Authorization: Bearer <token>.

Ad Groups

Create ad group (Swagger)

POST /api/v1/google-ads/ad-groups?customer_id={customer_id}
campaign_id
integer
required
Campaign ID to create the ad group in.
name
string
required
Ad group name (1-256 characters).
ad_group_type
string
default:"SEARCH_STANDARD"
One of: SEARCH_STANDARD, DISPLAY_STANDARD, SHOPPING_PRODUCT_ADS.
status
string
default:"ENABLED"
Initial status: ENABLED or PAUSED.
cpc_bid_micros
integer
Default CPC bid in micros (1,000,000 = $1.00). Must be > 0.
Response: AdGroupMutationResult
{
  "success": true,
  "ad_group_id": 123456789,
  "resource_name": "customers/1234567890/adGroups/123456789",
  "error_code": null,
  "error_message": null
}

Update ad group status (Swagger)

PATCH /api/v1/google-ads/ad-groups/{ad_group_id}/status?customer_id={customer_id}
status
string
required
New status: ENABLED or PAUSED.

Delete ad group (Swagger)

DELETE /api/v1/google-ads/ad-groups/{ad_group_id}?customer_id={customer_id}

Ads (Responsive Search Ads)

Create RSA (Swagger)

POST /api/v1/google-ads/ads?customer_id={customer_id}
ad_group_id
integer
required
Ad group to create the RSA in.
final_url
string
required
Landing page URL.
headlines
RSAAssetInput[]
required
3-15 headlines. Each has text (required) and optional pinned_field (HEADLINE_1, HEADLINE_2, etc.).
descriptions
RSAAssetInput[]
required
2-4 descriptions. Same structure as headlines.
display_path_1
string
Max 15 characters.
display_path_2
string
Max 15 characters.
final_mobile_url
string
Optional mobile-specific landing page.
tracking_url_template
string
Optional tracking template.
Response: AdMutationResult
{
  "success": true,
  "ad_id": 987654321,
  "resource_name": "customers/1234567890/ads/987654321",
  "error_code": null,
  "error_message": null
}

Update RSA (Swagger)

PATCH /api/v1/google-ads/ads/{ad_id}?customer_id={customer_id}
All fields optional — only provided fields are updated. When headlines or descriptions are provided, they replace all existing assets.

Delete ad (Swagger)

DELETE /api/v1/google-ads/ads/{ad_group_id}/{ad_id}?customer_id={customer_id}
Note: both ad_group_id and ad_id are required in the path.

Keywords

Create keywords (Swagger)

POST /api/v1/google-ads/keywords?customer_id={customer_id}
ad_group_id
integer
required
Ad group to add keywords to.
keywords
KeywordInput[]
required
Array of keywords. Each has:
  • text (string, required) — keyword text
  • match_type (string, default "PHRASE") — EXACT, PHRASE, or BROAD
Response: BatchKeywordOperationResponseSchema (same as existing batch keyword response).

Update keyword (Swagger)

PATCH /api/v1/google-ads/keywords/{ad_group_id}/{criterion_id}?customer_id={customer_id}
status
string
ENABLED or PAUSED.
cpc_bid_micros
integer
New CPC bid in micros. Must be > 0.
Response: KeywordMutationResult

Remove keywords (batch) (Swagger)

POST /api/v1/google-ads/keywords/remove?customer_id={customer_id}
Uses the same BatchKeywordOperationRequestSchema as the existing batch endpoint.

Ad Schedule

Add ad schedule entries (Swagger)

POST /api/v1/google-ads/ad-schedule?customer_id={customer_id}
campaign_id
integer
required
Campaign to add schedule to.
entries
AdScheduleEntryInput[]
required
Schedule entries. Each entry has:
  • day_of_week (string) — MONDAY, TUESDAY, … SUNDAY
  • start_hour (int, 0-23)
  • start_minute (int, default 0) — 0, 15, 30, or 45
  • end_hour (int, 0-24)
  • end_minute (int, default 0) — 0, 15, 30, or 45
Response: CriteriaMutationResult
{
  "success": true,
  "successful_count": 7,
  "failed_count": 0,
  "errors": null
}

Replace all ad schedule entries (Swagger)

PUT /api/v1/google-ads/ad-schedule?customer_id={customer_id}
Same body as POST. Replaces all existing schedule entries on the campaign.

Delete ad schedule entries (Swagger)

POST /api/v1/google-ads/ad-schedule/delete?customer_id={customer_id}
campaign_id
integer
required
Campaign owning the criteria.
resource_names
string[]
required
Resource names of schedule criteria to remove.

Location Targeting

Add locations (Swagger)

POST /api/v1/google-ads/locations/targeting?customer_id={customer_id}
campaign_id
integer
required
Campaign to add locations to.
locations
LocationTargetingEntry[]
required
Array of locations. Each has:
  • geo_target_constant_id (integer) — Google Ads geo target constant ID
  • target_type (string, default "INCLUDE") — INCLUDE or EXCLUDE
Response: CriteriaMutationResult

Replace all locations (Swagger)

PUT /api/v1/google-ads/locations/targeting?customer_id={customer_id}
Same body as POST. Replaces all existing location targeting on the campaign.

Delete location targeting (Swagger)

POST /api/v1/google-ads/locations/targeting/delete?customer_id={customer_id}
Same body as ad schedule delete (campaign_id + resource_names).

Live Campaign Editing

These endpoints allow reading and updating settings on already-published (live) Google Ads campaigns, under the /api/v1/campaigns/live/ prefix.

Get campaign settings (Swagger)

GET /api/v1/campaigns/live/{customer_id}/{campaign_id}
Returns the full campaign settings for the edit UI:
{
  "campaign_id": 123456789,
  "campaign_name": "Brand Campaign",
  "status": "ENABLED",
  "daily_budget_micros": 50000000,
  "budget_resource_name": "customers/123/campaignBudgets/456",
  "bidding_strategy": { ... },
  "network_settings": { ... },
  "start_date": "2025-01-15",
  "end_date": null,
  "language_ids": [1000],
  "location_targets": [
    {
      "geo_target_constant_id": 2840,
      "location_name": "United States",
      "target_type": "INCLUDE"
    }
  ],
  "ad_schedule": { ... },
  "conversion_goals": [ ... ]
}

Update campaign settings (Swagger)

PATCH /api/v1/campaigns/live/{customer_id}/{campaign_id}
Only provided fields are updated. All fields are optional.
campaign_name
string
New campaign name (1-256 characters).
status
string
ENABLED or PAUSED. Cannot set to REMOVED — use DELETE.
daily_budget_micros
integer
New daily budget in micros.
bidding_strategy
BiddingStrategySchema
New bidding strategy configuration.
network_settings
NetworkSettingsSchema
Search/display network settings.
language_ids
integer[]
Replace-all — sets exactly these language IDs.
location_targets
LocationTargetSchema[]
Replace-all — sets exactly these locations.
ad_schedule
AdScheduleSchema
Replace-all — sets exactly this schedule.
start_date
string (YYYY-MM-DD)
Campaign start date.
end_date
string (YYYY-MM-DD)
Campaign end date.
conversion_goals
ConversionGoalSchema[]
Replace-all — sets exactly these conversion goals.
Targeting fields (language_ids, location_targets, ad_schedule, conversion_goals) use replace-all semantics. When any of these fields is provided, the existing values are fully replaced. If you omit a field, it remains unchanged.
Response: UpdateCampaignResultSchema
{
  "success": true,
  "campaign_updated": true,
  "budget_updated": true,
  "targeting_updated": false,
  "conversion_goals_updated": false,
  "warnings": [],
  "errors": []
}

Delete campaign (Swagger)

DELETE /api/v1/campaigns/live/{customer_id}/{campaign_id}
Returns 204 No Content on success. This is irreversible — the campaign and all child resources (ad groups, keywords, ads) will be removed.

Error handling

All mutation endpoints return consistent error shapes:

Single-resource mutations

AdGroupMutationResult, AdMutationResult, KeywordMutationResult:
{
  "success": false,
  "ad_group_id": null,
  "resource_name": null,
  "error_code": "CAMPAIGN_NOT_FOUND",
  "error_message": "The campaign specified does not exist."
}

Batch/criteria mutations

CriteriaMutationResult:
{
  "success": false,
  "successful_count": 5,
  "failed_count": 2,
  "errors": [
    { "resource_name": "...", "error_code": "...", "message": "..." }
  ]
}

Common HTTP errors

StatusCause
401Missing or invalid auth token
404Customer or resource not found
400Google Ads API rejected the mutation
502Google Ads API call failed unexpectedly

Migration checklist

1

Implement server-side search

This is likely the biggest FE task. Add per-table search bars and optionally a global Cmd+K search dialog. See the implementation guide above for patterns (debounce, race-condition guard, progressive loading) and the admin.cattix.com reference code.
2

Handle PAUSED resources in data tables

Data tables now return both ENABLED and PAUSED campaigns, ad groups, and keywords. Add status badges or visual distinction for paused items.
3

Build CRUD UI for ad groups, ads, keywords

Use the new management endpoints to implement create/edit/delete actions in data tables.
4

Build campaign edit page

Use GET /campaigns/live/{cid}/{campaign_id} to populate the edit form and PATCH to save changes.
5

Add campaign_id filter to ads table

The /ads endpoint now supports ?campaign_id= for filtering. Use this when showing ads within a specific campaign.
6

Clean up login_customer_id from API calls (optional)

It’s no longer used and will be silently ignored, but removing it keeps your code clean.

TypeScript interfaces

For frontend type safety, here are the key interfaces:
// Mutation results
interface AdGroupMutationResult {
  success: boolean;
  ad_group_id: number | null;
  resource_name: string | null;
  error_code: string | null;
  error_message: string | null;
}

interface AdMutationResult {
  success: boolean;
  ad_id: number | null;
  resource_name: string | null;
  error_code: string | null;
  error_message: string | null;
}

interface KeywordMutationResult {
  success: boolean;
  criterion_id: number | null;
  resource_name: string | null;
  error_code: string | null;
  error_message: string | null;
}

interface CriteriaMutationResult {
  success: boolean;
  successful_count: number;
  failed_count: number;
  errors: Array<Record<string, unknown>> | null;
}

// Campaign settings (for edit UI)
interface CampaignSettingsDetail {
  campaign_id: number;
  campaign_name: string;
  status: "ENABLED" | "PAUSED" | "REMOVED";
  daily_budget_micros: number;
  budget_resource_name: string;
  bidding_strategy: BiddingStrategy | null;
  network_settings: NetworkSettings;
  start_date: string | null;
  end_date: string | null;
  language_ids: number[];
  location_targets: LocationTarget[];
  ad_schedule: AdSchedule | null;
  conversion_goals: Record<string, unknown>[];
}

interface UpdateCampaignResult {
  success: boolean;
  campaign_updated: boolean;
  budget_updated: boolean;
  targeting_updated: boolean;
  conversion_goals_updated: boolean;
  warnings: string[];
  errors: string[];
}

// CRUD request types
interface CreateAdGroupRequest {
  campaign_id: number;
  name: string;
  ad_group_type?: "SEARCH_STANDARD" | "DISPLAY_STANDARD" | "SHOPPING_PRODUCT_ADS";
  status?: "ENABLED" | "PAUSED";
  cpc_bid_micros?: number;
}

interface RSAAssetInput {
  text: string;
  pinned_field?: string | null;
}

interface CreateRSARequest {
  ad_group_id: number;
  final_url: string;
  headlines: RSAAssetInput[]; // 3-15
  descriptions: RSAAssetInput[]; // 2-4
  display_path_1?: string;
  display_path_2?: string;
  final_mobile_url?: string;
  tracking_url_template?: string;
}

interface KeywordInput {
  text: string;
  match_type?: "EXACT" | "PHRASE" | "BROAD";
}

interface CreateKeywordsRequest {
  ad_group_id: number;
  keywords: KeywordInput[];
}

interface LocationTargetingEntry {
  geo_target_constant_id: number;
  target_type?: "INCLUDE" | "EXCLUDE";
}

interface AdScheduleEntry {
  day_of_week: string;
  start_hour: number;
  start_minute?: number;
  end_hour: number;
  end_minute?: number;
}