# Accounts & labels

Read the Google Ads manager (MCC) hierarchy behind a connection, create client accounts under a manager, manage manager links, and organize campaigns, ad groups, ads and keywords with labels.

By the end of this page, you can:

- see every Google Ads account a connection reaches and how they nest under manager (MCC) accounts
- create a new client account under a manager
- bill it to the manager's monthly invoicing
- manage the links between managers and clients
- invite people to an account by email
- tag campaigns, ad groups, ads and keywords with labels

You need a `googleads` [account](/platforms/google-ads#connect).

## Manager hierarchy

[`GET /v1/ads/accounts/hierarchy`](/ad-accounts/get-ad-account-hierarchy) reads the manager and client tree live from Google.

It starts from every customer the Google user behind the connection can access directly, and walks each tree to any depth. Every client carries its direct parent, the link id and the link status.

```bash
curl "https://zernio.com/api/v1/ads/accounts/hierarchy?accountId=66b2e19d8c3f5a7e9d0b1c2d" \
  -H "Authorization: Bearer $ZERNIO_API_KEY"
```

Response (`200`), trimmed to one tree:

```json
{
  "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
  "roots": [
    {
      "customerId": "9876543210",
      "name": "Acme Agency MCC",
      "currency": "USD",
      "timeZone": "America/New_York",
      "manager": true,
      "testAccount": false,
      "status": "ENABLED",
      "managerLinks": [],
      "clients": [
        {
          "customerId": "1234567890",
          "name": "Acme Search",
          "currency": "USD",
          "timeZone": "America/New_York",
          "manager": false,
          "testAccount": false,
          "hidden": false,
          "level": 1,
          "status": "ENABLED",
          "parentCustomerId": "9876543210",
          "managerLinkId": "345678901",
          "linkStatus": "ACTIVE"
        },
        {
          "customerId": "5550001111",
          "name": null,
          "currency": null,
          "timeZone": null,
          "manager": false,
          "testAccount": false,
          "hidden": false,
          "level": 1,
          "status": null,
          "parentCustomerId": "9876543210",
          "managerLinkId": "345678902",
          "linkStatus": "PENDING"
        }
      ]
    }
  ],
  "directCustomers": [
    { "customerId": "9876543210", "manager": true, "pendingInvitations": [] }
  ],
  "unavailable": [],
  "truncated": false,
  "cachedAt": null,
  "stale": false
}
```

- `roots` has one entry per tree.
- `clients` lists every account under the root at any depth, with `level` (1 is a direct client of the root) and `parentCustomerId`.
- After them, `clients` lists invitations the manager sent that the client has not accepted yet (`linkStatus: "PENDING"`). Google returns no name or currency for them.
- Refused, canceled and ended links are history and are left out.
- `managerLinks` on a root lists the managers linked to that account, including invitations it can still accept.
- `directCustomers` lists every account the Google user accesses directly, the ones this connection can accept or decline invitations for, with their `pendingInvitations`.
- Customers Google refuses to read (for example a cancelled account) are listed in `unavailable` with Google's reason, instead of failing the call.
- Up to 50 roots and 50 managers per root are read. `truncated` is `true` when more exist.
- `adAccountId` narrows the read to the tree rooted at one directly accessible customer.
- When the connection is scoped to specific ad accounts, client accounts outside that scope are hidden; managers stay visible.

The read is cached for 10 minutes per connection and carries `cachedAt` and `stale`.

## Create a client account

[`POST /v1/ads/accounts`](/ad-accounts/create-ad-account) with `platform: "google"` creates a new Google Ads account under a manager.

Google only creates accounts through a manager, so `managerCustomerId` must be a manager the connection's Google user reaches, directly or under another manager.

Pick it from the hierarchy read. It can be your own manager or your customer's: send the `accountId` of the connection that reaches it.

```bash
curl -X POST "https://zernio.com/api/v1/ads/accounts" \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "platform": "google",
    "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
    "managerCustomerId": "9876543210",
    "name": "Acme Display",
    "currency": "USD",
    "timeZone": "America/New_York"
  }'
```

Response (`201`):

```json
{
  "platform": "google",
  "adAccountId": "5551234567",
  "managerCustomerId": "9876543210",
  "connectionUpdated": true,
  "warnings": [],
  "paymentMethodRequired": true,
  "nextSteps": "The account has no billing yet. If the manager has monthly invoicing, set it up with POST /v1/ads/accounts/billing; otherwise add billing in Google Ads (Billing > Settings). Ads do not serve until billing is set up. Zernio cannot add payment methods through the API.",
  "validateOnly": false
}
```

- `currency` (ISO 4217) and `timeZone` (an IANA name) cannot be changed later.
- The new account has no billing. Ads do not serve until billing is set up, through [the API](#billing-monthly-invoicing) when the manager has monthly invoicing, or in Google Ads.
- The account is added to the connection right away and works with every other ads endpoint through `adAccountId`.
- Google caps how many accounts a manager can create and how many active clients it can hold.

**Not idempotent.** Each successful call creates another account. After a `502` with `details.creationStatus: "unknown"`, check the manager in Google Ads before trying again.

**Dry run.** `validateOnly: true` has Google check the request without creating anything (`200`).

## Billing (monthly invoicing)

When a manager above the account has monthly invoicing with Google, you can bill the account to it through the API, so nobody has to open Google Ads.

Google's API cannot add a card or other payment method: without monthly invoicing, billing has to be added in Google Ads.

[`GET /v1/ads/accounts/billing`](/ad-accounts/get-ad-account-billing) lists the payments accounts the account can be billed to, plus its billing setups and account budgets:

```bash
curl "https://zernio.com/api/v1/ads/accounts/billing?accountId=66b2e19d8c3f5a7e9d0b1c2d&adAccountId=5551234567" \
  -H "Authorization: Bearer $ZERNIO_API_KEY"
```

An empty `paymentsAccounts` means the manager has no monthly invoicing.

[`POST /v1/ads/accounts/billing`](/ad-accounts/set-up-ad-account-billing) bills the account to one of them and proposes its account budget:

```bash
curl -X POST "https://zernio.com/api/v1/ads/accounts/billing" \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
    "adAccountId": "5551234567",
    "paymentsAccountId": "1234-5678-9012-3456",
    "spendingLimit": 5000
  }'
```

Response (`201`):

```json
{
  "customerId": "5551234567",
  "paymentsAccountId": "1234-5678-9012-3456",
  "paymentsAccountName": "Acme Agency invoicing",
  "billingSetupId": "8640113662",
  "reusedBillingSetup": false,
  "accountBudgetProposalId": "8640043389",
  "validateOnly": false,
  "nextSteps": "Google reviews the account budget, usually within an hour. Ads serve once the billing setup and its budget are approved: read GET /v1/ads/accounts/billing for their status."
}
```

- The budget starts now and never ends. Omit `spendingLimit` for an unlimited budget; otherwise it caps spend in the account currency.
- Google reviews the budget offline, usually within an hour. Until then the billing setup reads `APPROVED_HELD` and ads do not serve.
- An account that already has billing gets a `409`. This call never replaces existing billing.
- If the budget is rejected after the billing setup was created, send the same request again: it reuses that setup.

**Not idempotent.** After a `502` with `details.creationStatus: "unknown"`, read the billing before trying again.

**Dry run.** Google has no dry run for billing setups, so `validateOnly: true` checks the payments account and the existing billing without creating anything (`200`).

## Manager links

A manager link connects a manager account to a client.

Invite a client with [`POST /v1/ads/accounts/manager-links`](/ad-accounts/invite-ad-account-to-manager):

```bash
curl -X POST "https://zernio.com/api/v1/ads/accounts/manager-links" \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
    "managerCustomerId": "9876543210",
    "clientCustomerId": "5550001111"
  }'
```

Response (`201`):

```json
{
  "managerCustomerId": "9876543210",
  "clientCustomerId": "5550001111",
  "managerLinkId": "345678902",
  "status": "PENDING"
}
```

The manager must be one the connection's Google user reaches, directly or under another manager. The client can be any Google Ads account.

The link stays `PENDING` until someone with access to the client accepts it, in Google Ads or through the call below.

**Not idempotent.** Google refuses a second invitation while one is pending.

**Dry run.** `validateOnly: true` has Google check the request without sending anything (`200`, with `managerLinkId: null`).

[`PATCH /v1/ads/accounts/manager-links`](/ad-accounts/update-ad-account-manager-link) changes one link, with an `action`.

Identify the link by `managerCustomerId`, `clientCustomerId` and `managerLinkId`, all from the hierarchy read.

| `action` | Acts as | What it does |
|---|---|---|
| `accept` | The client | Accepts a pending invitation. The connection's Google user needs direct access to the client account; access through a manager is not enough, because the link does not exist yet. |
| `decline` | The client | Declines a pending invitation, with the same access rule. |
| `cancel` | The manager | Withdraws a pending invitation. |
| `unlink` | The manager | Ends an active link. |

```bash
curl -X PATCH "https://zernio.com/api/v1/ads/accounts/manager-links" \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
    "managerCustomerId": "9876543210",
    "clientCustomerId": "5550001111",
    "managerLinkId": "345678902",
    "action": "accept"
  }'
```

The response has the same shape, with the `status` the link has after the call: `ACTIVE`, `REFUSED`, `CANCELED` or `INACTIVE`.

`validateOnly: true` checks the change without applying it.

## User access invitations

Invite someone to a Google Ads account by email, for example to hand a client account you created under your manager to its owner.

[`POST /v1/ads/accounts/user-invitations`](/ad-accounts/invite-ad-account-user) sends the invitation:

```bash
curl -X POST "https://zernio.com/api/v1/ads/accounts/user-invitations" \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
    "adAccountId": "5550001111",
    "emailAddress": "owner@client.com",
    "accessRole": "ADMIN"
  }'
```

Response (`201`):

```json
{
  "customerId": "5550001111",
  "emailAddress": "owner@client.com",
  "accessRole": "ADMIN",
  "validateOnly": false,
  "status": "PENDING",
  "invitationId": "123456789",
  "multiPartyAuthReviewId": null
}
```

| `accessRole` | The user can |
|---|---|
| `ADMIN` | Manage the account, its billing and its users |
| `STANDARD` | Edit campaigns, but not users |
| `READ_ONLY` | View the account |
| `EMAIL_ONLY` | Only receive the account's emails |

- `adAccountId` is required. The connection's Google user needs admin access to that account, directly or through a manager above it. An account created under your manager qualifies.
- The recipient accepts from the email, possibly with another Google account. Invitations expire after 20 days.
- **Multi-party approval.** Google can hold the invitation until a second admin of the account approves it in Google Ads. The response then has `status: "PENDING_APPROVAL"`, a `multiPartyAuthReviewId` and no `invitationId`, and no email goes out until it is approved.
- **Not idempotent.** Google refuses a second invitation while the email has one pending or awaiting approval.
- **Dry run.** Google has no server-side check for this call, so `validateOnly: true` reads the account's invitations with the same credentials and returns `400` if the email already has a pending one. Nothing is sent (`200`, with `invitationId: null`).

[`GET /v1/ads/accounts/user-invitations`](/ad-accounts/list-ad-account-user-invitations) lists the invitations of one account, `PENDING`, `DECLINED` or `EXPIRED`.

An accepted invitation leaves the list, which can take Google up to 24 hours, and one awaiting multi-party approval appears only once approved.

The list is cached for 10 minutes and dropped on every invite or revoke.

[`DELETE /v1/ads/accounts/user-invitations`](/ad-accounts/revoke-ad-account-user-invitation) with `accountId`, `adAccountId` and `invitationId` withdraws an invitation that has not been accepted.

### Invitations awaiting multi-party approval

An invitation held for approval blocks a new one to the same email until a second admin decides or the request expires. Withdraw it to invite again right away.

[`GET /v1/ads/accounts/user-invitations/pending-approvals`](/ad-accounts/list-ad-account-user-invitation-approvals) lists the held invitations of one account:

```json
{
  "customerId": "5550001111",
  "approvals": [
    {
      "multiPartyAuthReviewId": "987654321",
      "emailAddress": "owner@client.com",
      "accessRole": "ADMIN",
      "requestUserEmail": "ads@youragency.com",
      "createdAt": "2026-10-20 12:00:00.000000"
    }
  ],
  "cachedAt": null,
  "stale": false
}
```

[`DELETE /v1/ads/accounts/user-invitations/pending-approvals`](/ad-accounts/withdraw-ad-account-user-invitation-approval) with `accountId`, `adAccountId` and `multiPartyAuthReviewId` withdraws one:

```bash
curl -X DELETE "https://zernio.com/api/v1/ads/accounts/user-invitations/pending-approvals?accountId=66b2e19d8c3f5a7e9d0b1c2d&adAccountId=5550001111&multiPartyAuthReviewId=987654321" \
  -H "Authorization: Bearer $ZERNIO_API_KEY"
```

The response is `{ "customerId": "5550001111", "multiPartyAuthReviewId": "987654321", "status": "REVOKED" }`.

- Only the Google user who sent the invitation (`requestUserEmail`) can withdraw it, and only while no admin has approved or rejected it. Otherwise the call returns `400` with Google's reason.
- The list is cached for 10 minutes and dropped on every invite or withdrawal, so an approval made in Google Ads can take that long to show.

## Labels

Google Ads labels are free-form tags you attach to campaigns, ad groups, ads and keywords. They group them in reports and in the Google Ads UI.

Every label call takes `accountId`. Add `adAccountId` (the customer id) when the connection has several Google customers.

[`POST /v1/ads/labels`](/ad-accounts/create-ad-label) creates one.

`name` (up to 80 characters) is unique per customer, so a duplicate returns `400`. `backgroundColor` (`#RRGGBB`) and `description` (up to 200 characters) are optional.

```bash
curl -X POST "https://zernio.com/api/v1/ads/labels" \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
    "adAccountId": "1234567890",
    "name": "Q1 launch",
    "backgroundColor": "#1A73E8"
  }'
```

Response (`201`): `{ "customerId": "1234567890", "id": "22334455", "resourceName": "customers/1234567890/labels/22334455" }`.

Attach it with [`POST /v1/ads/labels/{labelId}/assignments`](/ad-accounts/attach-ad-label), sending up to 1,000 ids in each target list:

```bash
curl -X POST "https://zernio.com/api/v1/ads/labels/22334455/assignments" \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
    "adAccountId": "1234567890",
    "campaignIds": ["21874563210"],
    "adSetIds": ["165489732105"],
    "adIds": ["165489732105~735198246013"],
    "keywordIds": ["165489732105~987654321"]
  }'
```

Response (`200`): `{ "customerId": "1234567890", "labelId": "22334455", "attached": 3, "unchanged": 1 }`.

All ids are Google's own:

| Target | Id |
|---|---|
| Campaigns, ad groups | Their numeric ids |
| Ads | `{adGroupId}~{adId}`, the composite id from the resource name |
| Keywords | `{adGroupId}~{criterionId}`, the tail of `resourceName` on [`GET /v1/ads/keywords`](/platforms/google-ads/keywords#synced-keyword-criteria) |

Attaching is idempotent. A target that already carries the label is counted in `unchanged` instead of failing the call.

[`DELETE` on the same path](/ad-accounts/detach-ad-label) takes the same body and removes the label from those targets. It reports `detached` and `unchanged`.

The rest of the lifecycle:

- [`GET /v1/ads/labels`](/ad-accounts/list-ad-labels) lists every non-removed label in one page, with `id`, `name`, `status`, `backgroundColor` and `description`. It is cached for 10 minutes and served with `stale: true` when Google's quota runs out. On a Meta ad account the same endpoint lists Meta's ad labels.
- [`PATCH /v1/ads/labels/{labelId}`](/ad-accounts/update-ad-label) changes the name, color or description. Only the fields you send are written, and `description: ""` clears it.
- [`DELETE /v1/ads/labels/{labelId}`](/ad-accounts/remove-ad-label) removes the label, and Google drops it from everything it was attached to.

Creating, editing and assigning labels is Google-only. Those calls return `501` on other platforms.

## Related

- [Keywords](/platforms/google-ads/keywords): where the keyword ids for `keywordIds` come from.
- [Limits and errors](/platforms/google-ads/reference): quotas and what the API cannot do.
- [Get manager account hierarchy](/ad-accounts/get-ad-account-hierarchy) and [Attach a Google Ads label](/ad-accounts/attach-ad-label): every field.