> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cattix.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Google Ads CRUD & Live Campaign Editing

> New endpoints for managing Google Ads resources (ad groups, ads, keywords, targeting) and editing live campaigns

<Note>
  This page is also available in [Ukrainian](/guides/google-ads-crud-uk).
</Note>

## 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](/guides/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.

### Two levels of search

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.

### Recommended implementation pattern

#### 1. Debounce the input (300ms)

Don't fire an API call on every keystroke. The admin uses a 300ms debounce:

```typescript theme={null}
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:

```typescript theme={null}
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:

```typescript theme={null}
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

| State                | What to show                                                 |
| -------------------- | ------------------------------------------------------------ |
| No customer selected | Disabled input, prompt to select account                     |
| Empty query          | Placeholder text (e.g. "Search campaigns, ads, keywords...") |
| Loading              | Skeleton rows or spinner per category                        |
| Partial results      | Show resolved categories, skeleton for pending ones          |
| No results           | "No results for 'query'"                                     |
| Error                | Per-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:

```typescript theme={null}
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`:

| File                                      | Purpose                                                            |
| ----------------------------------------- | ------------------------------------------------------------------ |
| `src/hooks/use-global-search.ts`          | Search hook with debounce, parallel requests, race-condition guard |
| `src/components/global-search-dialog.tsx` | `Cmd+K` dialog UI with `cmdk`                                      |
| `src/lib/api/google-ads.ts`               | API client that passes `search` param                              |
| `src/app/admin/google-ads-data/page.tsx`  | Data page that reads `?search=` from URL                           |

***

## Google Ads Management CRUD

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](/api-reference/google-ads-management/create-ad-group))

```
POST /api/v1/google-ads/ad-groups?customer_id={customer_id}
```

<ParamField body="campaign_id" type="integer" required>
  Campaign ID to create the ad group in.
</ParamField>

<ParamField body="name" type="string" required>
  Ad group name (1-256 characters).
</ParamField>

<ParamField body="ad_group_type" type="string" default="SEARCH_STANDARD">
  One of: `SEARCH_STANDARD`, `DISPLAY_STANDARD`, `SHOPPING_PRODUCT_ADS`.
</ParamField>

<ParamField body="status" type="string" default="ENABLED">
  Initial status: `ENABLED` or `PAUSED`.
</ParamField>

<ParamField body="cpc_bid_micros" type="integer">
  Default CPC bid in micros (1,000,000 = \$1.00). Must be > 0.
</ParamField>

**Response:** `AdGroupMutationResult`

```json theme={null}
{
  "success": true,
  "ad_group_id": 123456789,
  "resource_name": "customers/1234567890/adGroups/123456789",
  "error_code": null,
  "error_message": null
}
```

#### Update ad group status ([Swagger](/api-reference/google-ads-management/update-ad-group-status))

```
PATCH /api/v1/google-ads/ad-groups/{ad_group_id}/status?customer_id={customer_id}
```

<ParamField body="status" type="string" required>
  New status: `ENABLED` or `PAUSED`.
</ParamField>

#### Delete ad group ([Swagger](/api-reference/google-ads-management/delete-ad-group))

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

***

### Ads (Responsive Search Ads)

#### Create RSA ([Swagger](/api-reference/google-ads-management/create-rsa))

```
POST /api/v1/google-ads/ads?customer_id={customer_id}
```

<ParamField body="ad_group_id" type="integer" required>
  Ad group to create the RSA in.
</ParamField>

<ParamField body="final_url" type="string" required>
  Landing page URL.
</ParamField>

<ParamField body="headlines" type="RSAAssetInput[]" required>
  3-15 headlines. Each has `text` (required) and optional `pinned_field`
  (`HEADLINE_1`, `HEADLINE_2`, etc.).
</ParamField>

<ParamField body="descriptions" type="RSAAssetInput[]" required>
  2-4 descriptions. Same structure as headlines.
</ParamField>

<ParamField body="display_path_1" type="string">
  Max 15 characters.
</ParamField>

<ParamField body="display_path_2" type="string">
  Max 15 characters.
</ParamField>

<ParamField body="final_mobile_url" type="string">
  Optional mobile-specific landing page.
</ParamField>

<ParamField body="tracking_url_template" type="string">
  Optional tracking template.
</ParamField>

**Response:** `AdMutationResult`

```json theme={null}
{
  "success": true,
  "ad_id": 987654321,
  "resource_name": "customers/1234567890/ads/987654321",
  "error_code": null,
  "error_message": null
}
```

#### Update RSA ([Swagger](/api-reference/google-ads-management/update-rsa))

```
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](/api-reference/google-ads-management/delete-ad))

```
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](/api-reference/google-ads-management/create-keywords))

```
POST /api/v1/google-ads/keywords?customer_id={customer_id}
```

<ParamField body="ad_group_id" type="integer" required>
  Ad group to add keywords to.
</ParamField>

<ParamField body="keywords" type="KeywordInput[]" required>
  Array of keywords. Each has:

  * `text` (string, required) — keyword text
  * `match_type` (string, default `"PHRASE"`) — `EXACT`, `PHRASE`, or `BROAD`
</ParamField>

**Response:** `BatchKeywordOperationResponseSchema` (same as existing batch keyword response).

#### Update keyword ([Swagger](/api-reference/google-ads-management/update-keyword))

```
PATCH /api/v1/google-ads/keywords/{ad_group_id}/{criterion_id}?customer_id={customer_id}
```

<ParamField body="status" type="string">
  `ENABLED` or `PAUSED`.
</ParamField>

<ParamField body="cpc_bid_micros" type="integer">
  New CPC bid in micros. Must be > 0.
</ParamField>

**Response:** `KeywordMutationResult`

#### Remove keywords (batch) ([Swagger](/api-reference/google-ads-management/remove-keywords))

```
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](/api-reference/google-ads-management/create-ad-schedule))

