Campaign management (Sponsored Products and Sponsored Brands)¶
Every Sponsored Products entity has the same interface:
| Resource | Model | Id field |
|---|---|---|
sp.campaigns |
Campaign |
campaignId |
sp.ad_groups |
AdGroup |
adGroupId |
sp.keywords |
Keyword |
keywordId |
sp.targets |
Target |
targetId |
sp.negative_keywords |
NegativeKeyword |
keywordId |
sp.negative_targets |
NegativeTarget |
targetId |
sp.campaign_negative_keywords |
CampaignNegativeKeyword |
keywordId |
sp.campaign_negative_targets |
CampaignNegativeTarget |
targetId |
sp.product_ads |
ProductAd |
adId |
portfolios |
Portfolio |
portfolioId |
Reading¶
us.sp.keywords.list() # enabled and paused
us.sp.keywords.list(states=None) # every state, archived too
us.sp.keywords.list(states=["ENABLED"], campaign_ids=["123"], ad_group_ids=["456"])
us.sp.campaigns.list(name="Brand - Exact") # exact name
us.sp.keywords.get("123456789012345") # one, any state, or None
us.sp.targets.get_many(["1", "2"]) # {id: Target}
for kw in us.sp.keywords.iter(): # lazy, page by page
...
us.sp.keywords.list(filters={"matchTypeFilter": ["EXACT"]}) # any other Amazon filter
Pagination follows nextToken for you. Id filters longer than Amazon's 1,000-id limit
are split into several requests.
Models accept both spellings and keep unknown fields:
kw = us.sp.keywords.list()[0]
kw.keyword_text, kw.match_type, kw.bid # snake_case attributes
kw.to_api() # Amazon's JSON, camelCase, no None fields
Target.asin gives the ASIN of a single-ASIN product target.
Writing¶
result = us.sp.keywords.create([
{"campaignId": "1", "adGroupId": "2", "keywordText": "mystery novels",
"matchType": "EXACT", "bid": 0.45, "state": "ENABLED"},
])
result = us.sp.keywords.update([{"keywordId": "9", "bid": 0.50}])
result = us.sp.keywords.pause(["9", "10"])
result = us.sp.keywords.enable(["9"])
result = us.sp.keywords.delete(["9"]) # archive: permanent, see below
Inputs can be dicts in camelCase or snake_case, or models (Keyword(...)).
- Chunking. Lists over 1,000 items are sent in several calls. Results come back as
one
BatchResultwhose indexes point into your original list. - Read-only fields are dropped from updates. A keyword's text or match type cannot be changed by an update, so they are removed rather than sent and rejected. Each entity's updatable fields come from Amazon's spec.
- Archiving is permanent. Amazon cannot re-enable an archived entity. Pause instead when you might want it back.
- New campaigns bid dynamically by default. Amazon uses "dynamic bids, down only"
unless you send
"dynamicBidding": {"strategy": "MANUAL"}. - A Sponsored Products campaign budget may be given as a number. Amazon only accepts
it as an object, and rejects a bare number with a 400 that names no field, so
{"campaignId": "9", "budget": 20}is widened to{"budget": 20.0, "budgetType": "DAILY"}before it is sent. Pass the object yourself to set anything other than a daily budget. A value that is neither a number nor an object raises instead of being sent.
us.sp.campaigns.update([{"campaignId": "9", "budget": 20}]) # DAILY 20.00
us.sp.campaigns.update([{"campaignId": "9", "budget": # or be explicit
{"budget": 500, "budgetType": "LIFETIME"}}])
The budget may be written budgetType or budget_type, or passed as a Budget(...).
plan_update reads the campaign first, so it keeps the budgetType the campaign already
has: planning "budget": 800 on a lifetime-budget campaign plans a lifetime budget of
800, not a switch to daily. update and create read nothing back and cannot know the
current type, so a bare number there always means a daily budget; pass the object to say
otherwise. plan_create widens the budget as well, so the plan you approve is the request
that gets sent, and a budget that is neither a number, a mapping nor a Budget is refused
while the plan is built rather than part way through applying it.
A Sponsored Brands campaign is the other way round, see below.
Batch results¶
Amazon answers every batch write with 207 and a verdict per item. Nothing raises because some items failed; you decide:
result = us.sp.keywords.update(changes)
result.ok # False if any item failed
result.successes # [BatchSuccess(index, id, item, entity)]
result.errors # [BatchError(index, item, code, message, raw)]
result.ids # ids of the successes
result.summary() # "48 succeeded, 2 failed"
result.raise_for_errors() # PartialFailureError(result) if anything failed
Each BatchError has:
| Field | Meaning |
|---|---|
item |
What you sent for that entry |
error_type |
Amazon's error family: biddingError, rangeError, entityStateError, duplicateValueError, entityNotFoundError, ... |
code |
Amazon's reason, e.g. BID_OUT_OF_MARKET_PLACE_RANGE, TOO_LOW, PARENT_ARCHIVED_FORBIDS_UPDATES |
message |
Amazon's message, pulled out of its nested error objects |
lower_limit, upper_limit |
The allowed range, for bid and range errors |
hint |
What to do about it, in plain words |
transient |
True when Amazon did not act on the item (throttled or internal error) |
Items rejected as throttled inside a batch are sent again, alone and with backoff, so a busy moment does not fail part of a batch. Items that failed with an internal error are sent again for updates and archives, which are safe to repeat, but not for creates, because the item may already exist. Everything else (a bid out of range, an archived parent, a duplicate) would fail the same way again and is returned to you.
To review changes before sending them, and to be able to undo them, use change plans.
Sponsored Brands¶
Sponsored Brands campaigns, ad groups, ads, keywords and negative keywords have the same
interface, under ProfileClient.sb:
| Resource | Model | Id field | Amazon API |
|---|---|---|---|
sb.campaigns |
SbCampaign |
campaignId |
v4 (/sb/v4/campaigns) |
sb.ad_groups |
SbAdGroup |
adGroupId |
v4 (/sb/v4/adGroups) |
sb.ads |
SbAd |
adId |
v4 (/sb/v4/ads) |
sb.keywords |
SbKeyword |
keywordId |
v3 (/sb/keywords) |
sb.negative_keywords |
SbNegativeKeyword |
keywordId |
v3 (/sb/negativeKeywords) |
us.sb.keywords.list(campaign_ids=["303"]) # enabled and paused
us.sb.keywords.update([{"keywordId": "101", "bid": 0.95}])
us.sb.negative_keywords.create([
{"campaignId": "303", "adGroupId": "202", "keywordText": "free pdf",
"matchType": "negativeExact"},
])
us.sb.campaigns.update([{"campaignId": "303", "bidding": {
"bidOptimization": False,
"bidAdjustmentsByPlacement": [{"placement": "TOP_OF_SEARCH", "percentage": 60}],
}}])
plan = us.sb.keywords.plan_update([{"keywordId": "101", "bid": 0.95}]) # plans work too
Building a campaign that serves¶
A campaign on its own never delivers. It needs an ad group, and an ad under that carrying the creative and the landing page. Amazon accepts the campaign without either, so the missing pieces do not show up as an error, only as a campaign that spends nothing:
campaign = us.sb.campaigns.create([{
"name": "Mysteries", "state": "PAUSED", "budget": 10, "budgetType": "DAILY",
"startDate": "2026-01-01", "goal": "PAGE_VISIT", "costType": "CPC",
}])
campaign_id = campaign.successes[0].id
group = us.sb.ad_groups.create([
{"campaignId": campaign_id, "name": "Mysteries", "state": "ENABLED"},
])
ad_group_id = group.successes[0].id
us.sb.ads.create([{
"adGroupId": ad_group_id,
"name": "Mysteries",
"state": "ENABLED",
"adFormat": "productCollection", # the console calls this Collections
"creative": {
"brandName": "Invented Books",
"headline": "Three mysteries to start with",
"asins": ["B000000001", "B000000002", "B000000003"],
},
"landingPage": {"pageType": "DETAIL_PAGE", "asins": ["B000000001"]},
}])
adFormat is not an Amazon field. There is no POST /sb/v4/ads: each format has its own
create endpoint, so the field picks one and is left out of the body. It defaults to
productCollection. One create call cannot mix formats, because Amazon numbers per-item
results by their position in the request, so two endpoints would renumber them; send one
call per format. Listing, updating and archiving ads all use the shared /sb/v4/ads.
An ad group takes no bid: the bid lives on the campaign's keywords, so an ad group is only a name, a state and its campaign id. The creative and landing page belong to the ad, so changing which products an ad shows is an ad update, never a campaign update.
A Sponsored Brands campaign holds its budget as a plain number beside a sibling
budgetType, not as the object a Sponsored Products campaign uses, so "budget": 20 is
already the right shape here and is sent untouched.
Differences from Sponsored Products, all handled for you:
- Keywords are on the older v3 API. Ids are integers and states and match types are
lower case (
enabled,phrase); negative match types arenegativeExactandnegativePhrase. Pass states in either case; they are sent lower case. - An update must repeat the keyword's parent ids. Amazon requires
adGroupIdandcampaignIdnext tokeywordId; they are read from the account when you leave them out. - Smaller batches. Keyword writes go 100 at a time; campaign, ad group and ad writes 10 at a time.
- Negative keywords cannot be paused. They are
enabledorarchived;list()shows enabled ones unless you passstates=None. - Archiving a keyword is one request per keyword (
DELETE /sb/keywords/{keywordId}); a keyword Amazon cannot find comes back as a per-itemNOT_FOUNDerror, not an exception. - Placement premiums live in
bidding. Send the wholebiddingobject on a campaign update; change plans compare it as one field. The object exactly as a read returns it (four placements, although the spec says three) is accepted, so read it, change the percentage and send it back.