---
title: "Add Search ad-group keywords"
method: POST
path: "/v1/ads/keywords"
tags: ["Ad Campaigns"]
---

# Add Search ad-group keywords

`POST /v1/ads/keywords`

Adds one or more keyword criteria to an existing Google Search ad group,
without touching the keywords already there (unlike the whole-set diff on
`PUT /v1/ads/{adId}`, `keywords`/`negativeKeywords` in `platformSpecificData`,
which replaces the set). Set `negative: true` to add ad-group-level negatives
instead of positive keywords.

## Request body

- object
  - `accountId` string, required — Account ID (Google Ads)
  - `adSetId` string, required — Google ad group ID to add the keywords to
  - `keywords` union[], required
    - union
      - string — Keyword text; defaults to BROAD match
      - object
        - `text` string, required
        - `matchType` 'exact' | 'phrase' | 'broad'
  - `negative` boolean — Add as ad-group-level negatives instead of positive keywords

## Response `201`

Keywords added

- object
  - `keywords` AdKeyword[]
    - `id` string
    - `accountId` string — Account ID owning the sync
    - `profileId` string
    - `platform` 'google'
    - `adAccountId` string — Google customer ID
    - `campaignId` string
    - `campaignName` string, nullable
    - `campaignStatus` string, nullable
    - `adSetId` string — Google ad group ID
    - `adSetName` string, nullable
    - `adSetStatus` string, nullable
    - `keyword` string
    - `matchType` 'exact' | 'phrase' | 'broad' | 'unknown'
    - `status` 'active' | 'paused'
    - `negative` boolean
    - `qualityScore` integer, nullable — Deprecated, use `quality.score`. Google Quality Score, 1-10. Null when unrated.
    - `quality` object — Google Quality Score and the three component ratings behind it (`ad_group_criterion.quality_info`). Every field is null until Google has rated the keyword: a keyword with too little traffic is unrated, and negatives are never rated. Google's own UNKNOWN / UNSPECIFIED buckets are reported as null so "unrated" has a single representation.
      - `score` integer, nullable — Quality Score, 1-10.
      - `expectedCtr` 'BELOW_AVERAGE' | 'AVERAGE' | 'ABOVE_AVERAGE' | 'null', nullable — How the click-through rate compares with other ads in the same position (`search_predicted_ctr`).
      - `adRelevance` 'BELOW_AVERAGE' | 'AVERAGE' | 'ABOVE_AVERAGE' | 'null', nullable — How closely the ad matches the intent behind the search (`creative_quality_score`).
      - `landingPageExperience` 'BELOW_AVERAGE' | 'AVERAGE' | 'ABOVE_AVERAGE' | 'null', nullable — How relevant and useful the landing page is to people who click (`post_click_quality_score`).
    - `syncedAt` string, date-time, nullable
    - `metrics` object, nullable — Trailing 30-day window. Null on rows synced before the metrics columns existed (re-synced on the keyword's next weekly sweep).
      - `windowDays` integer
      - `clicks` integer
      - `impressions` integer
      - `cost` number — Account currency, not USD-normalized
      - `conversions` number
      - `firstPageCpc` number, nullable — Account currency
      - `firstPositionCpc` number, nullable — Account currency

## Other responses

- `400` — Invalid request
- `401` — Unauthorized
- `404` — The account or requested resource was not found or is not accessible. An account ID may have been disconnected and removed. Read GET /v1/accounts for current account IDs.
- `409` — The account exists but is inactive or needs reconnection. Reconnect it, then read GET /v1/accounts for its current account ID before retrying. Code: ads_connection_required.
- `501` — Only available on Google Ads accounts

## Changes

- **2026-09-25** `2c04683ce694` — 12 info
  - added the optional property `details/adAccountId` to the response with the `400` status
  - added the optional property `details/adAccountId` to the response with the `404` status
  - added the optional property `details/adAccountId` to the response with the `409` status
  - added the optional property `details/createdObjects` to the response with the `400` status
  - …8 more
- **2026-09-16** `3e6ddf2a99ea` — 3 info
  - added the optional property `details/budgetScope` to the response with the `400` status
  - added the optional property `details/budgetScope` to the response with the `404` status
  - added the optional property `details/budgetScope` to the response with the `409` status
- **2026-09-15** `0dba7d004d75` — 6 info
  - added the optional property `details/quotaExhausted` to the response with the `400` status
  - added the optional property `details/quotaExhausted` to the response with the `404` status
  - added the optional property `details/quotaExhausted` to the response with the `409` status
  - added the optional property `details/quotaScope` to the response with the `400` status
  - …2 more
- **2026-09-14** `a23eb241746a` — 1 info
  - added the optional property `keywords/items/quality` to the response with the `201` status
- **2026-09-10** `e70ed06e7150` — 2 info
  - added the media type `application/json` for the response with the status `404`
  - added the non-success response with the status `409`

[Full history](https://skmtc.dev/zernio/apis/zernio-api/changes/v1/ads/keywords/post.md)

---

[API](https://skmtc.dev/zernio/apis/zernio-api.md) · [All operations](https://skmtc.dev/zernio/apis/zernio-api/llms.txt) · [OpenAPI document](https://skmtc.dev/zernio/apis/zernio-api/revisions/b2325332041a?raw)
