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]: | Loop over what is inside an object, the row you already have, or a campaign or ad group you looked up by name at the top of the rule. 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. |
| The pairings | product, keyword, target 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, and suggestedKeyword and suggestedAsin from an ad group or product, Amazon's own suggestions. |
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. |
| Two levels | A child loop can contain one more, campaign, then ad group, then keyword, and no further. Every level multiplies into the one above it, so the depth is a limit rather than something you discover at run time. Each parent is still read once per run however many rows point at it. |
| 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.
One campaign or ad group, by name
A child loop can also open a rule. Look the parent up in a LET, then name it after IN, no outer loop, and nothing else in the account is read:
LET camp = FIND_CAMPAIGN("Brand - Exact")
FOR EACH keyword IN camp OVER 8..35:
IF keyword.clicks >= 10 AND keyword.orders = 0:
keyword.setBid(MAX($0.05, keyword.bid * 0.85))
END
| Rule | What it means |
|---|---|
| The parent | A LET name or the lookup itself, FIND_CAMPAIGN("name"), FIND_CAMPAIGN_ID("id"), FIND_AD_GROUP(camp, "name"). There is no outer row here, so searchTerm.adGroup and the like are not available. |
| The timeframe | After OVER, as anywhere else; LAST 30 DAYS when left out. It is the window every metric in the block is read over. |
| What you get | Everything in the campaign right now, not only what reported in the window, a keyword that never delivered reads zero. Archived rows and archived ad groups are left out; paused ones are not. |
| Paused campaigns | You named it, so it is read even when “Skip paused and archived campaigns” is on. |
| Not found | FIND_CAMPAIGN answers NONE for a name that matches nothing (a rename, an archive). The block then does nothing, and the run log says there was no such object, so a typo does not pass for a quiet night. |
| Search terms | A child of a campaign or an ad group: FOR EACH searchTerm IN product.adGroup OVER LAST 30 DAYS:. They are report rows, so the window is part of what you get, the terms that delivered in it, with their metrics. Each distinct campaign is one read of the largest table there is, and a run is capped (500 campaigns by default), so put an IF on the product or campaign around it rather than asking it of every row. For the whole account loop over them as a table: FOR EACH searchTerm IN LAST 30 DAYS:. |
| Cost | One read per distinct campaign or ad group, shared with KEYWORDS()/TARGETS() in the same rule. Nothing scans the account. |
Check one thing, then the next: ELSE and SET
A rule can look in one place, and only if that finds nothing look in another. ELSE IF and ELSE sit directly under an IF, at its indentation, and only one branch of the chain runs. SET carries a value out of a loop: declare a counter with LET, add to it inside the child loop, read it after the loop.
FOR EACH product IN LAST 30 DAYS:
IF product.acos > 70%:
LET ag = product.adGroup
LET wasteful = 0
FOR EACH searchTerm IN ag OVER LAST 30 DAYS:
IF searchTerm.cost >= $5 AND searchTerm.orders = 0:
searchTerm.addNegative(searchTerm.searchTerm)
SET wasteful = wasteful + 1
END
IF wasteful > 0:
log("{asin}: {wasteful} wasteful search terms negated")
ELSE:
LET checked = 0
LET weak = 0
FOR EACH keyword IN ag OVER LAST 30 DAYS:
SET checked = checked + 1
IF keyword.cost >= $5 AND keyword.orders = 0:
keyword.setBid(MAX($0.05, keyword.bid * 0.8))
SET weak = weak + 1
END
IF checked > 0 AND weak = 0:
product.setState("PAUSED")
END
| Rule | What it means |
|---|---|
What SET can change | A LET declared in the same body or in a body around it, a number, text or TRUE/FALSE. Not a top-level LET (those are worked out once, before any loop), not a loop row, and not an object such as an ad group. |
| A fresh start per row | The LET runs again for every row of the loop it sits in, so wasteful starts at 0 for each product. |
| 0 can mean "nothing to check" | A counter still at 0 after a loop means nothing matched or the loop had no rows: an ad group with no keywords (auto-targeted, or not synced yet), or a search-term loop over a window in which no term delivered. Before a pause, archive or negative on IF weak = 0, count every row as well, SET checked = checked + 1 as the loop's first step, and act on IF checked > 0 AND weak = 0, as above. The review shows the counters the condition read and how many rows each loop checked, e.g. checked 0 · weak 0 · keywords checked 0. |
One name, one LET | A name declared by two LETs cannot be SET, so there is never a question which one a SET changes. |
| Read it after the loop | A body works out its LETs and notes first, then runs everything else in the order written. So read a changing variable in an IF, a SET or a note inside an IF body that comes after the loop, never in a LET or note beside it, which is worked out before the loop has run. MerchDash refuses the LET case. |
| Previews and limits | Each row of every level counts against the run's iteration limit, and the preview shows each SET like a LET. |
Amazon's suggestions: keywords and product targets
Amazon suggests keywords for an ad group and products to target for an ASIN. A rule can read those suggestions as a child loop and decide what to do with each one, the same suggestions the console shows, with the bid Amazon would start at:
FOR EACH adGroup IN LAST 30 DAYS:
IF adGroup.orders >= 3 AND adGroup.acos < 25%:
FOR EACH suggestedKeyword IN adGroup:
IF suggestedKeyword.matchType = "EXACT" AND suggestedKeyword.rank <= 10 AND NOT suggestedKeyword.alreadyTargeted:
adGroup.createKeyword(suggestedKeyword.keywordText, "exact", MIN(suggestedKeyword.bid, $1.00))
adGroup.note("Amazon suggests it, rank {suggestedKeyword.rank}")
END
END
Products work the same way. suggestedAsin is asked for the ASINs the ad group advertises, and a product target is created with createTarget:
FOR EACH adGroup IN LAST 30 DAYS:
IF adGroup.orders >= 3:
FOR EACH suggestedAsin IN adGroup:
IF suggestedAsin.rank <= 5 AND NOT suggestedAsin.alreadyTargeted AND NOT HAS_ASIN(adGroup, suggestedAsin.asin):
adGroup.createTarget(PRODUCT_TARGET(suggestedAsin.asin))
END
END
| Field | On a suggested keyword |
|---|---|
keywordText, matchType | One row per keyword and match type (EXACT, PHRASE or BROAD), because Amazon prices each apart and a keyword is created per match type. |
bid, bidLow, bidHigh | Amazon's suggested bid for that match type and the range around it. They are suggestions: createKeyword still applies your maximum-bid limits. |
rank | Amazon's rank for the keyword at that match type; 1 is best. Use it to take only the top few. |
impressionRank, impressionShare | How the term has performed for the account, when it has been targeted with impressions; otherwise NONE. |
alreadyTargeted | The ad group already has this keyword in this match type, the answer HAS_KEYWORD(adGroup, keywordText, matchType) gives. A rule that runs every night should always test it. |
| Field | On a suggested ASIN |
|---|---|
asin | The suggested product. |
themes | Why Amazon suggests it, as Amazon names the themes. A list: "SUBSTITUTES" IN suggestedAsin.themes. |
rank | Its place in Amazon's answer; 1 is first. |
alreadyTargeted | The ad group already targets this ASIN, the answer HAS_TARGET(adGroup, PRODUCT_TARGET(asin)) gives. It does not say the ASIN is one of your own; use HAS_ASIN for that. |
| Rule | What it means |
|---|---|
| Only inside another loop | Suggestions are asked of Amazon, not read from your data, so they exist for one ad group or product at a time: FOR EACH suggestedKeyword IN adGroup (or IN product, for the keywords of that ASIN). There is no top-level FOR EACH suggestedKeyword. |
| Narrow the loop around it | Each distinct ad group or product is one request to Amazon, shared with your syncs and other runs. A run may ask for up to 300; past that it stops and says so. Put the loop under an IF on performance or name, as above. |
| Asked once per parent | However many outer rows point at the same ad group, Amazon is asked once for the run. |
| Sponsored Products only | Amazon offers suggestions for Sponsored Products. A Sponsored Brands or Sponsored Display ad group reads as having none, and the run says how many it skipped. An ad group or product Amazon cannot answer for is skipped the same way, without failing the run. |
| Reading is not creating | Nothing is added until the rule calls createKeyword or createTarget, and those go through the same review, limits and History as any other change. A suggestion has no performance of its own, so it has no clicks, orders or other metrics. |
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 | Keywords and targets hang directly off the report row, so write FOR EACH keyword IN accumulated_asin rather than going through FOR EACH adGroup IN accumulated_asin, one loop fewer to read, and the ad-group hop is only for rules that act on the ad group itself. |
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, adFormat (the Sponsored Brands format of an ad group, its keywords and its targets: "Store Spotlight", "Product Collection", "Video", "Brand Video", or "Mixed" when one ad group holds ads of several formats; NONE outside Sponsored Brands), 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 or 8..35 | An offset baseline window, unlagged. The short spelling works after FOR EACH and OVER, in CREATE ACCUMULATED … REPORT IN and in METRIC(), an inline keyword.clicks IN … window takes the long one. |
BEFORE 8 DAYS AGO or 8.. | 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", "audience" 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.Audience bid adjustments
A Sponsored Products campaign can carry up to 10 audience bid adjustments, each for one audience, Amazon's own (High interest…, Purchased brand's product, Clicked or added brand's product to cart, High likelihood of purchase) and your Amazon Marketing Cloud audiences. Each is a whole-number percentage, 0–900, on top of the placement adjustments. You see and edit them on the campaign's page, under Placements → Audiences; a rule reads and changes them like this:
| Written as | Does |
|---|---|
AUDIENCE_BID("high interest") | The campaign's current percentage for that audience, 0 when it has none, NONE on anything but a Sponsored Products campaign. |
campaign.setAudienceBid("high interest", 50) | Set that audience's adjustment. 0 removes it. Only that audience changes, the campaign's other audiences, its placements and its bidding strategy stay as Amazon holds them. Reverting puts the old percentage back, or removes the adjustment if there was none. |
DAYS_SINCE_CHANGE("audience")campaign.daysSinceAudienceChange | Days since the newest audience adjustment MerchDash applied on the campaign, any audience (LAST_CHANGE("audience") / campaign.lastAudienceChangeAt for the timestamp). Infinity for a campaign it never adjusted. |
Name an audience the way Campaigns shows it. Part of the name is enough when it matches exactly one audience (case and punctuation are ignored); you can also write its audience id. A name that matches nothing, or several audiences, stops the rule and lists the choices, rather than reading 0 or guessing. The audiences a marketplace offers are read from Amazon and refreshed with each sync.
Cool-downs for this rule only
Every cool-down above counts any change MerchDash applied, from this rule, from another rule, from a one-off mass run or a manual edit. That is usually what you want: two rules should not both move the same bid on the same day. But it also means a rule's own DAYS_SINCE_CHANGE("bid") >= 14 is held off by a change it never made.
Add "this rule" as a second argument to count only the changes this rule applied:
| Written as | Answers |
|---|---|
DAYS_SINCE_CHANGE("bid", "this rule")keyword.daysSinceRuleBidChangetarget.daysSinceRuleBidChange | Days since this rule last changed the bid. The timestamp is LAST_CHANGE("bid", "this rule") / lastRuleBidChangeAt. |
DAYS_SINCE_CHANGE("budget", "this rule")campaign.daysSinceRuleBudgetChange | Days since this rule last changed the budget; campaign.lastRuleBudgetChangeAt for the timestamp. |
DAYS_SINCE_CHANGE("top of search", "this rule")campaign.daysSinceRulePlacementChange | The placement cool-downs, narrowed the same way: one placement by name, or the newest of the three with "placement" / the field. |
“This rule” is the saved rule: editing its script keeps its history, while a copy, an imported shared rule or a rule deleted and created again starts with none. An item the rule has never changed reads Infinity, so a first run still matches, and so does a dry run of a rule you have not saved yet. Only applied changes count, as above, and undoing a run is itself a change, so it restarts the cool-down.
Another rule's changes
Name a different rule instead of "this rule" to count only its changes, for example, hold a keyword off for a week after your nightly bid-down rule touched it, whatever other rules did:
| Written as | Answers |
|---|---|
DAYS_SINCE_CHANGE("bid", RULE("Nightly bid down")) | Days since the rule named “Nightly bid down” last changed this bid. Capitals and spacing do not matter. |
DAYS_SINCE_CHANGE("bid", RULE_ID("rule_mgx1abcd")) | The same, by the rule's ID, shown as Rule ID in that rule's editor (click it to copy). Keeps working when the rule is renamed. |
Both work with every field ("budget", a placement) and with LAST_CHANGE(). The name or ID has to be written out in quotes, and it is looked up when the run starts: a name no rule on this account has, a name two rules share, or an ID that is not a rule on this account stops the run with a message saying which, it never quietly reads “never changed”. A rule can name up to 10 other rules.
Changes no cool-down tracks
A one-off or bulk rule, say a mass bid cut, resets every cool-down it touches, so your regular rules hold those items off afterwards. Put this line at the top of the rule to keep its changes out of every cool-down:
Its changes are still reviewed, applied, listed in History and can be undone like any other, they just never count as a “last change”: not for DAYS_SINCE_CHANGE(), daysSinceBidChange / daysSinceBudgetChange, "this rule" or RULE(), and undoing one is not counted either. The line applies to changes made while it is in the script; adding it later does not hide earlier ones. Because such a rule's own changes are never counted, it cannot use "this rule" or the daysSinceRule… fields, the rule is refused when you save it. The account-wide cool-downs still work in it and see every other rule's changes.
A rule's own maximum bid
Every bid a rule writes is held under your maximum bids in Profile → General. A rule can set those aside, or set its own, for its runs only, your profile and every other rule are unchanged. Put the lines at the top of the rule:
| Written as | Does |
|---|---|
# NO MAX BID | Ignores your Profile maximum bids in this rule. |
# MAX BID 1.50 | Caps every bid this rule writes at $1.50. |
# MAX BID SP 1.50 | One ad product: SP, SB or SD. |
# MAX BID KEYWORD 2.00 | One targeting type, in every ad product that has it: KEYWORD, PRODUCT, CLOSE MATCH, LOOSE MATCH, SUBSTITUTES, COMPLEMENTS. |
# MAX BID SP CLOSE MATCH 0.80 | One targeting type in one ad product. |
- A line sets its level and everything under it.
# MAX BID SP 1.50also replaces a Profile close-match maximum of $0.30 in this rule. What no line names keeps its Profile maximum,# MAX BID SP 1.50leaves Sponsored Brands as your profile has it. - The narrower line wins, whatever the order: above, close match is capped at $0.60 and everything else at $1.00.
- Only the rule's caps: write
# NO MAX BIDas well as the# MAX BIDlines. - Amazon's own limits always apply, per marketplace: in the US $0.02–$1,000, and for Sponsored Brands at least $0.10, or $0.25 for video ads (in Europe €0.10 / €0.15). See bid limits. A bid already above the cap can still come down, but not go up.
- A capped change says where the cap came from in History, “capped at this rule's $1.00 maximum bid (# MAX BID)”.
A # MAX BID line has to end in an amount; one that does not is an ordinary comment. An amount outside $0.02–$1,000, a type the ad product does not have (Sponsored Display has none; Sponsored Brands has KEYWORD and PRODUCT) or the same cap written twice is refused when you save the rule. Manual bulk edits always use your Profile maximum bids.
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,removeNegative,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.
Sponsored Brands keywords and targets are included once MerchDash has confirmed them with Amazon. One that has ever had an impression is confirmed straight away; the rest are confirmed during the regular background refresh, so a newly created Sponsored Brands keyword can take a few days to appear. Until it is confirmed it is left out, with or without the comment below.
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, portfolio | 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. |
.setBudget(amount[, policy: "monthly"|"date range"][, startDate: "2026-10-01"][, endDate: "2026-10-31"]) | portfolio | Set the portfolio's budget cap, the most all its campaigns may spend together. "monthly" resets every calendar month; "date range" is one total from startDate: to endDate:, and needs a start date. Leave policy: out to keep the current one (a portfolio with no cap gets a monthly one); dates you leave out are kept while the policy stays the same. Dates are written YYYY-MM-DD, TODAY() works, and endDate: NONE removes the end date. Reverting puts the whole previous cap back. |
.removeBudgetCap() | portfolio | Take the budget cap off, so the campaigns spend only against their own daily budgets. Reverting restores the cap. |
.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". |
.setAudienceBid("high interest", 50) | campaign | Audience bid adjustment 0–900% (0 removes it), Sponsored Products. See Audience bid adjustments. |
.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, portfolio | Rename the campaign or portfolio. |
.archive() | portfolio | Archive the portfolio. Permanent on Amazon: an archived portfolio cannot be enabled, changed or restored, so this change has no revert. setState() will not archive, the rule has to say archive(). Changes a rule asks of an already-archived portfolio are skipped and counted in the run log. |
.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 and auto-campaign traffic an ASIN is written as a negative product target instead; a plain query is ignored in a manual product-targeting ad group, which cannot negate text, and becomes an ordinary negative keyword in an auto campaign. On a Sponsored Products auto campaign, campaign.addNegative(asin) excludes the ASIN from the whole campaign as a campaign-level negative product target (e.g. searchTerm.campaign.addNegative(searchTerm.searchTerm)). In a Sponsored Brands campaign an ASIN given to a campaign, ad group, keyword or target is likewise a negative product target; on the campaign it is written into every ad group of the campaign, because Sponsored Brands keeps negatives on the ad group. |
.removeNegative(text[, "exact"|"phrase"]) | campaign, adGroup, keyword, target, searchTerm, product | Remove (archive) a negative keyword, the counterpart of addNegative, with the same receivers. It looks in the scope addNegative would write to: the ad group the row delivered from, or the campaign for a campaign (in a Sponsored Brands campaign, every ad group of it, because Sponsored Brands keeps negatives on the ad group). Leave the match type out to remove either the exact or the phrase negative. Text that looks like an ASIN also removes the negative product target for it. A negative that is not there is not an error: the call does nothing, so the rule can safely run again. Reverting a removal creates the negative again; a paused negative, or an excluded brand, is removed without a revert. A campaign-wide negative product target outside Sponsored Products cannot be removed, and the run log counts it. A term covered by a negative at another scope (the campaign, when you are in an ad group) stays blocked. |
.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 three kinds of row are skipped rather than sent: anything in an auto campaign (Amazon allows only negative keywords there), any search term that is an ASIN rather than a shopper search (an ASIN only works as a product target), and any Sponsored Display ad group (Sponsored Display has no keywords at all, it targets products and audiences). The run log counts each. Sponsored Brands ad groups do take keywords: point it at one with FIND_AD_GROUP(...) and the keyword is written at Sponsored Brands’ own $0.25 floor, with a lower bid raised to it and the change saying so. |
.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. Add "this rule" as a second argument to count only this rule's own changes, or RULE("name") / RULE_ID("id") for another rule's (see cool-downs for this rule only). |
AUDIENCE_BID("high interest") | The campaign's bid adjustment (%) for one audience, 0 when it has none. See Audience bid adjustments. |
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. |
FIND_SB_AD("name")FIND_SB_AD_ID("…") | A Sponsored Brands ad, or NONE. An SB ad is the creative, a headline, your brand, a landing page and up to three products, and it is the one ad Amazon reviews before it runs, so ad.moderationStatus is "PENDING" while it is under review, "APPROVED" once it serves, "REJECTED" when it will not, and NONE when Amazon answers with a status MerchDash has not seen before. ad.servingStatus is Amazon's own wording; ad.adFormat, ad.headline, ad.brandName, ad.asins, ad.landingPageType/landingPageUrl, ad.creativeStatus/creativeVersion and ad.campaignName/ad.adGroupName describe the rest. An SB ad itself cannot be changed by a rule, pair these with log(…), but a copy of it can go into another ad group with ad_group.addVideoAd(from: …) (a video ad) or ad_group.addSbAd(from: …) (any other format; see Sponsored Brands campaigns). Rules read no metrics on an SB ad, ask the campaign or ad group, and there is no FOR EACH over them; per-ad metrics are on the public API's GET /api/v1/sb-ads. |
FIND_VIDEO("name") | A video in your Sponsored Brands asset library, or NONE. video.ready is TRUE once Amazon has finished processing it (video.status is "ACTIVE"; "PROCESSING" before that), and video.durationSeconds, video.name, video.assetId and video.version describe the file. Names compare ignoring case and extra spaces; two videos with one name is an error, so rename one. Use it to check a video is ready before ad_group.addVideoAd(video: "…") uses it. A video has no performance numbers of its own. |
ELIGIBLE("B0…"[, "SP"|"SB"|"SD"])ELIGIBILITY("B0…"[, "SP"|"SB"|"SD"]) | Whether Amazon allows an ASIN to be advertised. ELIGIBLE() is TRUE for ELIGIBLE and ELIGIBLE_WITH_WARNING and FALSE for INELIGIBLE; ELIGIBILITY() gives the status itself as text. The ad product defaults to the row's own, else Sponsored Products. Each distinct ASIN is one request to Amazon, so narrow the loop (a run may make 300 such requests in all). A row Amazon cannot answer for is skipped, the rule does nothing for it that run and the run log counts it, so IF NOT ELIGIBLE(productAd.asin): productAd.pause() never pauses an ad because of an outage. Changes the row had already recorded before the call stay. |
SUGGESTED_BID(keyword)SUGGESTED_BID_LOW(keyword)SUGGESTED_BID_HIGH(keyword) | Amazon's suggested bid, and the low and high ends of its range, for a Sponsored Brands keyword or target (pass the row itself, e.g. keyword.setBid(SUGGESTED_BID(keyword))). It works for exact, phrase and broad keywords and for product (ASIN) and category targets. One request to Amazon covers up to 100 keywords and targets of the campaign, and the answer is reused for the rest of the run. Sponsored Products and Sponsored Display rows, clauses Amazon does not price (auto or theme targets), and rows Amazon has no suggestion for are skipped and counted in the run log, never given a made-up number. Amounts are in the marketplace currency; the usual minimum and maximum bids still apply to what you write. |
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 campaign with that name, creating it when there is none, so it never answers NONE. Sponsored Products unless adType: "brands" asks for Sponsored Brands (see below). Only the name is positional: budget: is required, a Sponsored Products campaign also needs targeting: ("manual" or "auto"), 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. In a Sponsored Products campaign the bid is required, because Amazon has no default ad-group bid; in a Sponsored Brands campaign leave it out. 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.
Sponsored Brands campaigns
Add adType: "brands" to build a Sponsored Brands campaign instead. It takes no
targeting: and no strategy:: a Sponsored Brands campaign has no auto or manual split,
and its bidding stays at Amazon's default. Its ad groups take no bid either, so put the bid on each keyword:
The ad. A Sponsored Brands campaign serves nothing until it has an ad, and Amazon refuses
keywords in a Sponsored Brands ad group until it has one. addVideoAd() puts a video ad in the
ad group, built from a video your account already runs, nothing is uploaded, and the ad you took it from is not
changed (the new ad has its own metrics). Say which video with one of:
from: FIND_SB_AD("ad name"), the video and ASIN of that ad.asin: "B0ABC12345", the video ad that advertises this ASIN. If the ASIN has several different videos, the rule stops and names them; usefrom:to pick one.video: "cat-mom.mp4", the video by its name in your Amazon Ads asset library (the file name it was uploaded under). The ASIN comes from the ads already running that video; for a video no ad uses yet, addasin:as well. To use a new video, upload it through the API: it is usable under the name you give it as soon as Amazon has processed it (GET /api/v1/videos?refresh=trueshows itready). Videos added in Amazon Ads itself are picked up by sync once a day, so one uploaded there today may need until tomorrow.
name: sets the new ad's name (it defaults to the source ad's) and state: "paused" builds
it switched off. Amazon reviews every new Sponsored Brands ad before it serves, usually within a day. Call
addVideoAd() before createKeyword(): keywords into the same ad group then wait for
the ad. Without an ad, the run that builds the ad group skips its keywords and says so in the run summary, add the
ad in Amazon's console, and the rule's next run creates them. If no video ad matches (a wrong name, an ASIN with no
video ad, or one Amazon's review rejected), the ad is skipped and the run summary says why.
Product collection, store spotlight and brand video ads. addSbAd(from: FIND_SB_AD("ad name"))
copies one your account already runs into the ad group, its brand, logo, headline, products, video and landing page,
which a rule could not supply on its own. Change the copy with headline: "…" (at most 50 characters),
asins: "B0…, B0…, B0…" (a product collection's one to five products), logo: "logo name" (an
image in your Amazon Ads asset library, by its name there) and
landingPage: "Home" (a page of your own Amazon Store, by name or URL). name: and
state: work as for addVideoAd(), and so does the order: call it before the keywords. A video
ad is copied with addVideoAd().
No ad to copy? addSbAd(format: …) builds a product collection or store spotlight ad
from parts instead of from: (use one or the other). Both need headline: and
logo:. A "product collection" also needs asins: and lands on the product list, or
on a store page when you add landingPage:. A "store spotlight" needs landingPage:
and pages: "Home, T-Shirts, Mugs", exactly three different pages of your store, each shown with its
first product. The brand name is brand: "…" (at most 30 characters), or the one brand your existing
Sponsored Brands ads carry. Brand video and video ads cannot be built this way, they are copied.
The goal. goal: chooses what Amazon optimises a Sponsored Brands campaign for:
"page visits" (the default, paid per click), "impression share" (top-of-search impressions)
or "new customers", those two are paid per 1,000 viewable impressions (vCPM), so the bids on their
keywords are per 1,000 impressions, or "ad views". Amazon cannot change a campaign's goal later.
Your maximum bid (Profile → General) is a per-click limit, so it does not cap the bids in a per-1,000-impressions
campaign; Amazon checks those against its own vCPM range.
Undo. Reverting a run archives the Sponsored Brands campaigns, ad groups and ads it created (archiving is final on Amazon). A campaign or ad group that was already there is never touched.
Fill a Sponsored Brands ad group with createKeyword() for keyword targeting, or with
createTarget(PRODUCT_TARGET("B0…"), $0.60) / CATEGORY_TARGET("12345") for product
targeting, a Sponsored Brands product-targeting campaign is the same campaign and ad group, filled with targets
instead of keywords. One or the other per ad group: Amazon refuses a product target in a Sponsored
Brands ad group that has keywords, so a rule that sends both to the same new ad group will not save. Within one
run, the kind that reaches an ad group second is skipped and counted in the run summary; an ad group that already
holds the other kind from before is refused by Amazon, and History says why. For both, create two ad groups,
each with its own addVideoAd(). Give every one a bid: Sponsored Brands ad groups have no default bid, and bids start at its
floor ($0.25 for a video ad group in the US, €0.15 in Europe). Targets wait for the ad exactly like keywords, so call addVideoAd() first.
addAsin() is Sponsored Products only, and a rule that uses it there will not save.
Two things to know
A rule has to ask. # ALLOW CREATION on a line of its own is what lets a rule build
campaigns, ad groups and Sponsored Brands ads; 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.