# Automated ad rules

Pause, reactivate or rebudget campaigns, ad sets and ads on a schedule when their metrics cross a threshold, or get a webhook when they do.

An ad rule watches one ad account for you. On a schedule, it checks every campaign, ad set or ad against a set of conditions ("cost per conversion above 25 over the last 7 days") and acts on each one that matches: pause it, turn it back on, change its daily budget, or send you an `ad_rule.triggered` webhook. Rules run on Meta, Google, TikTok, LinkedIn, Pinterest, X and Reddit.

## Create a rule safely

### Begin with a template

`GET /v1/ads/rules/templates` gives you finished rules to copy, for jobs such as cutting off ad sets that lose money, giving winners more budget, pausing ads that bring no conversions, spotting tired creative and warning you when spend jumps. A template is already a full rule body. Its `platforms` field names the ad networks it suits; `null` means every network.

Take one, set `name`, `accountId` and `adAccountId`, and adjust the thresholds. Money amounts are in the ad account's currency.

### Create it switched off

This rule checks every hour and pauses any ad set that spent at least 50 in the last 7 days at a cost per conversion above 25:

```bash cURL
curl -X POST https://api.pozy.ai/v1/ads/rules \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Pause expensive ad sets",
    "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
    "adAccountId": "act_1234567890",
    "level": "adset",
    "conditions": { "all": [
      { "metric": "cpa", "window": "last_7d", "op": ">", "value": 25 },
      { "metric": "spend", "window": "last_7d", "op": ">=", "value": 50 }
    ] },
    "actions": [{ "type": "pause", "maxChangesPerDay": 10 }, { "type": "notify" }],
    "schedule": { "everyMinutes": 60 },
    "cooldownMinutes": 1440,
    "enabled": false
  }'
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/ads/rules", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: "Pause expensive ad sets",
    accountId: "66b2e19d8c3f5a7e9d0b1c2d",
    adAccountId: "act_1234567890",
    level: "adset",
    conditions: {
      all: [
        { metric: "cpa", window: "last_7d", op: ">", value: 25 },
        { metric: "spend", window: "last_7d", op: ">=", value: 50 },
      ],
    },
    actions: [{ type: "pause", maxChangesPerDay: 10 }, { type: "notify" }],
    schedule: { everyMinutes: 60 },
    cooldownMinutes: 1440,
    enabled: false,
  }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
console.log(data.rule.id);
```
```python Python
import os, requests

resp = requests.post(
    "https://api.pozy.ai/v1/ads/rules",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    json={
        "name": "Pause expensive ad sets",
        "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
        "adAccountId": "act_1234567890",
        "level": "adset",
        "conditions": {"all": [
            {"metric": "cpa", "window": "last_7d", "op": ">", "value": 25},
            {"metric": "spend", "window": "last_7d", "op": ">=", "value": 50},
        ]},
        "actions": [{"type": "pause", "maxChangesPerDay": 10}, {"type": "notify"}],
        "schedule": {"everyMinutes": 60},
        "cooldownMinutes": 1440,
        "enabled": False,
    },
)
print(resp.json()["rule"]["id"])
```

The response (`201`) is the rule, with `lastRunAt` and `nextRunAt`. `enabled` defaults to `true`, so send `false` explicitly while you test.

### Preview against today's numbers

`POST /v1/ads/rules/{ruleId}/preview` evaluates the conditions on current metrics and acts on nothing. It returns every object with `matched`, the actual value of each condition, and the time zone and currency used.

```bash
curl -X POST https://api.pozy.ai/v1/ads/rules/66f9a0b1c2d3e4f5a6b7c8d9/preview \
  -H "Authorization: Bearer $POZY_API_KEY"
```

```json
{
  "evaluatedAt": "2026-10-30T09:00:00.000Z",
  "today": "2026-10-30",
  "timezone": "America/New_York",
  "timezoneAssumed": false,
  "currency": "USD",
  "matched": 1,
  "objects": [
    { "id": "120210000000000001", "name": "Retargeting, 7 day", "campaignId": "120210000000000000", "matched": true, "conditions": [ { "metric": "cpa", "actual": 31.4 }, { "metric": "spend", "actual": 188.2 } ] }
  ]
}
```

