Automation
The automation language
The complete reference: entities, fields, time ranges, operators, functions, and actions.
Rules are written in a small, readable language built for Amazon Ads: loop over an entity, check conditions on its metrics, call actions. The editor has syntax highlighting, autocomplete, inline error checking, and a built-in reference; every run is recorded and revertible.
The basic shape
| Statement | Meaning |
|---|---|
FOR EACH entity [AS name] [IN range]: | Loop over an entity. Default window: LAST 30 DAYS. OVER/USING are accepted for IN. |
FOR EACH entity IN parent [OVER range]: | Inside a block: loop over what is inside the row you already have — see child loops. |
IF condition: / WHEN condition: | Run the indented body when true; bodies may nest. |
LET name = expression | Name a value or related entity. Top-level LETs are visible everywhere; a LET in a body is visible in that body and below. |
END | Close a FOR EACH block. |
# comment | Ignored to the end of the line (except inside quotes). |
Bodies are indented two spaces. Keywords, entities, fields, and functions are case-insensitive.
Entities you can loop over
Hop to related entities with a dot: keyword.campaign, searchTerm.adGroup, campaign.portfolio, campaign.placement. Related objects can be named: LET campaign = keyword.campaign.
Inside the row you already have: child loops
A FOR EACH written inside a block reads what is inside the row it is standing on. Where the outer loop says which table to read, the child loop names a parent object — and the rows it hands back are ordinary rows, with the same fields, metrics and actions they have in a top-level loop.
This is what lets one rule cross two dimensions. "A campaign for every proven search term and every proven ASIN in its ad group" is one loop inside another:
# ALLOW CREATION
FOR EACH searchTerm IN LAST 90 DAYS:
IF searchTerm.orders > 25 AND searchTerm.acos < 20%:
FOR EACH product IN searchTerm.adGroup OVER LAST 30 DAYS:
IF product.orders > 5:
LET name = CONCAT("MD, ", product.asin, ", ", searchTerm.searchTerm)
LET camp = createCampaign(name, budget: $10, targeting: "manual", portfolio: createPortfolio("MerchDash"))
LET ag = camp.createAdGroup(name, $0.25)
ag.addAsin(product.asin)
ag.createKeyword(searchTerm.searchTerm, "exact", $0.25)
END
END
| Rule | What it means |
|---|---|
What follows IN is an object | searchTerm.adGroup, keyword.campaign, a LET holding one, or FIND_AD_GROUP(…) — never a timeframe. The timeframe goes after OVER, and defaults to the outer block's window. |
| Eight pairings | product and adGroup from a campaign; product, keyword and target from an ad group — the same contents ASINS(), AD_GROUPS(), KEYWORDS() and TARGETS() list. Plus keyword, target and adGroup from an accumulated ASIN row. |
Not ASINS(ag) | The collection functions answer with names, for IN, COUNT() and JOIN(). A child loop reads the rows straight out of the parent, so the parent is what follows IN. |
| One level | A child loop cannot contain another. Two levels would read a table for every row of a table. |
| Read once per parent | However many outer rows point at the same ad group, it is read once for the whole run — and shares that read with any ASINS()/HAS_ASIN() in the same rule. |
| Its own scope | A LET written inside the child loop belongs to the child row. The outer row is still in reach by name: searchTerm.searchTerm in an expression, {searchTerm.orders} in a note — a bare {searchTerm} is the row itself, not a value. |
A child loop iterates what is in the ad group right now, from the same snapshot the Campaigns pages read — not only what Amazon reported activity for. Metrics on those rows are read for the window in force, so an ASIN that never delivered reads zero rather than disappearing.
Across every campaign: accumulated reports
A keyword row is one keyword in one ad group, and a product row is one ad in one campaign. So a keyword that looks acceptable in each of ten campaigns, but has quietly spent $40 with no orders across all of them, is invisible to an ordinary loop. An accumulated report is the rollup the Accumulated tabs show, built for the run and iterated like any other entity:
CREATE ACCUMULATED KEYWORD REPORT IN LAST 60 DAYS
FOR EACH accumulated_keyword:
IF accumulated_keyword.spend >= $40 AND accumulated_keyword.orders = 0:
accumulated_keyword.pauseEverywhere()
accumulated_keyword.note("{spend:money} across {campaigns} campaigns, no orders")
END
The same works for ASINs with CREATE ACCUMULATED ASIN REPORT and FOR EACH accumulated_asin. The report exists only for that run — nothing appears under Reports.
| Rule | Why |
|---|---|
| One report of each kind per rule | Summing every campaign is the most expensive thing a rule can do. A second timeframe belongs in a second rule. |
| The loop variable is fixed | It is always accumulated_keyword / accumulated_asin — no AS. For a shorter name: LET kw = accumulated_keyword. |
The timeframe lives on the CREATE line | The FOR EACH takes none of its own, and neither do inline windows inside it — the row is already summed over exactly one window. Up to 180 days by default (your operator can change the ceiling); LIFETIME is refused. |
| No relation hops | The row spans many campaigns, so there is no single one to hop to. Ask the counts instead: campaigns, adGroups, liveCampaigns, liveAdGroups. |
| Rows with nothing running are skipped | A report lists what delivered in its window, including what you have since switched off — so a pause rule would otherwise re-pause the same things every run. This is the same thing as the Active only tick on the Accumulated tabs, and it is on by default: there is nothing to write for it. Write # ACTIVE ONLY above the CREATE line if you want to see it stated (it changes nothing), or # INCLUDE PAUSED KEYWORDS / # INCLUDE PAUSED ASINS to keep the switched-off rows. Without a noun, # ACTIVE ONLY covers both reports. |
Fields. Every metric, plus keywordText, matchType, asin, sku, the counts campaigns / adGroups / keywords / ads, their live and paused halves in full — live_keywords / paused_keywords, live_ads / paused_ads, live_campaigns / paused_campaigns, live_ad_groups / paused_ad_groups, and keyword_state / asin_state — ENABLED when anything can still deliver, PAUSED when nothing can. Both spellings work: live_keywords and liveKeywords. An ASIN row also splits its ads by ad product: sp_ads is the Sponsored Products share of ads, and sb_ads counts the Sponsored Brands ads whose creative features the ASIN right now — a snapshot that is not part of ads and carries no metrics of its own, because Amazon publishes no per-ASIN reporting for Sponsored Brands (why).
Accumulated rows carry one metric the per-entity loops don’t: cpo, cost per order (spend ÷ orders). Like every ratio it has no value when the denominator is zero, so a row with no orders is skipped by a numeric comparison rather than matching as infinity.
Actions say how far they reach. One row is many real entities, so pause() does not apply to it — use pauseEverywhere(), setBidEverywhere(v) or negateEverywhere([match]). Each acts on every instance that delivered in the report's window and is still enabled, and each writes ordinary History entries you can revert.
Acting on a proven ASIN's bids
An ASIN has no bid. A bid lives on the keywords and targets that advertise it — which is why there is no setBidEverywhere() for an ASIN, and never will be. An ASIN row therefore has child loops: they iterate what that ASIN delivered through inside the report's timeframe, as ordinary rows you can read and act on. Everything it ran in is handed back, so the report decides which ASINs and the rule decides what to touch.
CREATE ACCUMULATED ASIN REPORT IN LAST 60 DAYS
FOR EACH accumulated_asin:
IF accumulated_asin.orders > 10 AND accumulated_asin.cpo < $2.50:
FOR EACH keyword IN accumulated_asin:
IF keyword.state = "ENABLED":
keyword.setBid(MIN($1.50, keyword.bid * 1.10))
keyword.note("ASIN {accumulated_asin.asin} proven across campaigns")
END
END
The report decides which ASINs; the child loop does the acting. keyword, target and adGroup all work this way. Note the placeholder: inside the child loop a bare {asin} would ask the keyword for an ASIN it does not have, so name the outer row — {accumulated_asin.asin}.
| Worth knowing | Why |
|---|---|
| An ad group can hold several ASINs | Then its keywords are not exclusively the winner's, and raising them lifts the other products too. The loop reaches those ad groups — narrowing is the rule's call, not the engine's. When it matters, guard with COUNT(ASINS(keyword.adgroup)) = 1, or <= 3 if your ad groups hold variations of one product. |
| Paused ad groups are included | A report lists what delivered in its window, and a paused ad group may well have. Filter with keyword.state or keyword.adgroup.state if you only want live ones. |
| Cap the blast radius | COUNT(KEYWORDS(keyword.adgroup)) <= 20 keeps a rule off ad groups with hundreds of keywords. Both counts are free: the rule has already read that ad group. |
| Keep the child loop's tests simple | Judge the ASIN on the report row; inside the loop test state and bid only. Re-filtering the child rows on their metrics is what makes these rules slow. |
| Put the bid loop directly under the report | Nesting is one level deep, so FOR EACH keyword IN accumulated_asin — not inside a FOR EACH adGroup IN accumulated_asin, which would be two. |
Metrics & fields
Read any metric off an item over the loop's window (or an inline one):
Plus settings and identity per entity: bid, bidInherited, budget, state, name, matchType, keywordText, searchTerm, asin, adType, targetingType, biddingStrategy, portfolioName, adGroupCount, the owning campaign's campaignName / campaignState (on ad groups, keywords, targets, search terms and ASINs — no relation hop needed), placement percentages (top_of_search, product_pages, rest_of_search) — whose own names are placementTopPct / placementProductPagePct / placementRestOfSearchPct, so either spelling reads, and recency fields days_since_bid_change / days_since_budget_change / days_since_placement_change. Friendly aliases work everywhere (daily_budget, purchases, conversion_rate, tos, …) — the editor autocompletes them all.
Which kind of target is this? Use target.targeting. It reads exactly one of "close match", "loose match", "substitutes", "complements" (the four Sponsored Products auto-targeting clauses) or "product" for manual product and category targeting, and nothing at all when the row does not say — normal for Sponsored Brands and Display targets. It is what you want for a per-clause rule, such as a lower maximum bid on complements than on close match. target.targetingClause is the same field under its original name and still works.
targetingMode is not that field: it reads "AUTO" or "MANUAL" — whether the target sits in an auto or a manual campaign — and never names the clause. target.targetingMode = "SUBSTITUTES" therefore matches nothing, and the editor refuses it rather than letting the rule run to no effect. Any time you mean a clause, write target.targeting.
Has this search term already been negated? searchTerm.negated is TRUE when an exact negative already covers that term in its ad group or campaign — counting both the negatives a sync read back from Amazon (so negatives you or another tool created in the console are seen too) and the ones MerchDash itself applied. It is the gate every negation recipe filters on, and the reason a negation rule doesn't re-propose the same term every run:
Less common fields
Everything above covers most rules. These are real too, and read the same way:
| Field | On | Reads |
|---|---|---|
negated | searchTerm | TRUE when an exact negative already covers the term — see above. |
defaultBid | adGroup | The ad group's default bid — what a keyword with no bid of its own actually bids (keyword.bid already resolves to it, flagged bidInherited). |
servingStatus | campaign, adGroup | Amazon's own delivery status, beyond enabled/paused — why a live campaign isn't serving (out of budget, incomplete billing, ended). Empty until a sync has read it. |
budgetType | campaign | How Amazon meters the budget, e.g. DAILY. |
startDate / endDate | campaign | The campaign's own schedule at Amazon. endDate is empty on a campaign that runs indefinitely. |
creationDate | campaign, adGroup | When Amazon created it — the field for “leave brand-new campaigns alone”. |
lastUpdated | campaign, adGroup | When Amazon last recorded a change to the entity, by anyone — MerchDash, the console, another tool. |
updatedAt | campaign, adGroup, keyword, target, product | When MerchDash last synced the row. A sync timestamp, not an edit — don't read it as “when this last changed”; that is lastUpdated. |
lastBidChangeAtlastBudgetChangeAtlastPlacementChangeAt | keyword, target / campaign | The timestamp behind days_since_bid_change and its siblings. Same source: changes MerchDash applied, so a bid you edited in the Amazon console is not one of them. |
lastBidChangeAt, and days_since_bid_change is treated as infinite — so days_since_bid_change >= 10 matches it. That is the common case, not an edge case: it is what makes a cool-down rule act on entities it has never seen before. When a note prints the number, an untouched entity reads “many” days.Time ranges
The window after FOR EACH is optional and defaults to the last 30 settled days. Any metric read can carry its own inline window: keyword.clicks IN LAST 7 DAYS.
| Range | Means |
|---|---|
LAST 7 DAYS / LAST 30 DAYS / LAST 90 DAYS | The last N settled days (excludes the two newest, still-settling days — see attribution). |
YESTERDAY | The latest settled attribution day (currently two UTC days behind today). |
TODAY | The current UTC day from the latest sync — partial and unlagged. |
N DAYS AGO | One exact unlagged UTC day. |
LIFETIME | All stored history. |
FROM 8 DAYS AGO TO 35 DAYS AGO | An offset baseline window, unlagged. |
BEFORE 8 DAYS AGO | All history older than 8 days ago (new-vs-historical checks). |
FROM 2026-01-01 TO 2026-01-31 | Explicit start/end dates. |
Conditions & operators
| Kind | Syntax |
|---|---|
| Logic | AND, OR, NOT, parentheses (precedence: NOT, AND, OR) |
| Comparison | =/==/IS, !=/<>/IS NOT, <, <=, >, >= (text equality is case-insensitive) |
| Lists | IN, NOT IN — e.g. keyword.matchType IN ["EXACT", "PHRASE"] |
| Text | CONTAINS, NOT CONTAINS / DOES NOT CONTAIN, CONTAINS ANY [list], STARTS WITH, ENDS WITH (case-insensitive) |
| Arithmetic | + - * / % (+ concatenates text; % is modulo), unary - |
| Units | money $0.85, percent 45% (= 0.45), plain numbers, TRUE/FALSE, NONE, ["lists"] |
| Inline window | expression IN range — e.g. keyword.acos IN LAST 7 DAYS > keyword.acos IN LAST 90 DAYS. A window takes everything to its left, so parenthesise it when a comparison follows it mid-condition: keyword.clicks > 20 AND (keyword.acos IN LAST 30 DAYS) <= 30% — without the brackets the <= 30% would test the whole condition, and the rule is rejected. |
Settings read as they are now. Time ranges apply to metrics only. Fields such as bid, budget, biddingStrategy, state, and the placement percentages always return their current value — an inline window on one (keyword.bid IN LAST 30 DAYS) does not look up what it used to be. To react to a change, use DAYS_SINCE_CHANGE("bid") / LAST_CHANGE("budget") — and DAYS_SINCE_CHANGE("top of search") for a placement.
Placement adjustment cool-downs
A Sponsored Products campaign carries three placement bid adjustments — top of search, product pages, rest of search — and campaign.setPlacementBid() changes them. Because a placement adjustment needs several days of traffic before its effect is visible, a rule that adjusts one should refuse to adjust it again straight away; otherwise the next run judges the same data and moves it a second time.
Ask how long ago it changed the same way you would for a bid or a budget:
| Written as | Answers |
|---|---|
DAYS_SINCE_CHANGE("placement")campaign.daysSincePlacementChange | Days since the newest of the campaign's three adjustments. The two spellings are the same value; the field also has the aliases days_since_placement_change and last_placement_change / campaign.lastPlacementChangeAt for the timestamp itself. |
DAYS_SINCE_CHANGE("top of search")DAYS_SINCE_CHANGE("product pages")DAYS_SINCE_CHANGE("rest of search") | Days since that one placement changed, so a top-of-search rule is not held off by yesterday's product-pages change. Every spelling setPlacementBid() accepts works here too (tos, detail page, PLACEMENT_TOP, and an optional placement prefix). |
LAST_CHANGE("placement") | The timestamp instead of the day count, or NONE. |
The count covers every placement change MerchDash applied — from a rule and from a bulk edit on the Campaigns page alike — and is Infinity for a campaign it has never adjusted, so a first run still matches. Placement recency is campaign-level and Sponsored Products only; on a keyword, target or ad group it reads Infinity. The percentages themselves stay read-only: campaign.placement.top_of_search reads, setPlacementBid() writes.
DAYS_SINCE_CHANGE() and LAST_CHANGE() take "bid", "budget", "placement" or a placement name — nothing else. A name outside that list is refused — when you save the rule if you wrote it as a literal, and when the rule runs if it came from a LET variable or anything else computed. A cool-down that cannot read its field would answer “never changed” for everything and quietly let the whole account through, so it stops rather than pretends.Entities with no impressions
Amazon only reports a keyword, target or ASIN on days it actually showed, so one with zero impressions in the window has no report row at all. A FOR EACH normally iterates what was reported — which is where its metrics come from — so those silent entities used to be invisible to every rule. On a quiet portfolio that can be most of it.
MerchDash decides this per loop, with no syntax on your part. A silent row reads as zero impressions, clicks, cost, orders and sales (ACOS, ROAS, CTR and CVR are undefined, so every comparison against them is false).
When they are included
First, the condition has to be able to match one at all. keyword.impressions < 1000 can; keyword.clicks > 7 never can, so that loop keeps reading reported rows only and stays exactly as fast as before.
Then it depends on what the rule does:
setBidandnote— always included. Raising the bid on something nobody has seen yet is the entire point.pause,archive,enable,setState,addNegative,createKeyword,createTarget,addAsin— only when the rule tests impressions. Writeimpressions = 0orimpressions < 50and it reaches them.createPortfolio,createCampaign,createAdGroup— never. They create something, so a block that calls one reads reported rows only, even though it is written inside aLETrather than as an action.
The reason for that last rule is that clicks = 0 and orders = 0 describe something that ran and failed — that is how almost every “pause the losers” rule is written. Something that never ran is a different thing, and only an impressions test says so. Without that distinction, a rule meant to pause a handful of duds would pause every idle keyword and ASIN in the account.
FOR EACH product IN LAST 60 DAYS:
IF product.impressions = 0: # reaches ASINs that never ran
product.pause()
END
FOR EACH keyword IN LAST 30 DAYS:
IF keyword.clicks = 0: # only keywords that DID run
keyword.pause()
END
What is covered
Keywords, targets and ASINs work as described. Campaigns and ad groups have always been read from the full stored list, so one with no traffic already appears with zeros. Search terms cannot be: a search nobody typed does not exist.
Archived entities are never included, whichever rule you write.
Forcing it
Override the decision with a comment above the loop — it applies to every FOR EACH below it, until another one overrides it:
# INCLUDE ZERO IMPRESSIONS
FOR EACH keyword IN LAST 30 DAYS:
IF keyword.campaign.portfolioName = "Winter":
keyword.setBid(keyword.bid + $0.02)
END
# EXCLUDE ZERO IMPRESSIONS forces the opposite, which is worth doing on a very large account when you only care about entities that actually ran.
Looking things up by name
A rule loops over one kind of item, and every relation points upward — a keyword knows its campaign, but a campaign does not list its keywords. The lookups fill that in: they fetch a specific campaign, ad group or portfolio by name from anywhere in a rule, and read what is inside it.
Three things worth knowing. A lookup that finds nothing answers NONE, so test it
with IS NONE / IS NOT NONE — and calling an action on NONE
stops the run rather than quietly doing nothing, because a rule with a stale name would otherwise
look like a rule that simply had nothing to do. A name matching two items also
stops the run and names both IDs: names are not unique on Amazon, so the rule cannot guess which
one you meant — use the _ID form. And a lookup inside a loop costs one read no matter
how many rows the loop has, so it is safe to write it there.
Paused & archived campaigns
The mirror image of the section above. A campaign you paused part-way through the window still reported for the days before that, so everything inside it — keywords, targets, ad groups, ASINs, search terms — is still in the data a FOR EACH reads. Rewriting bids there costs you part of the run's change limit and can never win an auction.
Skip paused and archived campaigns in Profile → General (on by default) leaves them out. Two kinds of loop keep their paused campaigns automatically, so the setting can't turn a rule into a no-op:
- A loop that can run
enable()orsetState()— a reactivation rule has to be able to see what is off. - A loop that reads campaign state itself:
campaign.state,keyword.campaignState,target.campaign.state— or the same hop bound to a variable first,LET campaign = keyword.campaignthencampaign.state. You have already said what you want.
Archived campaigns are skipped either way — with the setting on, with it off, and for both of the loops above. Amazon rejects every write into an archived campaign, so those rows can only become failures in your history. Turning the setting off brings back paused campaigns, nothing more.
Force it per rule with a comment above the loop, scoped exactly like the zero-impressions directive:
# INCLUDE PAUSED CAMPAIGNS
FOR EACH keyword IN LAST 30 DAYS:
IF keyword.acos > 45%: # prepare bids before relaunching
keyword.setBid(keyword.bid * 0.9)
END
# EXCLUDE PAUSED CAMPAIGNS forces the skip on, even when the setting is off and even for a loop that would otherwise exempt itself.
Actions
| Action | On | What it does |
|---|---|---|
.pause() / .enable() / .setState("PAUSED") | campaign, adGroup, keyword, target, product | Pause or enable the item. |
.setBid(amount) | keyword, target | Set the bid — compute it, e.g. keyword.bid * 0.9. |
.setBudget(amount) | campaign | Set the daily budget. |
.setBiddingStrategy("FIXED BIDS") | campaign | Also "DYNAMIC BIDS - DOWN ONLY", "DYNAMIC BIDS - UP AND DOWN". |
.setPlacementBid("TOP OF SEARCH", 50) | campaign | Placement adjustment 0–900%; also "PRODUCT PAGES", "REST OF SEARCH". |
.setAutoTargetingBid("close match", 0.35[, when: "empty"]) | campaign, adGroup | Set the bid on one of an auto campaign's four predefined clauses — "close match", "loose match", "substitutes", "complements" — in every ad group of the campaign, or of the one ad group it is called on. This is the Adjust Auto Targeting Bid action from the Campaigns page, on a schedule. The clauses are read live from Amazon, which is what a FOR EACH target loop cannot do for you: MerchDash's target rows come from Amazon's reports, so a clause that has never delivered — usually loose match, substitutes and complements — has no row to loop over, and is exactly the clause a fill is for. when: "empty" is the dialog's only fill empty bids tick: it writes only the clauses that have no bid of their own and follow the ad group default. One Amazon read per campaign per run, however many clauses the rule sets. A campaign with no predefined clauses (manual targeting, Sponsored Brands or Display) is skipped and counted in the run log, so test campaign.targetingType = "auto" first. |
.setName(text) / .rename(text) | campaign | Rename the campaign. |
.moveToPortfolio(portfolio) | campaign | Move a campaign into a portfolio (setPortfolio is an alias). Takes the portfolio itself — PORTFOLIO("Brand A"), FIND_PORTFOLIO("Brand A") or createPortfolio("Brand A") — or NONE to take the campaign out of the one it is in. A portfolio createPortfolio is still creating works too: the move is written after it exists, and is held if the portfolio is not created. |
.addNegative(text[, "exact"|"phrase"]) | campaign, adGroup, keyword, target, searchTerm, product | Add a negative keyword; match type defaults to exact. It lands in the ad group the row delivered from when there is one, otherwise at campaign level. A term already covered by an exact negative is skipped; in product-targeting traffic an ASIN is written as a negative product target instead, and a plain query is ignored because that mode cannot negate text. |
.createKeyword(text[, match[, bid]][, state: "paused"]) | adGroup, keyword, target, searchTerm, product | Create a positive keyword in the entity's ad group — the harvesting primitive. Match: exact/phrase/broad (default exact), and the keyword is live unless state: "paused" says otherwise. It always creates in the entity's OWN ad group, so two kinds of row are skipped rather than sent: anything in an auto campaign (Amazon allows only negative keywords there) and any search term that is an ASIN rather than a shopper search (an ASIN only works as a product target). The run log counts both. |
.createTarget(PRODUCT_TARGET("B0…")[, bid][, state: "paused"]) | adGroup | Add a product or category targeting clause to an ad group, unless it is already there — including an ad group the same rule is still creating. The clause is live unless state: "paused" says otherwise. Build the clause with PRODUCT_TARGET(asin) or CATEGORY_TARGET(categoryId) — a bare string is not a clause. Get-or-create, so it needs no HAS_TARGET guard of its own and is safe on a rule that runs nightly. Sponsored Products, manual campaigns only: an auto campaign's four clauses are created by Amazon with the ad group and cannot be added to. Omitting the bid leaves the clause on the ad group's default bid. |
.addAsin("B0…"[, state: "paused"]) | adGroup | Advertise an ASIN in an ad group, unless it is already advertised there — including an ad group the same rule is still creating. The product ad is live unless state: "paused" says otherwise. Get-or-create, Sponsored Products only. Everything a rule adds to one ad group arrives as a single change to review, and reverting it archives only what Amazon actually created — never a clause or an ad that was already there. Values that are not ASINs (a shopper query out of a search-term column) are skipped and counted in the run log. |
log("…") | — | Write one line into this run's log. Not an action on an item — it is written on its own, like note(…), and takes the same placeholders. Use it to see what a rule reads: unlike a note, it is recorded even when the rule proposes no changes. Capped at 200 lines per run. Where to read it: a rule that changed nothing shows its log straight away, one numbered row per line, on the Automation page's "Log only" card. Where the log is not the whole story it waits behind a Run log toggle instead — on the run's row in Change History, and on its card in the approval queue. Everywhere it has a Download CSV button, with the same numbering as the screen, so a rule written purely to report can be opened in a spreadsheet. |
.note("…") | any | The reason logged with every action in the same body. Placeholders: {clicks}, {acos:percent}, {spend:money}, {field:number}, and any scalar LET variable. Any field the item carries works, in either spelling — {campaign_name}, {ad_group_name}, {portfolio_name}, {matchType}. A placeholder can also hop one relation, exactly like a condition does, so a rule can print the figure it decided on: {adGroup.cost:money}, {adGroup.acos:percent}, {campaign.name}. A field with no value reads None, as does a figure with nothing to compute from (ACOS with no sales); a name the item has no field for is left as written, so a typo stays visible. |
Functions
| Function | What it does |
|---|---|
MIN(…) / MAX(…) / CLAMP(v, lo, hi) | Smallest / largest / keep a number inside limits — the usual bid-guard tools. |
ROUND(v[, places]), FLOOR(v), CEIL(v), ABS(v) | Numeric shaping. |
IF(cond, then, else) | Choose a value; chain IF(c1, v1, c2, v2, …, default) for multi-branch logic. |
METRIC("clicks", "last7") | Read a metric for the current item in another window ("last7", "today", "8..35", "8.."). |
LAST_CHANGE("bid") / DAYS_SINCE_CHANGE("budget") | When the latest applied change happened — for cool-downs between adjustments. The field is "bid", "budget", or a placement (see below). Anything else is refused when the rule is saved. |
DAYS_SINCE_CHANGE("placement") | Days since the newest of a campaign's three placement adjustments. Name one — "top of search", "product pages", "rest of search" — to cool down that placement alone. |
PORTFOLIO("name") | Find a portfolio by exact (case-insensitive) name. It fails the run when there is no such portfolio, so it cannot be used to test whether one exists — FIND_PORTFOLIO() is the version that answers. |
FIND_CAMPAIGN("name")FIND_AD_GROUP(campaign, "name")FIND_PORTFOLIO("name") | Look something up by name, or NONE when there is none. Names match case-insensitively with spacing normalised, and archived items are ignored. What comes back is the real item: read its fields (camp.budget, camp.acos) and act on it (camp.setBudget($20)) exactly as if the loop were over it. An ad group needs its campaign, because ad group names are only unique inside one — and a NONE campaign gives a NONE ad group rather than an error. |
FIND_CAMPAIGN_ID("…")FIND_AD_GROUP_ID("…") | The same, by ID. Kept separate from the name lookups on purpose: a campaign really can be named "2024", so one value is never tried as both. |
HAS_ASIN(item, "B0…")HAS_KEYWORD(adGroup, "text"[, match])HAS_TARGET(adGroup, clause) | Is it already there? HAS_ASIN takes an ad group or a whole campaign. Omit the match type to ask about any of them. |
PRODUCT_TARGET("B0…")CATEGORY_TARGET("1234") | Name a targeting clause, for HAS_TARGET() and for comparing against TARGETS(). Amazon spells its clauses several different ways depending on where the row came from; these compare against all of them. |
ASINS(item), AD_GROUPS(campaign), KEYWORDS(adGroup), TARGETS(adGroup) | What is inside something, as a list of text. Works with the operators you already have: "B0…" IN ASINS(ag), COUNT(ASINS(ag)), JOIN(AD_GROUPS(camp), ", "). ASINS() takes an ad group or a campaign; the rest take an ad group. |
createPortfolio("name") | The portfolio with that name, creating it when there is none. Named like createKeyword(), but called on nothing — a portfolio belongs to the account, not to an item, so it is written on its own — so it never answers NONE and needs no guard before campaign.moveToPortfolio(pf), not even on the run that creates it. Get-or-create: one that is already there is not proposed again, so this is safe in a rule that runs nightly. A new portfolio is one change to review; approving it looks Amazon up first and adopts a portfolio somebody made in the console rather than building a second one. Two portfolios sharing the name, or an archived one, stops the rule instead of guessing. Reverting never removes a portfolio. |
createCampaign("name", budget: $20, targeting: "manual") | The Sponsored Products campaign with that name, creating it when there is none, so it never answers NONE. Only the name is positional: budget: and targeting: ("manual" or "auto") are required, and strategy:, portfolio: and state: are optional. Get-or-create, so it is safe in a rule that runs nightly — and a campaign that is already there is answered with as it stands: its budget is not overwritten, which is what setBudget() is for. Two campaigns sharing the name, or an archived one, stops the rule instead of guessing; so does finding one whose targeting type is not the one asked for, because Amazon can never change that. A new campaign is created live unless the rule says state: "paused". Needs # ALLOW CREATION. |
campaign.createAdGroup("name", $0.75[, state: "paused"]) | The ad group with that name inside that campaign — including a campaign the same rule is still creating. Both positional arguments are required; Amazon has no default ad-group bid. Get-or-create like the rest, created live unless state: "paused" says otherwise, and it hands back the ad group so addAsin, createKeyword and createTarget can fill it on the same run. Needs # ALLOW CREATION. |
COUNT(list) / JOIN(list[, ", "]) | How many, and joined into text. |
LOWER, UPPER, LENGTH, REPLACE, CONCAT, TODAY() | Text and date helpers. |
Building a campaign
A rule can build the whole structure — portfolio, campaign, ad group, ASINs, targets and keywords — in one pass:
The constant names inside the loop are deliberate. However many search terms match, that is one campaign and one ad group: each name is a single request, proposed once and reviewed once, with the ASINs and clauses batched onto one row each.
Two things to know
A rule has to ask. # ALLOW CREATION on a line of its own is what lets a rule build
campaigns and ad groups; without it the rule will not save. Every other action a rule can take moves a number on
something you already chose to run — a new campaign is new spend, so it is the one capability a rule cannot start
using because somebody edited it. (createPortfolio does not need it: a portfolio holds no budget.)
Everything is created live. The campaign, the ad group, the ASINs, the clauses and the keywords
all arrive ENABLED, so a structure a rule builds serves as soon as you approve the run — there is no
second run and nothing left to switch on. To build a piece switched off instead, add state: "paused" to
any create verb:
"enabled" and "paused" are the only two values, and each create verb takes its own — a
live campaign whose keywords go in paused is a perfectly good thing to ask for. One thing still belongs to a later
run: a rule cannot enable, rename or re-budget a campaign on the run that creates it, because there is nothing there
yet to change, and those lines are refused when the rule is saved. Everything settable at creation is a named
argument instead.