```
POST /api/v1/google-ads/ad-schedule?customer_id={customer_id}
```

<ParamField body="campaign_id" type="integer" required>
  Campaign to add schedule to.
</ParamField>

<ParamField body="entries" type="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
</ParamField>

**Response:** `CriteriaMutationResult`

```json theme={null}
{
  "success": true,
  "successful_count": 7,
  "failed_count": 0,
  "errors": null
}
```

#### Replace all ad schedule entries ([Swagger](/api-reference/google-ads-management/replace-ad-schedule))

```
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](/api-reference/google-ads-management/delete-ad-schedule))

```
POST /api/v1/google-ads/ad-schedule/delete?customer_id={customer_id}
```

<ParamField body="campaign_id" type="integer" required>
  Campaign owning the criteria.
</ParamField>

<ParamField body="resource_names" type="string[]" required>
  Resource names of schedule criteria to remove.
</ParamField>

***

### Location Targeting

#### Add locations ([Swagger](/api-reference/google-ads-management/add-locations))

```
POST /api/v1/google-ads/locations/targeting?customer_id={customer_id}
```

<ParamField body="campaign_id" type="integer" required>
  Campaign to add locations to.
</ParamField>

<ParamField body="locations" type="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`
</ParamField>

**Response:** `CriteriaMutationResult`

#### Replace all locations ([Swagger](/api-reference/google-ads-management/replace-locations))

```
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](/api-reference/google-ads-management/delete-location-targeting))

```
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](/api-reference/campaigns/get-live-campaign-settings))

```
GET /api/v1/campaigns/live/{customer_id}/{campaign_id}
```

Returns the full campaign settings for the edit UI:

```json theme={null}
{
  "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](/api-reference/campaigns/patch-live-campaign-settings))

```
PATCH /api/v1/campaigns/live/{customer_id}/{campaign_id}
```

Only provided fields are updated. All fields are optional.

<ParamField body="campaign_name" type="string">
  New campaign name (1-256 characters).
</ParamField>

<ParamField body="status" type="string">
  `ENABLED` or `PAUSED`. Cannot set to `REMOVED` — use DELETE.
</ParamField>

<ParamField body="daily_budget_micros" type="integer">
  New daily budget in micros.
</ParamField>

<ParamField body="bidding_strategy" type="BiddingStrategySchema">
  New bidding strategy configuration.
</ParamField>

<ParamField body="network_settings" type="NetworkSettingsSchema">
  Search/display network settings.
</ParamField>

<ParamField body="language_ids" type="integer[]">
  **Replace-all** — sets exactly these language IDs.
</ParamField>

<ParamField body="location_targets" type="LocationTargetSchema[]">
  **Replace-all** — sets exactly these locations.
</ParamField>

<ParamField body="ad_schedule" type="AdScheduleSchema">
  **Replace-all** — sets exactly this schedule.
</ParamField>

<ParamField body="start_date" type="string (YYYY-MM-DD)">
  Campaign start date.
</ParamField>

<ParamField body="end_date" type="string (YYYY-MM-DD)">
  Campaign end date.
</ParamField>

<ParamField body="conversion_goals" type="ConversionGoalSchema[]">
  **Replace-all** — sets exactly these conversion goals.
</ParamField>

<Warning>
  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.
</Warning>

**Response:** `UpdateCampaignResultSchema`

```json theme={null}
{
  "success": true,
  "campaign_updated": true,
  "budget_updated": true,
  "targeting_updated": false,
  "conversion_goals_updated": false,
  "warnings": [],
  "errors": []
}
```

### Delete campaign ([Swagger](/api-reference/campaigns/remove-a-live-google-ads-campaign))

```
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`:

```json theme={null}
{
  "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`:

```json theme={null}
{
  "success": false,
  "successful_count": 5,
  "failed_count": 2,
  "errors": [
    { "resource_name": "...", "error_code": "...", "message": "..." }
  ]
}
```

### Common HTTP errors

| Status | Cause                                   |
| ------ | --------------------------------------- |
| `401`  | Missing or invalid auth token           |
| `404`  | Customer or resource not found          |
| `400`  | Google Ads API rejected the mutation    |
| `502`  | Google Ads API call failed unexpectedly |

***

## Migration checklist

<Steps>
  <Step title="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](#server-side-search-search) for patterns
    (debounce, race-condition guard, progressive loading) and the
    [admin.cattix.com reference code](#reference-implementation).
  </Step>

  <Step title="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.
  </Step>

  <Step title="Build CRUD UI for ad groups, ads, keywords">
    Use the new management endpoints to implement create/edit/delete actions
    in data tables.
  </Step>

  <Step title="Build campaign edit page">
    Use `GET /campaigns/live/{cid}/{campaign_id}` to populate the edit form
    and `PATCH` to save changes.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

***

## TypeScript interfaces

For frontend type safety, here are the key interfaces:

```typescript theme={null}
// 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;
}
```