### Turn it on

```bash
curl -X PATCH https://api.pozy.ai/v1/ads/rules/66f9a0b1c2d3e4f5a6b7c8d9 \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": true }'
```

## Rule fields

| Field | Meaning |
| --- | --- |
| `accountId` | Your ads connection (`metaads`, `googleads`, `tiktokads`, `linkedinads`, `pinterestads`, `xads` or `redditads`), not an account you post with. |
| `adAccountId` | Which ad account of that connection to watch. Take it from `GET /v1/ads/accounts?accountId=...`; it has to be one the connection syncs. |
| `level` | `campaign`, `adset` or `ad`. Each object at that level is checked on its own. |
| `campaignIds` | Optional. Only these campaigns, or the ad sets and ads inside them. |
| `conditions.all` | 1 to 10 conditions. All must hold for an object to match. |
| `actions` | What to do with each match (below). |
| `schedule.everyMinutes` | `60`, `180`, `360`, `720` or `1440`. |
| `cooldownMinutes` | How long an object rests after the rule acts on it. Default `1440` (a day), up to 43,200. |
| `enabled` | Whether it runs. Default `true`. |

### Conditions

Each condition checks one metric, measured over one time window, against a threshold:

```json
{ "metric": "roas", "window": "last_3d", "op": "<", "value": 1.5 }
```

- **Metrics:** `spend`, `impressions`, `clicks`, `conversions`, `cpc`, `cpm`, `cpa`, `roas`, `reach`, `frequency`, and `ctr` as a percentage.
- **Windows:** `today`, `yesterday`, `last_3d`, `last_7d`, `last_14d`, `last_30d`. A day is a day in the ad account's time zone, and the multi-day windows count today.
- **Operators:** `>`, `>=`, `<`, `<=`.
- `reach` and `frequency` need de-duplicated reach, which only Meta and TikTok provide. On other networks they return `400`.
- A rate with nothing to divide by never matches. A `cpa` condition skips objects with no conversions, so pair it with a `spend` floor as in the example.

### Actions

