Class: AmazonAds::ApiClient

Inherits:
Object
  • Object
show all
Defined in:
app/services/amazon_ads/api_client.rb

Overview

Faraday-backed client for the Amazon Advertising API (NA region) — the
profile + campaign listing surface used by AmazonAdsCampaignSyncWorker
to mirror campaigns into the sources table, the direct parallel to
Pinterest::AdvertiserApiClient / MicrosoftAds::CampaignManagementClient.

Amazon splits campaigns across per-ad-product endpoints with versioned
media types (Sponsored Products v3, Sponsored Brands v4, Sponsored
Display) and scopes every campaign call to a profile (one per
marketplace/account pair) via the Amazon-Advertising-API-Scope header.
#list_campaigns flattens all three ad products into one uniform list so
the sync worker doesn't care which product a campaign belongs to.

Authenticates with a lazily-refreshed OAuth access token from
OauthService plus the LWA client id header.

Defined Under Namespace

Classes: Request, Response, ThrottledError

Constant Summary collapse

API_HOST =

NA-region API host — WarmlyYours advertises on amazon.com (+ .ca, .com.mx),
which all live under the NA endpoint.

'https://advertising-api.amazon.com'
PAGE_LIMIT =

Page size for the SP/SB list endpoints. Sponsored Brands v4 caps
maxResults at 100 (500 gets HTTP 400 INVALID_ARGUMENT / rangeError) —
the tightest limit of the list endpoints, so both use it; the cursor
loop absorbs accounts with more campaigns than one page.

100
DEFAULT_THROTTLE_DELAY =

Default delay exposed to a rescheduling worker when Amazon omits a
Retry-After header. The client never sleeps: holding a Sidekiq thread
cannot create quota, so callers persist their state and retry later.

20
MAX_THROTTLE_DELAY =

Ceiling for a server-supplied Retry-After. RFC 7231 permits an absolute
date; callers combine this bounded hint with their own durable cooldown
policy rather than accepting an arbitrarily distant retry time.

60
STATES =

Campaign states fetched from every ad product. ARCHIVED campaigns are
deliberately excluded — the sync worker archives any local Source whose
campaign drops out of this set.

%w[ENABLED PAUSED].freeze
REPORT_CONTENT_TYPE =

Media type every reporting call must send AND accept.

'application/vnd.createasyncreportrequest.v3+json'
REPORT_PRODUCTS =

adProductreportTypeId for campaign-grain reports. All three
accept the same column set (verified against the live API 2026-07-27).

{
  'SPONSORED_PRODUCTS' => 'spCampaigns',
  'SPONSORED_BRANDS'   => 'sbCampaigns',
  'SPONSORED_DISPLAY'  => 'sdCampaigns'
}.freeze
REPORT_COLUMNS =

Campaign-grain metric columns requested for every ad product. cost is
in the profile's own currency — callers spanning profiles must convert.

%w[campaignId campaignName cost clicks impressions date].freeze
CONVERSION_COLUMNS =

Conversions and attributed sales, named DIFFERENTLY per ad product:
Sponsored Products carries the attribution window in the column name,
Brands and Display do not. Verified against the live seller profile on
2026-08-02 — Amazon rejects the whole report for one unknown column, so
these are probed rather than inferred from the docs.

7-day attribution for Sponsored Products: it matches what the
Advertising console and AdLabs report, which is what these numbers get
checked against. Widening to 14d/30d silently inflates every ROAS on the
dashboard, so change this only alongside whatever it is compared with.

sales* is money in the profile's own currency, exactly like cost.

{
  'SPONSORED_PRODUCTS' => { purchases: 'purchases7d', sales: 'sales7d' },
  'SPONSORED_BRANDS' => { purchases: 'purchases', sales: 'sales' },
  'SPONSORED_DISPLAY' => { purchases: 'purchases', sales: 'sales' }
}.freeze

Instance Method Summary collapse

Constructor Details

#initialize(oauth: AmazonAds::OauthService.new) ⇒ ApiClient

Returns a new instance of ApiClient.

Parameters:

  • oauth (AmazonAds::OauthService) (defaults to: AmazonAds::OauthService.new)

    token source (injectable for tests)



69
70
71
# File 'app/services/amazon_ads/api_client.rb', line 69

def initialize(oauth: AmazonAds::OauthService.new)
  @oauth = oauth
end

Instance Method Details

