Class: MicrosoftAds::ConversionsClient

Inherits:
Object
  • Object
show all
Defined in:
app/services/microsoft_ads/conversions_client.rb

Overview

REST client for the Microsoft Advertising (Bing Ads) Campaign Management
v13 ApplyOfflineConversions operation — uploads offline conversions keyed
by Microsoft Click Id (msclkid). The direct parallel to
OpenaiAds::ApiClient, and the conversion-side sibling of the SOAP
CampaignManagementClient.

Why REST (the campaign read path is still SOAP): Microsoft is
deprecating the SOAP API on 2027-01-31 and asks integrators to move to REST
before 2026-10-01, so the net-new conversion path is built REST-first. Auth
uses the same four credentials as the SOAP client, but REST passes the OAuth
token as a standard Authorization: Bearer header (the SOAP
AuthenticationToken envelope element has no REST equivalent); the developer
token, CustomerId and CustomerAccountId remain custom headers.

200-with-PartialErrors: the operation returns HTTP 200 even when
individual conversions are rejected — per-item failures arrive in the
PartialErrors array of BatchError objects. A 200 with a non-empty
PartialErrors is therefore a failure for our (single) conversion, and a
terminal one: the same payload re-sent gets the same rejection.

Constant Summary collapse

SERVICE_URL =

Production Campaign Management v13 REST endpoint for ApplyOfflineConversions.
(Sandbox swaps campaign.apicampaign.api.sandbox.)

'https://campaign.api.bingads.microsoft.com/CampaignManagement/v13/OfflineConversions/Apply'

Instance Method Summary collapse

Constructor Details

#initialize(developer_token:, access_token:, customer_id:, account_id:) ⇒ ConversionsClient

Returns a new instance of ConversionsClient.

Parameters:

  • developer_token (String)

    Microsoft Advertising developer token

  • access_token (String)

    OAuth access token (sent as Authorization: Bearer)

  • customer_id (String, Integer)

    CustomerId header (cid)

  • account_id (String, Integer)

    CustomerAccountId header (aid)



36
37
38
39
40
41
# File 'app/services/microsoft_ads/conversions_client.rb', line 36

def initialize(developer_token:, access_token:, customer_id:, account_id:)
  @developer_token = developer_token
  @access_token    = access_token
  @customer_id     = customer_id
  @account_id      = 
end

Instance Method Details

#apply_offline_conversions(conversions:) ⇒ Hash

Upload one or more offline conversions.

Parameters:

  • conversions (Array<Hash>)

    OfflineConversion objects
    (MicrosoftClickId, ConversionName, ConversionTime,
    ConversionValue, ConversionCurrencyCode, …) per the
    ApplyOfflineConversions schema. Max 1,000 per request.

Returns:

  • (Hash)

    { status: :reported|:rejected|:rate_limited|:failed, http_status:, error:, partial_errors: }. :rejected is terminal
    (per-item PartialErrors); :rate_limited/:failed are retryable.



52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'app/services/microsoft_ads/conversions_client.rb', line 52

def apply_offline_conversions(conversions:)
  response = connection.post('') do |req|
    req.body = { OfflineConversions: conversions }.to_json
  end

  case response.status
  when 200
    partial_errors = extract_partial_errors(response.body)
    if partial_errors.empty?
      Rails.logger.info "MicrosoftAds::ConversionsClient: #{conversions.size} offline conversion(s) accepted (HTTP 200)"
      { status: :reported, http_status: 200 }
    else
      # 200 + PartialErrors = the conversion data itself was rejected (bad
      # click id, conversion time outside the goal's window, unknown goal
      # name). Terminal — a retry sends the identical payload and earns the
      # identical rejection.
      msg = partial_errors.map { |e| "#{e['ErrorCode'].presence || e['Code']}: #{e['Message']}" }.join('; ')
      Rails.logger.error "MicrosoftAds::ConversionsClient: PartialErrors -- #{msg}"
      { status: :rejected, http_status: 200, error: msg, partial_errors: partial_errors }
    end
  when 401
    # Token lapsed mid-run. The reporter fetches a fresh OAuth token per run
    # via OauthService#access_token!, so this is transient — keep :failed so
    # the sweep retries with a newly-minted token.
    { status: :failed, http_status: 401, error: 'Unauthorized (HTTP 401)' }
  when 429
    { status: :rate_limited, http_status: 429, error: 'Rate limited (HTTP 429)' }
  when 400, 403, 404
    # Persistent client errors (malformed payload, wrong account, unknown
    # goal) never succeed on retry — terminal :rejected so the sweep doesn't
    # churn them daily for 7 days. (401 stays :failed: a lapsed token is
    # transient; the reporter mints a fresh one on the next run.)
    msg = rest_error_message(response.body) || "HTTP #{response.status}"
    Rails.logger.error "MicrosoftAds::ConversionsClient: Rejected (terminal HTTP #{response.status}) -- #{msg}"
    { status: :rejected, http_status: response.status, error: msg }
  else
    msg = rest_error_message(response.body) || "HTTP #{response.status}"
    Rails.logger.error "MicrosoftAds::ConversionsClient: Failed -- #{msg}"
    { status: :failed, http_status: response.status, error: msg }
  end
rescue Faraday::TimeoutError => e
  # Keep status :failed so ConversionRetrySweepWorker still re-enqueues it
  # (it selects result == 'failed'); the `timeout` flag lets the reporter
  # skip AppSignal noise for these transient, already-retried failures.
  { status: :failed, http_status: nil, error: "Timeout: #{e.message}", timeout: true }
rescue Faraday::Error => e
  { status: :failed, http_status: nil, error: e.message }
end