| `type` | What it does |
| --- | --- |
| `notify` | Sends the `ad_rule.triggered` webhook with the objects that matched. Subscribe an endpoint first (see [Webhooks](https://pozy.ai/docs/webhooks)), or a notify-only rule only leaves run history. |
| `pause` | Pauses the object. |
| `activate` | Turns back on what this rule paused, once the cooldown has passed. It never touches objects you paused yourself. |
| `adjust_budget` | Changes the daily budget. `direction` is `increase` or `decrease`; `percent` is up to 100 up or 90 down; `limit` is the ceiling for increases and the floor for decreases. Not on `level: "ad"`. |

Combine `notify` with no more than one changing action: `pause`, `activate` or `adjust_budget`. Those three accept `maxChangesPerDay`, from 1 to 100, which caps how many objects the rule may change in a rolling 24 hours, counting all its runs. Objects past the cap are skipped with `daily_limit_reached` and picked up on a later run. A rule that changes objects must also have a `cooldownMinutes` no shorter than its schedule.

## What a rule did

`GET /v1/ads/rules/{ruleId}/runs` shows a rule's runs over the past 31 days, latest first. For the workspace as a whole, `GET /v1/ads/rules/activity` lists each change that any rule made.

| `outcome` | What happened |
| --- | --- |
| `triggered` | The rule acted on the objects that matched, listed in `actedObjectIds`. |
| `no_match` | No object passed all the conditions. |
| `cooldown` | Objects matched, but all were resting. |
| `failed` | Metrics could not be read, every action failed, or a notify-only rule could not queue its webhook. `error` says why. |

Each run's `evaluation.actions[]` records `status` (`done`, `skipped` or `failed`) per object, with a `reason` on skips and `previousDailyBudget` and `newDailyBudget` on budget changes. `evaluation.notification.status` is `done` once the webhook is queued.

## The webhook

`ad_rule.triggered` fires when a run of a rule with a `notify` action acts on at least one object.

- `objects[]` lists only the objects the run acted on. Objects in cooldown are left out, and a run where every match is resting sends nothing.
- `objects[].conditions` holds every condition again, with `actual` set to the object's real value.
- `objects[].actions` records what the rule changed on the object; a rule that only notifies leaves it empty.
- `run.today`, `run.timezone` and `run.currency` tell you which calendar day and currency the numbers used. `run.timezoneAssumed` is `true` when the ad account has no known time zone and UTC was used.

The full payload is in the [events reference](https://pozy.ai/docs/webhooks/events#ad-rule-triggered).

## How rules behave

- **Metrics lag.** Rules read the metrics Pozy syncs from each network, which can trail the network's own dashboard.
- **Batch size.** A run changes 20 objects at most; any further matches are handled by the next run.
- **Failed actions** get no cooldown, so the next run retries them.
- **Your own pauses.** `activate` only restores what the rule paused. If you turn an object on and off again between two runs, the rule cannot tell your pause from its own.
- **Google budgets** live on campaigns, so `adjust_budget` on Google needs `level: "campaign"`.
- **Ads access.** If the workspace loses ads access, every rule pauses until it returns.
- **Free allowance.** Past the free allowance of live ads without a payment method, reactivations and budget increases are skipped with `ads_allowance_exceeded`. Pauses and decreases keep working.
- **Disabled rules** stop running until you set `enabled: true` again. `DELETE /v1/ads/rules/{ruleId}` removes a rule for good.

## Errors you will see

| Status and code | Cause | Fix |
| --- | --- | --- |
| `403 ad_account_not_selected` | The ad account is not one the connection syncs | Add it with `adAccountIds` on the connect call, which replaces the selection. |
| `400 invalid_field_value` on `cooldownMinutes` | The rule changes objects and its cooldown is shorter than its schedule | Set `cooldownMinutes` to `schedule.everyMinutes` or more. |
| `400 invalid_field_value` on `actions` | A budget change at ad level, or on ad sets in Google | Switch to `level: "campaign"`, or `adset` on networks other than Google. |
| `400 invalid_field_value` on `conditions` | `reach` or `frequency` on a network other than Meta or TikTok | Pick a different metric. |
| `403 ads_allowance_exceeded` | A reactivating or budget-raising rule past the free live-ads allowance | Add a payment method. |
| `404 resource_not_found` | No such rule in this workspace | Check the `ruleId`. |

## Endpoints

| Endpoint | What it does |
| --- | --- |
| [`GET /v1/ads/rules`](https://pozy.ai/docs/api/listAdRules) | List ad rules |
| [`POST /v1/ads/rules`](https://pozy.ai/docs/api/createAdRule) | Create an ad rule |
| [`GET /v1/ads/rules/templates`](https://pozy.ai/docs/api/listAdRuleTemplates) | List ready-made ad rules |
| [`GET /v1/ads/rules/activity`](https://pozy.ai/docs/api/listAdRuleActivity) | List every change ad rules made |
| [`GET /v1/ads/rules/{ruleId}`](https://pozy.ai/docs/api/getAdRule) | Get an ad rule |
| [`PATCH /v1/ads/rules/{ruleId}`](https://pozy.ai/docs/api/updateAdRule) | Update an ad rule |
| [`DELETE /v1/ads/rules/{ruleId}`](https://pozy.ai/docs/api/deleteAdRule) | Delete an ad rule |
| [`POST /v1/ads/rules/{ruleId}/preview`](https://pozy.ai/docs/api/previewAdRule) | Preview an ad rule |
| [`GET /v1/ads/rules/{ruleId}/runs`](https://pozy.ai/docs/api/listAdRuleRuns) | List an ad rule's runs |

## Webhook events

| Event | When it fires |
| --- | --- |
| [`ad_rule.triggered`](https://pozy.ai/docs/webhooks/events#ad-rule-triggered) | Ad rule triggered. |