#create_report(profile_id:, ad_product:, date:, end_date: nil) ⇒ String

Requests one daily campaign report.

Amazon deduplicates report requests by configuration + date range
(not by name) and answers a repeat with HTTP 425 carrying the original
report id. That is a feature, not an error: re-running a date reuses the
already-generating report, so this returns that id rather than raising.

timeUnit: DAILY means a multi-day range returns one row per campaign
per day, so a backfill wants ONE report for the whole range rather than
one per day — six requests instead of six-times-N, which is the
difference between working and being throttled into uselessness.

Parameters:

  • profile_id (Integer, String)
  • ad_product (String)
  • date (Date)

    single day, or the range start when end_date is given

  • end_date (Date, nil) (defaults to: nil)

    inclusive range end; defaults to date

Returns:

  • (String)

    reportId

Raises:

  • (RuntimeError)

    on any non-2xx other than the 425 duplicate



151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'app/services/amazon_ads/api_client.rb', line 151

def create_report(profile_id:, ad_product:, date:, end_date: nil)
  end_date ||= date
  body = {
    'name' => "heatwave #{ad_product} #{date}..#{end_date}",
    'startDate' => date.to_s, 'endDate' => end_date.to_s,
    'configuration' => {
      'adProduct' => ad_product, 'groupBy' => ['campaign'],
      'columns' => REPORT_COLUMNS + CONVERSION_COLUMNS.fetch(ad_product).values,
      'reportTypeId' => REPORT_PRODUCTS.fetch(ad_product),
      'timeUnit' => 'DAILY', 'format' => 'GZIP_JSON'
    }
  }
  request(:post, '/reporting/reports', profile_id: profile_id,
                                       content_type: REPORT_CONTENT_TYPE, body: body)['reportId']
rescue RuntimeError => e
  duplicate_id = e.message[/duplicate of\s*:?\s*([0-9a-f-]{36})/i, 1]
  raise unless duplicate_id

  duplicate_id
end

#download_report(url) ⇒ Array<Hash>

Fetches and inflates a completed report.

Parameters:

Returns:

  • (Array<Hash>)

    rows keyed campaignId, campaignName, cost,
    clicks, impressions, date



186
187
188
189
# File 'app/services/amazon_ads/api_client.rb', line 186

def download_report(url)
  raw = URI.parse(url).open(open_timeout: 15, read_timeout: 60, &:read)
  JSON.parse(Zlib::GzipReader.new(StringIO.new(raw)).read)
end

#list_campaigns(profile_id:) ⇒ Array<Hash>

Every ENABLED or PAUSED campaign on the profile, across Sponsored
Products, Sponsored Brands, and Sponsored Display, normalized to a
uniform shape.

Parameters:

  • profile_id (Integer, String)

    profileId from #profiles

Returns:

  • (Array<Hash>)

    { 'id', 'name', 'state' (upcased), 'type' ('sp'|'sb'|'sd') }

Raises:

  • (RuntimeError)

    on non-2xx responses (Sidekiq retry surface)



89
90
91
# File 'app/services/amazon_ads/api_client.rb', line 89

def list_campaigns(profile_id:)
  sponsored_products(profile_id) + sponsored_brands(profile_id) + sponsored_display(profile_id)
end

#profilesArray<Hash>

All advertising profiles visible to the connected account — one per
marketplace/account pair (seller US, vendor US, seller CA, …).

Returns:

  • (Array<Hash>)

    per Amazon's spec — profileId, countryCode,
    currencyCode, timezone, accountInfo (type: seller/vendor/agency).



78
79
80
# File 'app/services/amazon_ads/api_client.rb', line 78

def profiles
  Array(request(:get, '/v2/profiles'))
end

#report_status(profile_id:, report_id:) ⇒ Hash

Returns status (PENDING|PROCESSING|COMPLETED|FAILED) and, once
complete, a presigned url.

Parameters:

  • profile_id (Integer, String)
  • report_id (String)

Returns:

  • (Hash)

    status (PENDING|PROCESSING|COMPLETED|FAILED) and, once
    complete, a presigned url



176
177
178
179
# File 'app/services/amazon_ads/api_client.rb', line 176

def report_status(profile_id:, report_id:)
  request(:get, "/reporting/reports/#{report_id}", profile_id: profile_id,
                                                   content_type: REPORT_CONTENT_TYPE)
end