├── .circleci └── config.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .ruby-gemset ├── .ruby-version ├── CHANGELOG.md ├── Gemfile ├── LICENSE.md ├── README.md ├── Rakefile ├── bin └── facebook_ads_console ├── facebook_ads.gemspec ├── lib ├── facebook_ads.rb └── facebook_ads │ ├── account.rb │ ├── ad.rb │ ├── ad_account.rb │ ├── ad_audience.rb │ ├── ad_campaign.rb │ ├── ad_creative.rb │ ├── ad_exception.rb │ ├── ad_image.rb │ ├── ad_insight.rb │ ├── ad_product.rb │ ├── ad_product_catalog.rb │ ├── ad_product_feed.rb │ ├── ad_product_set.rb │ ├── ad_set.rb │ ├── ad_set_activity.rb │ ├── ad_targeting.rb │ ├── ad_user.rb │ ├── advertisable_application.rb │ └── base.rb └── spec ├── facebook_ads ├── ad_account_spec.rb ├── ad_campaign_spec.rb ├── ad_targeting_spec.rb └── facebook_ads_spec.rb ├── fixtures ├── approvals │ └── facebookads_adaccount │ │ ├── ad_campaigns │ │ └── lists_campaigns.approved.json │ │ ├── ad_creatives │ │ └── lists_creatives.approved.json │ │ ├── ad_images │ │ └── lists_images.approved.json │ │ ├── ad_sets │ │ └── lists_ad_sets.approved.json │ │ ├── ads │ │ └── lists_ads.approved.json │ │ ├── all │ │ └── lists_all_accounts.approved.json │ │ ├── create_ad_campaign │ │ └── creates_a_new_ad_campaign.approved.json │ │ ├── create_ad_images │ │ └── creates_an_image.approved.json │ │ ├── find_by │ │ └── finds_a_specific_account.approved.json │ │ └── reach_estimate │ │ └── estimates_the_reach_of_a_targeting_spec.approved.json └── cassettes │ ├── FacebookAds │ └── _stubbornly_get │ │ └── executes_the_request_for_the_provided_url.yml │ ├── FacebookAds_AdAccount │ ├── _ad_campaigns │ │ └── lists_campaigns.yml │ ├── _ad_creatives │ │ └── lists_creatives.yml │ ├── _ad_images │ │ └── lists_images.yml │ ├── _ad_sets │ │ └── lists_ad_sets.yml │ ├── _ads │ │ └── lists_ads.yml │ ├── _all │ │ └── lists_all_accounts.yml │ ├── _create_ad_campaign │ │ └── creates_a_new_ad_campaign.yml │ ├── _create_ad_creative │ │ └── creates_carousel_ad_creative.yml │ ├── _create_ad_images │ │ └── creates_an_image.yml │ ├── _find_by │ │ └── finds_a_specific_account.yml │ └── _reach_estimate │ │ └── estimates_the_reach_of_a_targeting_spec.yml │ └── FacebookAds_AdCampaign │ ├── _create_ad_set │ └── creates_valid_ad_set.yml │ └── _destroy │ └── sets_effective_status_to_deleted.yml ├── spec_helper.rb └── support ├── approvals.rb ├── facebook_ads.rb ├── rest_client.rb └── vcr.rb /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | aws-cli: circleci/aws-cli@1.2.1 5 | ruby: circleci/ruby@1.1.0 6 | 7 | jobs: 8 | test: 9 | docker: 10 | - image: circleci/ruby:2.7.0-node 11 | executor: ruby/default 12 | working_directory: ~/repo 13 | environment: 14 | APP_BUNDLER_VERSION: '2.1.2' 15 | steps: 16 | - checkout: 17 | path: ~/repo 18 | - ruby/install-deps: 19 | with-cache: false 20 | - ruby/rspec-test 21 | - ruby/rubocop-check 22 | 23 | workflows: 24 | version: 2 25 | build: 26 | jobs: 27 | - test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | /Gemfile.lock 3 | /.approvals 4 | /.bundle 5 | /coverage 6 | .DS_Store 7 | 8 | /test_access_token 9 | /test_business_id 10 | .idea 11 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.5 3 | NewCops: enable 4 | 5 | Metrics/AbcSize: 6 | Enabled: false 7 | Metrics/CyclomaticComplexity: 8 | Enabled: false 9 | Metrics/PerceivedComplexity: 10 | Enabled: false 11 | Metrics/ClassLength: 12 | Enabled: false 13 | Metrics/MethodLength: 14 | Enabled: false 15 | Metrics/BlockLength: 16 | Enabled: false 17 | Metrics/ParameterLists: 18 | Enabled: false 19 | 20 | Layout/ElseAlignment: 21 | Enabled: false 22 | Layout/EndAlignment: 23 | Enabled: false 24 | Layout/RescueEnsureAlignment: 25 | Enabled: false 26 | Layout/EmptyLineAfterGuardClause: 27 | Enabled: false 28 | Layout/LineLength: 29 | Enabled: false 30 | Layout/IndentationWidth: 31 | Enabled: false 32 | 33 | Style/Documentation: 34 | Enabled: false 35 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | facebook-ruby-ads-sdk 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.7.0 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.7.0 (2020-09-29) 2 | - Add stubborn network call helpers. 3 | - Update default version to v7.0. 4 | - Switch from TravisCI to CircleCI. 5 | - Remove coveralls. 6 | - Require ruby 2.5+. 7 | - Update Rubocop & fix warnings. 8 | 9 | ## 0.6.11 (2020-02-28) 10 | - Fixed memoization in `FacebookAds::Ad`. 11 | 12 | ## 0.6.10 (2019-05-06) 13 | - Set default version to 3.2. 14 | - Removed `relevance_score` from the list of default fields in `AdInsight`. 15 | 16 | ## 0.6.9 (2019-03-25) 17 | - Added the following fields to `AdCreative`: `product_set_id` and `url_tags`. (#37, @cte) 18 | 19 | ## 0.6.7 (2019-03-07) 20 | - Added the following fields to `AdSet`: `attribution_spec`, `start_time` and `end_time`. (#36, @cte) 21 | 22 | ## 0.6.6 (2018-11-29) 23 | - Added ability to pass date range when fetching `ad_set_activities`. (#34, @amosharrafa) 24 | 25 | ## 0.6.5 (2018-11-26) 26 | - Expose `ad_set_activities` for `ad_sets`. (#31, @amosharrafa) 27 | 28 | ## 0.6.4 (2018-10-03) 29 | - Expose `budget_remaining`, `daily_budget`, `lifetime_budget` for the `AdCampaign` object. You now have the option to set the budget ad the campaign level instead of the ad set level. 30 | 31 | ## 0.6.3 (2018-05-17) 32 | - Replaced deprecated `is_autobid` with new `bid_strategy` field. 33 | 34 | ## 0.6.2 (2018-05-08) 35 | - Set Product Catalog path to `/owned_product_catalogs` for API v2.11 compatibility. 36 | 37 | ## 0.4 (2017-07-25) 38 | - Added ability to pass `bid_amount` parameter when creating ad sets. 39 | - Ad Set `is_autobid` parameter now defaults to `nil` rather than `true`. 40 | 41 | ## 0.3 (2017-07-24) 42 | - Added ability to pass `app_link` parameter with ad creatives. 43 | 44 | ## 0.2 (2017-07-20) 45 | - Added ability to configure App Secret to be included with API requests. 46 | 47 | ## 0.1.12 (2017-05-25) 48 | - Added complete set of ad campaign objectives. (#9, @cte) 49 | - Switched to rspec from minitest. (#8, @cte) 50 | - Formatting tweaks in exception error messages (#7, @dekaikiwi) 51 | - Added a reach_estimate method for ad accounts (ae35878, @cte) 52 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | gemspec 5 | 6 | group :development, :test do 7 | gem 'awesome_print' 8 | gem 'pry' 9 | gem 'rake' 10 | gem 'rubocop' 11 | end 12 | 13 | group :test do 14 | gem 'approvals' 15 | gem 'multi_json' 16 | gem 'rspec' 17 | gem 'rspec_junit_formatter' 18 | gem 'vcr' 19 | gem 'webmock' 20 | end 21 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Tophatter, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Gem Version](https://badge.fury.io/rb/facebook_ads.svg)](https://badge.fury.io/rb/facebook_ads) 2 | [![Build Status](https://circleci.com/gh/tophatter/facebook-ruby-ads-sdk.svg?style=svg&circle-token=3fa538bc2aece310fdc07b82ce77d117758afb02)](https://circleci.com/gh/tophatter/facebook-ruby-ads-sdk) 3 | 4 | # [Facebook Marketing API](https://developers.facebook.com/docs/marketing-apis) SDK for Ruby 5 | 6 | ![Facebook Ads](http://i.imgur.com/GrxAj07.png) 7 | 8 | This gem allows you to manage your Facebook Ads using a ruby interface. It allows you to list, create, update and destroy Facebook Ad objects (campaigns, ad sets, ads, etc) and get real-time insights about the performance of Facebook Ads. 9 | 10 | ## Install 11 | 12 | ```bash 13 | gem install facebook_ads 14 | ``` 15 | 16 | Or, add the following to your Gemfile: 17 | 18 | ```ruby 19 | gem 'facebook_ads', '~> 0.7' 20 | ``` 21 | 22 | ## Permissions 23 | 24 | You'll need an [Access Token](https://developers.facebook.com/docs/marketing-api/authentication) with `ads_management` permissions in order to use Facebook's Marketing API. 25 | 26 | ```ruby 27 | FacebookAds.access_token = '[YOUR_ACCESS_TOKEN]' 28 | ``` 29 | 30 | You can also optionally include an [App Secret](https://developers.facebook.com/docs/accountkit/graphapi#app-tokens) if you need one for your application. 31 | 32 | ```ruby 33 | FacebookAds.app_secret = '[YOUR_APP_SECRET]' 34 | ``` 35 | 36 | ## API Version 37 | 38 | This gem currently defaults v3.2 of the Marketing API. You can change the version as desired with the following: 39 | 40 | ```ruby 41 | FacebookAds.base_uri = 'https://graph.facebook.com/[desired-version-here]' 42 | ``` 43 | 44 | ## Console 45 | 46 | This gem provides a console using [Pry](https://github.com/pry/pry) and [AwesomePrint](https://github.com/awesome-print/awesome_print) for you to test & debug. 47 | It reads the Access Token from a file called test_access_token. 48 | 49 | ```bash 50 | echo [YOUR_ACCESS_TOKEN] > test_access_token 51 | bin/facebook_ads_console 52 | ``` 53 | 54 | ## Usage Examples 55 | 56 | A strong understanding of the Facebook Ads [object structure](https://developers.facebook.com/docs/marketing-api/buying-api) will greatly help you use this gem. 57 | 58 | The basic object structure: 59 | 60 | ![Facebook Ads Object Structure](http://i.imgur.com/Ak4FQ4H.jpg) 61 | 62 | In total, there are 7 Facebook Ads objects that can be interacted with via this gem: AdAccount, AdCampaign, AdImage, AdCreative, AdSet, Ad and AdInsight. 63 | 64 | The typical flow is as follows: 65 | 66 | 1. Create an **AdCampaign** for an **AdAccount**. 67 | 2. Create **AdImages** for an **AdAccount**. 68 | 3. Create an **AdCreative** for an **AdAccount** using the **AdImages** from #2. 69 | 4. Create ad **AdSet** for the **AdCampaign** from #1. 70 | 5. Create an **Ad** for the **AdSet** from #4 using the **AdCreative** from #3. 71 | 6. Monitor the performance of the **Ad** from #5 using **AdInsights**. 72 | 7. Update the daily budget of the **AdSet** from #4 as needed. 73 | 74 | You'll find usage examples for each of these 7 objects below. 75 | 76 | ___ 77 | 78 | ### [Ad Accounts](https://developers.facebook.com/docs/marketing-api/reference/ad-account) (Fetch, Find, Update) 79 | 80 | Fetch all accounts that can be accessed using your access token: 81 | ```ruby 82 | accounts = FacebookAds::AdAccount.all 83 | ``` 84 | 85 | Find an account by ID: 86 | ```ruby 87 | account = FacebookAds::AdAccount.find('act_1132789356764349') 88 | ``` 89 | 90 | Find an account by name: 91 | ```ruby 92 | account = FacebookAds::AdAccount.find_by(name: 'ReFuel4') 93 | ``` 94 | 95 | Update an account (using both .save() and .update()): 96 | ```ruby 97 | account.name = 'ReFuel4 [Updated]' 98 | account = account.save # Returns the updated object. 99 | account.update(name: 'ReFuel4') # Returns a boolean. 100 | ``` 101 | 102 | The list of fields that can be updated is [here](https://developers.facebook.com/docs/marketing-api/reference/ad-account#Updating). 103 | 104 | ___ 105 | 106 | ### [Ad Campaigns](https://developers.facebook.com/docs/marketing-api/reference/ad-campaign-group) (Fetch, Find, Create, Update, Destroy) 107 | 108 | Fetch all active campaigns: 109 | ```ruby 110 | campaigns = account.ad_campaigns 111 | ``` 112 | 113 | Fetch all paused campaigns (can pass multiple statuses in the array): 114 | ```ruby 115 | campaigns = account.ad_campaigns(effective_status: ['PAUSED']) 116 | ``` 117 | See FacebookAds::AdCampaign::STATUSES for a list of all statuses. 118 | 119 | Fetch all campaigns: 120 | ```ruby 121 | campaigns = account.ad_campaigns(effective_status: nil) 122 | ``` 123 | 124 | Create a new campaign for website conversions that is initially paused: 125 | ```ruby 126 | campaign = account.create_ad_campaign( 127 | name: 'Test Campaign', 128 | objective: 'CONVERSIONS', 129 | status: 'PAUSED' 130 | ) 131 | ``` 132 | See FacebookAds::AdCampaign::OBJECTIVES for a list of all objectives. 133 | 134 | Find a campaign by ID: 135 | ```ruby 136 | campaign = FacebookAds::AdCampaign.find(campaign.id) 137 | ``` 138 | 139 | Update a campaign (using both .save() and .update()): 140 | ```ruby 141 | campaign.status = 'ACTIVE' 142 | campaign = campaign.save # Returns the updated object. 143 | campaign.update(status: 'PAUSED') # Returns a boolean. 144 | ``` 145 | The list of fields that can be updated is [here](https://developers.facebook.com/docs/marketing-api/reference/ad-campaign-group#Updating). 146 | 147 | Destroy a campaign: 148 | ```ruby 149 | campaign.destroy 150 | ``` 151 | 152 | ___ 153 | 154 | ### [Ad Images](https://developers.facebook.com/docs/marketing-api/reference/ad-image) (Fetch, Find, Create, Destroy) 155 | 156 | Notes: 157 | * Images cannot be updated. 158 | * You can upload the same image multiple times and Facebook will de-duplicate them server side. 159 | * An image will always generate the same hash on Facebook's end - even across ad accounts. 160 | * Image uploading via a URL currently assumes a \*nix system (Mac OS, linux). It likely will fail on Windows. A cross-platform tempfile-based solution is in the works. 161 | * You can't destroy an image if its being used by a creative. You have to destroy the creative first. 162 | 163 | Fetch all images owned by an account: 164 | ```ruby 165 | ad_images = account.ad_images 166 | ``` 167 | 168 | Create images using an array of URLs: 169 | ```ruby 170 | ad_images = account.create_ad_images([ 171 | 'https://d38eepresuu519.cloudfront.net/485674b133dc2f1d66d20c9d52c62bec/original.jpg', 172 | 'https://d38eepresuu519.cloudfront.net/3977d2a47b584820969e2acf4d923e33/original.jpg' 173 | ]) 174 | ``` 175 | 176 | Find images using their hash values: 177 | ```ruby 178 | ad_images = account.ad_images(hashes: ad_images.map(&:hash)) 179 | ``` 180 | 181 | Destroy images: 182 | ```ruby 183 | ad_images.map(&:destroy) 184 | ``` 185 | 186 | ___ 187 | 188 | ### [Ad Creatives](https://developers.facebook.com/docs/marketing-api/reference/ad-creative) (Fetch, Find, Create, Update, Destroy) 189 | 190 | Notes: 191 | * I'd like to add a configuration object that allows you to specify the Facebook Page, Instagram account, website, iOS app and/or Android app that you will be advertising. This is needed when creating both Ad Creative objects and Ad Set objects. 192 | 193 | Fetch all creatives owned by an account: 194 | ```ruby 195 | ad_creatives = account.ad_creatives 196 | ``` 197 | 198 | Create a carousel creative driving installs for an Android app: 199 | ```ruby 200 | carousel_ad_creative = account.create_ad_creative({ 201 | name: 'Test Carousel Creative', 202 | page_id: '300664329976860', # Add your Facebook Page ID here. 203 | link: 'http://play.google.com/store/apps/details?id=com.tophatter', # Add your Play Store ID here. 204 | message: 'A message.', 205 | assets: [ 206 | { hash: ad_images.first.hash, title: 'Image #1 Title' }, 207 | { hash: ad_images.second.hash, title: 'Image #2 Title' } 208 | ], 209 | call_to_action_type: 'SHOP_NOW', 210 | multi_share_optimized: true, 211 | multi_share_end_card: false 212 | }, creative_type: 'carousel') 213 | ``` 214 | See FacebookAds::AdCreative::CALL_TO_ACTION_TYPES for a list of all call to action types. 215 | 216 | Create a single image creative advertising an Android app: 217 | ```ruby 218 | image_ad_creative = account.create_ad_creative({ 219 | name: 'Test Single Image Creative', 220 | page_id: '300664329976860', # Add your Facebook Page ID here. 221 | message: 'A message.', 222 | link: 'http://play.google.com/store/apps/details?id=com.tophatter', # Add your Play Store ID here. 223 | link_title: 'A link title.', 224 | image_hash: ad_images.first.hash, 225 | call_to_action_type: 'SHOP_NOW' 226 | }, creative_type: 'image') 227 | ``` 228 | 229 | Create a single creative for a web link: 230 | ```ruby 231 | image_ad_creative = account.create_ad_creative({ 232 | title: 'Test Link Title', 233 | body: 'Link Description Text', 234 | object_url: 'www.example.com/my-ad-link', 235 | link_url: 'www.example.com/my-ad-link', 236 | image_hash: ad_images.first.hash, 237 | }, creative_type: 'link') 238 | ``` 239 | 240 | The options will be different depending on the thing being advertised (Android app, iOS app or website). 241 | 242 | Find a creative by ID: 243 | ```ruby 244 | ad_creative = FacebookAds::AdCreative.find(ad_creative.id) 245 | ``` 246 | 247 | Update a creative (using both .save() and .update()): 248 | ```ruby 249 | ad_creative.name = 'Test Carousel Creative [Updated]' 250 | ad_creative = ad_creative.save # Returns the updated object. 251 | ad_creative.update(name: 'Test Carousel Creative') # Returns a boolean. 252 | ``` 253 | The list of fields that can be updated is [here](https://developers.facebook.com/docs/marketing-api/reference/ad-creative#Updating). 254 | 255 | Destroy a creative: 256 | ```ruby 257 | ad_creative.destroy 258 | ``` 259 | 260 | ___ 261 | 262 | ### [Ad Sets](https://developers.facebook.com/docs/marketing-api/reference/ad-campaign) (Fetch, Find, Create, Update, Destroy) 263 | 264 | Notes: 265 | * It's important to make sure your targeting spec makes sense in the context of the promoted object. For example if the promoted object is an iOS app and the targeting spec specifies Android devices your ads are not likely to perform well since no one will be able to download your iOS app. 266 | 267 | You interact with ad sets via a campaign: 268 | ```ruby 269 | campaign = account.ad_campaigns(effective_status: nil).first 270 | ``` 271 | 272 | Fetch all active ad sets for a campaign: 273 | ```ruby 274 | ad_sets = campaign.ad_sets 275 | ``` 276 | 277 | Fetch all paused ad sets for a campaign (can pass multiple statuses in the array): 278 | ```ruby 279 | ad_sets = campaign.ad_sets(effective_status: ['PAUSED']) 280 | ``` 281 | See FacebookAds::AdSet::STATUSES for a list of all statuses. 282 | 283 | Fetch all ad sets for a campaign: 284 | ```ruby 285 | ad_sets = campaign.ad_sets(effective_status: nil) 286 | ``` 287 | 288 | Specify the audience targeted by this ad set: 289 | ```ruby 290 | targeting = FacebookAds::AdTargeting.new 291 | targeting.genders = [FacebookAds::AdTargeting::WOMEN] 292 | targeting.age_min = 29 293 | targeting.age_max = 65 294 | targeting.countries = ['US'] 295 | targeting.user_os = [FacebookAds::AdTargeting::ANDROID_OS] 296 | targeting.user_device = FacebookAds::AdTargeting::ANDROID_DEVICES 297 | targeting.app_install_state = FacebookAds::AdTargeting::NOT_INSTALLED 298 | ``` 299 | A lot can be done with targeting. You can learn more about targeting specs [here](https://developers.facebook.com/docs/marketing-api/targeting-specs). 300 | 301 | Create an ad set to drive installs to an Android app using the targeting above: 302 | ```ruby 303 | ad_set = campaign.create_ad_set( 304 | name: 'Test Ad Set', 305 | targeting: targeting, 306 | promoted_object: { # This can be an Android app, iOS app or pixel ID, plus an optional custom event. 307 | application_id: '295802707128640', 308 | object_store_url: 'http://play.google.com/store/apps/details?id=com.tophatter', 309 | custom_event_type: 'PURCHASE' 310 | }, 311 | optimization_goal: 'OFFSITE_CONVERSIONS', 312 | daily_budget: 500, # This is in cents, so the daily budget here is $5. 313 | billing_event: 'IMPRESSIONS', 314 | status: 'PAUSED', 315 | bid_strategy: 'LOWEST_COST_WITHOUT_CAP' 316 | ) 317 | ``` 318 | See FacebookAds::AdSet::OPTIMIZATION_GOALS for a list of all optimization goals. 319 | See FacebookAds::AdSet::BILLING_EVENTS for a list of all billing events. 320 | See FacebookAds::AdSet::BID_STRATEGIES for a list of all bid strategies. 321 | 322 | Find an ad set by ID: 323 | ```ruby 324 | ad_set = FacebookAds::AdSet.find(ad_set.id) 325 | ``` 326 | 327 | Update an ad set (using both .save() and .update()): 328 | ```ruby 329 | ad_set.status = 'ACTIVE' 330 | ad_set.daily_budget = 400 331 | ad_set = ad_set.save # Returns the updated object. 332 | ad_set.update(status: 'PAUSED', daily_budget: 500) # Returns a boolean. 333 | ``` 334 | The list of fields that can be updated is [here](https://developers.facebook.com/docs/marketing-api/reference/ad-campaign#Updating). 335 | 336 | Destroy an ad set: 337 | ```ruby 338 | ad_set.destroy 339 | ``` 340 | 341 | ___ 342 | 343 | ### [Ad Set Activities](https://developers.facebook.com/docs/marketing-api/reference/ad-activity) (Fetch) 344 | 345 | You interact with activities via an ad set: 346 | ```ruby 347 | ad_set = account.ad_sets(effective_status: nil).first 348 | ``` 349 | 350 | Fetch all activities in last 24 hours for an ad set: 351 | ```ruby 352 | activities = ad_set.activities 353 | ``` 354 | 355 | Fetch all activities in last 48 hours for an ad set: 356 | ```ruby 357 | activities = ad_set.activities(from: 2.days.ago, to: Date.today) 358 | ``` 359 | 360 | ___ 361 | 362 | ### [Ads](https://developers.facebook.com/docs/marketing-api/reference/adgroup) (Fetch, Find, Create, Update, Destroy) 363 | 364 | You interact with ads via an ad set: 365 | ```ruby 366 | ad_set = account.ad_sets(effective_status: nil).first 367 | ``` 368 | 369 | Fetch all active ads for an ad set: 370 | ```ruby 371 | ads = ad_set.ads 372 | ``` 373 | 374 | Fetch all paused ads for an ad set (can pass multiple statuses in the array): 375 | ```ruby 376 | ads = ad_set.ads(effective_status: ['PAUSED']) 377 | ``` 378 | See FacebookAds::Ad::STATUSES for a list of all statuses. 379 | 380 | Fetch all ads for an ad set: 381 | ```ruby 382 | ads = ad_set.ads(effective_status: nil) 383 | ``` 384 | 385 | Fetch a creative that we'll use to create an ad: 386 | ```ruby 387 | ad_creative = account.ad_creatives.first 388 | ``` 389 | 390 | Create an ad: 391 | ```ruby 392 | ad = ad_set.create_ad(name: 'Test Ad', creative_id: ad_creative.id) 393 | ``` 394 | 395 | Find an ad by ID: 396 | ```ruby 397 | ad = FacebookAds::Ad.find(ad.id) 398 | ``` 399 | 400 | Update an ad (using both .save() and .update()): 401 | ```ruby 402 | ad.name = 'Test Ad [Updated]' 403 | ad.status = 'ACTIVE' 404 | ad = ad.save # Returns the updated object. 405 | ad.update(name: 'Test Ad', status: 'PAUSED') # Returns a boolean. 406 | ``` 407 | The list of fields that can be updated is [here](https://developers.facebook.com/docs/marketing-api/reference/adgroup#Updating). 408 | 409 | Destroy an ad: 410 | ```ruby 411 | ad.destroy 412 | ``` 413 | 414 | ___ 415 | 416 | ### [Ad Insights](https://developers.facebook.com/docs/marketing-api/insights/overview) (Fetch) 417 | 418 | Fetch today's insights for an account: 419 | ```ruby 420 | account.ad_insights 421 | ``` 422 | 423 | Fetch yesterday's insights for an account: 424 | ```ruby 425 | account.ad_insights(range: Date.yesterday..Date.yesterday) 426 | ``` 427 | 428 | Fetch today's insights for a campaign: 429 | ```ruby 430 | account.ad_campaigns.last.ad_insights 431 | ``` 432 | 433 | Fetch yesterday's insights for a campaign: 434 | ```ruby 435 | account.ad_campaigns.last.ad_insights(range: Date.yesterday..Date.yesterday) 436 | ``` 437 | 438 | ___ 439 | 440 | ### [Product Catalogs](https://developers.facebook.com/docs/marketing-api/reference/product-catalog) 441 | 442 | List all product catalogs: 443 | ```ruby 444 | FacebookAds::AdProductCatalog.all 445 | ``` 446 | 447 | Create a new product catalog: 448 | ```ruby 449 | catalog = FacebookAds::AdProductCatalog.create(name: 'test') 450 | ``` 451 | 452 | Delete a product catalog: 453 | ```ruby 454 | catalog.destroy 455 | ``` 456 | 457 | ## @TODO: 458 | 459 | * [Batch operations](https://developers.facebook.com/docs/marketing-api/batch-requests). 460 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rspec/core/rake_task' 4 | RSpec::Core::RakeTask.new(:spec) 5 | task default: :spec 6 | -------------------------------------------------------------------------------- /bin/facebook_ads_console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'facebook_ads' 6 | 7 | begin 8 | FacebookAds.access_token = File.read('test_access_token').chop 9 | puts "access_token: #{FacebookAds.access_token}" 10 | rescue Errno::ENOENT 11 | puts 'Create a test_access_token file and add your Access Token to it.' 12 | exit 13 | end 14 | 15 | begin 16 | FacebookAds.business_id = File.read('test_business_id').chop 17 | puts "business_id: #{FacebookAds.business_id}" 18 | rescue Errno::ENOENT 19 | puts 'Create a test_business_id file and add your Business ID to it.' 20 | end 21 | 22 | FacebookAds.logger = Logger.new($stdout) 23 | FacebookAds.logger.level = Logger::Severity::DEBUG 24 | 25 | require 'awesome_print' 26 | require 'pry' 27 | 28 | AwesomePrint.pry! 29 | Pry.start 30 | -------------------------------------------------------------------------------- /facebook_ads.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # To publish the next version: 4 | # gem build facebook_ads.gemspec 5 | # gem push facebook_ads-0.7.0.gem 6 | Gem::Specification.new do |s| 7 | s.name = 'facebook_ads' 8 | s.version = '0.7.0' 9 | s.platform = Gem::Platform::RUBY 10 | s.licenses = ['MIT'] 11 | s.authors = ['Chris Estreich'] 12 | s.email = 'chris@tophatter.com' 13 | s.homepage = 'https://github.com/tophatter/facebook-ruby-ads-sdk' 14 | s.summary = 'Facebook Marketing API SDK for Ruby.' 15 | s.description = "This gem allows to easily manage your Facebook ads via Facebook's Marketing API in ruby." 16 | 17 | s.extra_rdoc_files = ['README.md'] 18 | 19 | s.required_ruby_version = '>= 2.5' 20 | 21 | s.add_dependency 'hashie' 22 | s.add_dependency 'rest-client', '>= 1.6' 23 | 24 | s.files = `git ls-files`.split("\n") 25 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 26 | s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } 27 | s.require_paths = ['lib'] 28 | end 29 | -------------------------------------------------------------------------------- /lib/facebook_ads.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # External requires. 4 | require 'json' 5 | require 'rest-client' 6 | require 'hashie' 7 | require 'logger' 8 | require 'openssl' 9 | 10 | # Internal requires. 11 | require 'facebook_ads/base' 12 | require 'facebook_ads/account' 13 | require 'facebook_ads/ad_account' 14 | require 'facebook_ads/ad_audience' 15 | require 'facebook_ads/ad_campaign' 16 | require 'facebook_ads/ad_creative' 17 | require 'facebook_ads/ad_exception' 18 | require 'facebook_ads/ad_image' 19 | require 'facebook_ads/ad_insight' 20 | require 'facebook_ads/ad_product_catalog' 21 | require 'facebook_ads/ad_product_feed' 22 | require 'facebook_ads/ad_product' 23 | require 'facebook_ads/ad_set_activity' 24 | require 'facebook_ads/ad_set' 25 | require 'facebook_ads/ad_targeting' 26 | require 'facebook_ads/ad_user' 27 | require 'facebook_ads/ad' 28 | require 'facebook_ads/advertisable_application' 29 | 30 | # The primary namespace for this gem. 31 | module FacebookAds 32 | REQUEST_HEADERS = { accept: :json, accept_encoding: :identity }.freeze 33 | RETRYABLE_ERRORS = [RestClient::ExceptionWithResponse, Errno::ECONNRESET, Errno::ECONNREFUSED].freeze 34 | RETRY_LIMIT = 10 35 | RECOVERABLE_CODES = [1, 2, 2601].freeze 36 | 37 | def self.logger=(logger) 38 | @logger = logger 39 | end 40 | 41 | def self.logger 42 | unless defined?(@logger) 43 | @logger = Logger.new('/dev/null') 44 | @logger.level = Logger::Severity::UNKNOWN 45 | end 46 | 47 | @logger 48 | end 49 | 50 | def self.base_uri=(base_uri) 51 | @base_uri = base_uri 52 | end 53 | 54 | def self.base_uri 55 | @base_uri ||= "https://graph.facebook.com/v#{api_version}" 56 | end 57 | 58 | def self.api_version=(api_version) 59 | @api_version = api_version 60 | @base_uri = nil 61 | end 62 | 63 | def self.api_version 64 | @api_version ||= '8.0' 65 | end 66 | 67 | def self.access_token=(access_token) 68 | @access_token = access_token 69 | end 70 | 71 | def self.access_token 72 | @access_token 73 | end 74 | 75 | def self.app_secret=(app_secret) 76 | @app_secret = app_secret 77 | end 78 | 79 | def self.app_secret 80 | @app_secret 81 | end 82 | 83 | def self.appsecret_proof 84 | OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), @app_secret, @access_token) 85 | end 86 | 87 | def self.business_id=(business_id) 88 | @business_id = business_id 89 | end 90 | 91 | def self.business_id 92 | @business_id 93 | end 94 | 95 | # Stubborn network calls. 96 | 97 | def self.stubbornly(retry_limit: RETRY_LIMIT, recoverable_codes: RECOVERABLE_CODES, debug: false) 98 | raise ArgumentError unless block_given? 99 | 100 | response = nil 101 | retry_count = 0 102 | 103 | loop do 104 | response = yield 105 | break 106 | rescue *RETRYABLE_ERRORS => e 107 | if e.is_a?(RestClient::ExceptionWithResponse) 108 | error = begin 109 | JSON.parse(e.response) 110 | rescue JSON::ParserError 111 | nil 112 | end 113 | 114 | code = error&.[]('code') 115 | raise e if code && !recoverable_codes.include?(code) 116 | end 117 | 118 | raise e if retry_count >= retry_limit 119 | 120 | retry_count += 1 121 | wait = (retry_count**2) + 15 + (rand(15) * (retry_count + 1)) 122 | puts "retry ##{retry_count} will start in #{wait}s" if debug 123 | sleep wait 124 | end 125 | 126 | response 127 | end 128 | 129 | def self.stubbornly_get(url, retry_limit: RETRY_LIMIT, recoverable_codes: RECOVERABLE_CODES, debug: false) 130 | stubbornly(retry_limit: retry_limit, recoverable_codes: recoverable_codes, debug: debug) do 131 | RestClient.get(url, REQUEST_HEADERS) 132 | end 133 | end 134 | 135 | def self.stubbornly_post(url, payload, retry_limit: RETRY_LIMIT, recoverable_codes: RECOVERABLE_CODES, debug: false) 136 | stubbornly(retry_limit: retry_limit, recoverable_codes: recoverable_codes, debug: debug) do 137 | RestClient.post(url, payload, REQUEST_HEADERS) 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /lib/facebook_ads/account.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FacebookAds 4 | class Account < Base 5 | FIELDS = %w[].freeze 6 | 7 | class << self 8 | def all(query = {}) 9 | get('/me/accounts', query: query, objectify: true) 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/facebook_ads/ad.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FacebookAds 4 | # An ad belongs to an ad set. It is created using an ad creative. 5 | # https://developers.facebook.com/docs/marketing-api/reference/adgroup 6 | class Ad < Base 7 | FIELDS = %w[id account_id campaign_id adset_id adlabels bid_amount bid_info bid_type configured_status conversion_specs created_time creative effective_status last_updated_by_app_id name tracking_specs updated_time ad_review_feedback].freeze 8 | STATUSES = %w[ACTIVE PAUSED DELETED PENDING_REVIEW DISAPPROVED PREAPPROVED PENDING_BILLING_INFO CAMPAIGN_PAUSED ARCHIVED ADSET_PAUSED].freeze 9 | 10 | # belongs_to ad_account 11 | 12 | def ad_account 13 | @ad_account ||= AdAccount.find(account_id) 14 | end 15 | 16 | # belongs_to ad_campaign 17 | 18 | def ad_campaign 19 | @ad_campaign ||= AdCampaign.find(campaign_id) 20 | end 21 | 22 | # belongs_to ad_set 23 | 24 | def ad_set 25 | @ad_set ||= AdSet.find(adset_id) 26 | end 27 | 28 | # belongs_to ad_creative 29 | 30 | def ad_creative 31 | @ad_creative ||= AdCreative.find(creative['id']) 32 | end 33 | 34 | # has_many ad_insights 35 | 36 | def ad_insights(range: Date.today..Date.today, level: 'ad', time_increment: 1) 37 | query = { 38 | level: level, 39 | time_increment: time_increment, 40 | time_range: { 'since': range.first.to_s, 'until': range.last.to_s } 41 | } 42 | AdInsight.paginate("/#{id}/insights", query: query) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/facebook_ads/ad_account.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FacebookAds 4 | # https://developers.facebook.com/docs/marketing-api/reference/ad-account 5 | class AdAccount < Base 6 | FIELDS = %w[id account_id account_status age created_time currency name].freeze 7 | 8 | class << self 9 | def all(query = {}) 10 | get('/me/adaccounts', query: query, objectify: true) 11 | end 12 | end 13 | 14 | # AdvertisableApplication 15 | 16 | def advertisable_applications(limit: 100) 17 | AdvertisableApplication.paginate("/#{id}/advertisable_applications", query: { limit: limit }) 18 | end 19 | 20 | # AdUser 21 | 22 | def ad_users(limit: 100) 23 | AdCampaign.paginate("/#{id}/users", query: { limit: limit }) 24 | end 25 | 26 | # AdCampaign 27 | 28 | def ad_campaigns(effective_status: ['ACTIVE'], limit: 100) 29 | AdCampaign.paginate("/#{id}/campaigns", query: { effective_status: effective_status, limit: limit }) 30 | end 31 | 32 | def create_ad_campaign(name:, objective:, status: 'ACTIVE') 33 | raise ArgumentError, "Objective must be one of: #{AdCampaign::OBJECTIVES.join(', ')}" unless AdCampaign::OBJECTIVES.include?(objective) 34 | raise ArgumentError, "Status must be one of: #{AdCampaign::STATUSES.join(', ')}" unless AdCampaign::STATUSES.include?(status) 35 | query = { name: name, objective: objective, status: status } 36 | result = AdCampaign.post("/#{id}/campaigns", query: query) 37 | AdCampaign.find(result['id']) 38 | end 39 | 40 | def create_dynamic_ad_campaign(name:, product_catalog_id:, status: 'ACTIVE') 41 | raise ArgumentError, "Status must be one of: #{AdCampaign::STATUSES.join(', ')}" unless AdCampaign::STATUSES.include?(status) 42 | query = { name: name, objective: 'PRODUCT_CATALOG_SALES', status: status, promoted_object: { product_catalog_id: product_catalog_id } } 43 | result = AdCampaign.post("/#{id}/campaigns", query: query) 44 | AdCampaign.find(result['id']) 45 | end 46 | 47 | # AdSet 48 | 49 | def ad_sets(effective_status: ['ACTIVE'], limit: 100) 50 | AdSet.paginate("/#{id}/adsets", query: { effective_status: effective_status, limit: limit }) 51 | end 52 | 53 | # Ad 54 | 55 | def ads(effective_status: ['ACTIVE'], limit: 100) 56 | Ad.paginate("/#{id}/ads", query: { effective_status: effective_status, limit: limit }) 57 | end 58 | 59 | # AdImage 60 | 61 | def ad_images(hashes: nil, limit: 100) 62 | if !hashes.nil? 63 | AdImage.get("/#{id}/adimages", query: { hashes: hashes }, objectify: true) 64 | else 65 | AdImage.paginate("/#{id}/adimages", query: { limit: limit }) 66 | end 67 | end 68 | 69 | def create_ad_images(urls) 70 | files = urls.map do |url| 71 | name, path = download(url) 72 | [name, File.open(path)] 73 | end.to_h 74 | 75 | response = AdImage.post("/#{id}/adimages", query: files) 76 | files.each_value { |file| File.delete(file.path) } 77 | !response['images'].nil? ? ad_images(hashes: response['images'].map { |_key, hash| hash['hash'] }) : [] 78 | end 79 | 80 | # AdCreative 81 | 82 | def ad_creatives(limit: 100) 83 | AdCreative.paginate("/#{id}/adcreatives", query: { limit: limit }) 84 | end 85 | 86 | def create_ad_creative(creative, creative_type: nil, carousel: false) 87 | # Support old deprecated carousel param. 88 | return create_carousel_ad_creative(creative) if carousel 89 | 90 | case creative_type 91 | when 'carousel' 92 | create_carousel_ad_creative(creative) 93 | when 'link' 94 | create_link_ad_creative(creative) 95 | when 'image' 96 | create_image_ad_creative(creative) 97 | else 98 | create_image_ad_creative(creative) 99 | end 100 | end 101 | 102 | # AdAudience 103 | 104 | def ad_audiences(limit: 100) 105 | AdAudience.paginate("/#{id}/customaudiences", query: { limit: limit }) 106 | end 107 | 108 | def create_ad_audience_with_pixel(name:, pixel_id:, event_name:, subtype: 'WEBSITE', retention_days: 15) 109 | query = { 110 | name: name, 111 | pixel_id: pixel_id, 112 | subtype: subtype, 113 | retention_days: retention_days, 114 | rule: { event: { i_contains: event_name } }.to_json, 115 | prefill: 1 116 | } 117 | 118 | result = AdAudience.post("/#{id}/customaudiences", query: query) 119 | AdAudience.find(result['id']) 120 | end 121 | 122 | # AdInsight 123 | 124 | def ad_insights(range: Date.today..Date.today, level: 'ad', time_increment: 1) 125 | ad_campaigns.map do |ad_campaign| 126 | ad_campaign.ad_insights(range: range, level: level, time_increment: time_increment) 127 | end.flatten 128 | end 129 | 130 | def reach_estimate(targeting:, optimization_goal:, currency: 'USD') 131 | raise ArgumentError, "Optimization goal must be one of: #{AdSet::OPTIMIZATION_GOALS.join(', ')}" unless AdSet::OPTIMIZATION_GOALS.include?(optimization_goal) 132 | 133 | if targeting.is_a?(AdTargeting) 134 | raise ArgumentError, 'The provided targeting spec is not valid.' unless targeting.validate! 135 | targeting = targeting.to_hash 136 | end 137 | 138 | query = { 139 | targeting_spec: targeting.to_json, 140 | optimize_for: optimization_goal, 141 | currency: currency 142 | } 143 | 144 | self.class.get("/#{id}/reachestimate", query: query, objectify: false) 145 | end 146 | 147 | def delivery_estimate(targeting:, optimization_goal:, currency: 'USD') 148 | raise ArgumentError, "Optimization goal must be one of: #{AdSet::OPTIMIZATION_GOALS.join(', ')}" unless AdSet::OPTIMIZATION_GOALS.include?(optimization_goal) 149 | 150 | if targeting.is_a?(AdTargeting) 151 | raise ArgumentError, 'The provided targeting spec is not valid.' unless targeting.validate! 152 | targeting = targeting.to_hash 153 | end 154 | 155 | query = { 156 | targeting_spec: targeting.to_json, 157 | optimization_goal: optimization_goal, 158 | currency: currency 159 | } 160 | 161 | self.class.get("/#{id}/delivery_estimate", query: query, objectify: false) 162 | end 163 | 164 | private 165 | 166 | def create_carousel_ad_creative(creative) 167 | required = %i[name page_id link message assets call_to_action_type multi_share_optimized multi_share_end_card] 168 | 169 | unless (keys = required - creative.keys).length.zero? 170 | raise ArgumentError, "Creative is missing the following: #{keys.join(', ')}" 171 | end 172 | 173 | raise ArgumentError, "Creative call_to_action_type must be one of: #{AdCreative::CALL_TO_ACTION_TYPES.join(', ')}" unless AdCreative::CALL_TO_ACTION_TYPES.include?(creative[:call_to_action_type]) 174 | 175 | query = if creative[:product_set_id] 176 | AdCreative.product_set( 177 | name: creative[:name], 178 | page_id: creative[:page_id], 179 | link: creative[:link], 180 | message: creative[:message], 181 | headline: creative[:headline], 182 | description: creative[:description], 183 | product_set_id: creative[:product_set_id] 184 | ) 185 | else 186 | AdCreative.carousel(creative) 187 | end 188 | 189 | result = AdCreative.post("/#{id}/adcreatives", query: query) 190 | AdCreative.find(result['id']) 191 | end 192 | 193 | def create_image_ad_creative(creative) 194 | required = %i[name page_id message link link_title image_hash call_to_action_type] 195 | 196 | unless (keys = required - creative.keys).length.zero? 197 | raise ArgumentError, "Creative is missing the following: #{keys.join(', ')}" 198 | end 199 | 200 | raise ArgumentError, "Creative call_to_action_type must be one of: #{AdCreative::CALL_TO_ACTION_TYPES.join(', ')}" unless AdCreative::CALL_TO_ACTION_TYPES.include?(creative[:call_to_action_type]) 201 | query = AdCreative.photo(creative) 202 | result = AdCreative.post("/#{id}/adcreatives", query: query) 203 | AdCreative.find(result['id']) 204 | end 205 | 206 | def create_link_ad_creative(creative) 207 | required = %i[name title body object_url link_url image_hash page_id] 208 | 209 | unless (keys = required - creative.keys).length.zero? 210 | raise ArgumentError, "Creative is missing the following: #{keys.join(', ')}" 211 | end 212 | 213 | query = AdCreative.link(creative) 214 | result = AdCreative.post("/#{id}/adcreatives", query: query) 215 | AdCreative.find(result['id']) 216 | end 217 | 218 | def download(url) 219 | pathname = Pathname.new(url) 220 | name = "#{pathname.dirname.basename}.jpg" 221 | # @FIXME: Need to handle exception here. 222 | data = RestClient.get(url).body 223 | file = File.open("/tmp/#{name}", 'w') # Assume *nix-based system. 224 | file.binmode 225 | file.write(data) 226 | file.close 227 | [name, file.path] 228 | end 229 | end 230 | end 231 | -------------------------------------------------------------------------------- /lib/facebook_ads/ad_audience.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FacebookAds 4 | # https://developers.facebook.com/docs/marketing-api/reference/custom-audience 5 | class AdAudience < Base 6 | FIELDS = %w[id account_id subtype name description approximate_count data_source delivery_status external_event_source lookalike_audience_ids lookalike_spec operation_status opt_out_link permission_for_actions pixel_id retention_days rule time_content_updated time_created time_updated].freeze 7 | 8 | # belongs_to ad_account 9 | 10 | def ad_account 11 | @ad_account ||= AdAccount.find("act_#{account_id}") 12 | end 13 | 14 | # actions 15 | 16 | def share(account_id) 17 | AdAccount.post( 18 | "/#{id}/share_with_objects", 19 | query: { 20 | share_with_object_id: account_id, 21 | share_with_object_type: 'Account' 22 | } 23 | ) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/facebook_ads/ad_campaign.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FacebookAds 4 | # https://developers.facebook.com/docs/marketing-api/reference/ad-campaign-group 5 | class AdCampaign < Base 6 | FIELDS = %w[ 7 | id 8 | account_id 9 | buying_type 10 | can_use_spend_cap 11 | configured_status 12 | created_time 13 | effective_status 14 | name 15 | objective 16 | start_time 17 | stop_time 18 | updated_time spend_cap 19 | budget_remaining daily_budget lifetime_budget 20 | ].freeze 21 | 22 | STATUSES = %w[ 23 | ACTIVE PAUSED 24 | DELETED 25 | PENDING_REVIEW 26 | DISAPPROVED 27 | PREAPPROVED 28 | PENDING_BILLING_INFO 29 | CAMPAIGN_PAUSED 30 | ARCHIVED 31 | ADSET_PAUSED 32 | ].freeze 33 | 34 | OBJECTIVES = %w[ 35 | APP_INSTALLS 36 | BRAND_AWARENESS 37 | CONVERSIONS 38 | EVENT_RESPONSES 39 | LEAD_GENERATION 40 | LINK_CLICKS 41 | LOCAL_AWARENESS 42 | OFFER_CLAIMS 43 | PAGE_LIKES 44 | POST_ENGAGEMENT 45 | PRODUCT_CATALOG_SALES 46 | REACH 47 | VIDEO_VIEWS 48 | ].freeze 49 | 50 | # AdAccount 51 | 52 | def ad_account 53 | @ad_account ||= AdAccount.find("act_#{account_id}") 54 | end 55 | 56 | # AdSet 57 | 58 | def ad_sets(effective_status: ['ACTIVE'], limit: 100) 59 | AdSet.paginate("/#{id}/adsets", query: { effective_status: effective_status, limit: limit }) 60 | end 61 | 62 | def create_ad_set(name:, targeting:, optimization_goal:, promoted_object: {}, daily_budget: nil, lifetime_budget: nil, end_time: nil, billing_event: 'IMPRESSIONS', status: 'ACTIVE', bid_strategy: nil, bid_amount: nil) 63 | raise ArgumentError, "Optimization goal must be one of: #{AdSet::OPTIMIZATION_GOALS.join(', ')}" unless AdSet::OPTIMIZATION_GOALS.include?(optimization_goal) 64 | raise ArgumentError, "Billing event must be one of: #{AdSet::BILLING_EVENTS.join(', ')}" unless AdSet::BILLING_EVENTS.include?(billing_event) 65 | raise ArgumentError, "Bid strategy must be one of: #{AdSet::BID_STRATEGIES.join(', ')}" unless AdSet::BID_STRATEGIES.include?(bid_strategy) 66 | 67 | if targeting.is_a?(Hash) 68 | # NOP 69 | else 70 | targeting.validate! # Will raise if invalid. 71 | targeting = targeting.to_hash 72 | end 73 | 74 | query = { 75 | campaign_id: id, 76 | name: name, 77 | targeting: targeting.to_json, 78 | promoted_object: promoted_object.to_json, 79 | optimization_goal: optimization_goal, 80 | billing_event: billing_event, 81 | status: status, 82 | bid_strategy: bid_strategy, 83 | bid_amount: bid_amount 84 | } 85 | 86 | raise ArgumentError 'Only one budget may be set between daily_budget and life_budget' if daily_budget && lifetime_budget 87 | 88 | if daily_budget 89 | query[:daily_budget] = daily_budget 90 | elsif lifetime_budget 91 | query[:lifetime_budget] = lifetime_budget 92 | query[:end_time] = end_time 93 | end 94 | 95 | result = AdSet.post("/act_#{account_id}/adsets", query: query) 96 | AdSet.find(result['id']) 97 | end 98 | 99 | # AdInsight 100 | 101 | def ad_insights(range: Date.today..Date.today, level: 'ad', time_increment: 1) 102 | query = { 103 | level: level, 104 | time_increment: time_increment, 105 | time_range: { since: range.first.to_s, until: range.last.to_s } 106 | } 107 | 108 | AdInsight.paginate("/#{id}/insights", query: query) 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/facebook_ads/ad_creative.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FacebookAds 4 | # Ad ad creative has many ad images and belongs to an ad account. 5 | # https://developers.facebook.com/docs/marketing-api/reference/ad-creative 6 | class AdCreative < Base 7 | FIELDS = %w[id name object_story_id object_story_spec object_type thumbnail_url product_set_id url_tags status].freeze 8 | CALL_TO_ACTION_TYPES = %w[SHOP_NOW INSTALL_MOBILE_APP USE_MOBILE_APP SIGN_UP DOWNLOAD BUY_NOW NO_BUTTON].freeze 9 | 10 | class << self 11 | def photo(name:, page_id:, message:, link:, link_title:, image_hash:, call_to_action_type:, instagram_actor_id: nil, app_link: nil, link_description: nil) 12 | object_story_spec = { 13 | 'page_id' => page_id, # 300664329976860 14 | 'instagram_actor_id' => instagram_actor_id, # 503391023081924 15 | 'link_data' => { 16 | 'name' => link_title, 17 | 'description' => link_description, 18 | 'link' => link, # https://tophatter.com/, https://itunes.apple.com/app/id619460348, http://play.google.com/store/apps/details?id=com.tophatter 19 | 'message' => message, 20 | 'image_hash' => image_hash, 21 | 'call_to_action' => { 22 | 'type' => call_to_action_type, 23 | 'value' => { 24 | # 'application' =>, 25 | 'link' => link, 26 | 'app_link' => app_link 27 | } 28 | } 29 | } 30 | } 31 | 32 | { 33 | name: name, 34 | object_story_spec: object_story_spec.to_json 35 | } 36 | end 37 | 38 | # https://developers.facebook.com/docs/marketing-api/guides/videoads 39 | def carousel(name:, page_id:, link:, message:, assets:, call_to_action_type:, multi_share_optimized:, multi_share_end_card:, instagram_actor_id: nil, app_link: nil) 40 | object_story_spec = { 41 | 'page_id' => page_id, # 300664329976860 42 | 'instagram_actor_id' => instagram_actor_id, # 503391023081924 43 | 'link_data' => { 44 | 'link' => link, # https://tophatter.com/, https://itunes.apple.com/app/id619460348, http://play.google.com/store/apps/details?id=com.tophatter 45 | 'message' => message, 46 | 'call_to_action' => { 47 | 'type' => call_to_action_type, 48 | 'value' => { 49 | 'link' => link, 50 | 'app_link' => app_link 51 | } 52 | }, 53 | 'child_attachments' => assets.collect do |asset| 54 | { 55 | 'link' => asset[:link] || link, 56 | 'image_hash' => asset[:hash], 57 | 'name' => asset[:title], 58 | # 'description' => asset[:title], 59 | 'call_to_action' => { # Redundant? 60 | 'type' => call_to_action_type, 61 | 'value' => { 62 | 'link' => asset[:link] || link, 63 | 'app_link' => asset[:app_link] || app_link 64 | } 65 | } 66 | } 67 | end, 68 | 'multi_share_optimized' => multi_share_optimized, 69 | 'multi_share_end_card' => multi_share_end_card 70 | } 71 | } 72 | 73 | { 74 | name: name, 75 | object_story_spec: object_story_spec.to_json 76 | } 77 | end 78 | 79 | def product_set(name:, page_id:, link:, message:, headline:, description:, product_set_id:) 80 | { 81 | name: name, 82 | object_story_spec: { 83 | page_id: page_id, 84 | template_data: { 85 | description: description, 86 | link: link, 87 | message: message, 88 | name: headline 89 | } 90 | }, 91 | template_url: link, 92 | product_set_id: product_set_id 93 | } 94 | end 95 | 96 | def link(name:, title:, body:, link_url:, image_hash:, page_id:) 97 | object_story_spec = { 98 | 'page_id' => page_id, 99 | 'link_data' => { 100 | 'name' => title, 101 | 'message' => body, 102 | 'link' => link_url, 103 | 'image_hash' => image_hash 104 | } 105 | } 106 | 107 | { 108 | name: name, 109 | object_story_spec: object_story_spec.to_json 110 | } 111 | end 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/facebook_ads/ad_exception.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FacebookAds 4 | class AdException < StandardError 5 | attr_reader :code, :title, :message 6 | 7 | def initialize(code:, title:, message:) 8 | super(message) 9 | @code = code 10 | @title = title 11 | @message = message 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/facebook_ads/ad_image.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FacebookAds 4 | # An image will always produce the same hash. 5 | # https://developers.facebook.com/docs/marketing-api/reference/ad-image 6 | class AdImage < Base 7 | FIELDS = %w[id hash account_id name permalink_url original_width original_height].freeze 8 | 9 | class << self 10 | def find(_id) 11 | raise NotImplementedError 12 | end 13 | end 14 | 15 | # @FIXME: You are setting a key that conflicts with a built-in method 16 | # FacebookAds::AdImage#hash defined in Hash. 17 | disable_warnings 18 | 19 | def hash 20 | self[:hash] 21 | end 22 | 23 | def update(_data) 24 | raise NotImplementedError 25 | end 26 | 27 | def destroy 28 | super(path: "/act_#{account_id}/adimages", query: { hash: self[:hash] }) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/facebook_ads/ad_insight.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FacebookAds 4 | # Ad insights exist for ad accounts, ad campaigns, ad sets, and ads. 5 | # A lot more can be done here. 6 | # https://developers.facebook.com/docs/marketing-api/insights/overview 7 | # https://developers.facebook.com/docs/marketing-api/insights/parameters 8 | class AdInsight < Base 9 | FIELDS = %w[account_id campaign_id adset_id ad_id objective impressions unique_actions cost_per_unique_action_type clicks cpc cpm cpp ctr spend reach].freeze 10 | 11 | class << self 12 | def find(_id) 13 | raise NotImplementedError 14 | end 15 | end 16 | 17 | def update(_data) 18 | raise NotImplementedError 19 | end 20 | 21 | def destroy 22 | raise NotImplementedError 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/facebook_ads/ad_product.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FacebookAds 4 | # https://developers.facebook.com/docs/marketing-api/reference/product-item 5 | class AdProduct < Base 6 | FIELDS = %w[id retailer_id name description brand category currency price image_url url].freeze 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/facebook_ads/ad_product_catalog.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FacebookAds 4 | # https://developers.facebook.com/docs/marketing-api/reference/product-catalog 5 | class AdProductCatalog < Base 6 | FIELDS = %w[id name vertical product_count feed_count].freeze 7 | 8 | class << self 9 | def all(query = {}) 10 | get("/#{FacebookAds.business_id}/owned_product_catalogs", query: query, objectify: true) 11 | end 12 | 13 | def create(name:) 14 | query = { name: name } 15 | result = post("/#{FacebookAds.business_id}/owned_product_catalogs", query: query) 16 | find(result['id']) 17 | end 18 | end 19 | 20 | # has_many ad_product_feeds 21 | 22 | def ad_product_feeds 23 | AdProductFeed.paginate("/#{id}/product_feeds") 24 | end 25 | 26 | # catalog.create_ad_product_feed(name: 'Test', schedule: { url: 'https://tophatter.com/admin/ad_automation/ad_product_feeds/1.csv', interval: 'HOURLY' }) 27 | def create_ad_product_feed(name:, schedule:) 28 | query = { name: name, schedule: schedule } 29 | result = AdProductCatalog.post("/#{id}/product_feeds", query: query) 30 | AdProductFeed.find(result['id']) 31 | end 32 | 33 | # catalog.create_ad_product_set(name: 'Ring Set', filter: {"category":{"i_contains":"Apparel & Accessories > Jewelry > Earrings"}}) 34 | def create_ad_product_set(name:, filter:) 35 | query = { name: name, filter: filter.to_json } 36 | result = AdProductSet.post("/#{id}/product_sets", query: query) 37 | AdProductSet.find(result['id']) 38 | end 39 | 40 | # has_many product_groups 41 | 42 | def ad_product_groups 43 | AdProductGroup.paginate("/#{id}/product_groups") 44 | end 45 | 46 | # has_many product_sets 47 | 48 | def ad_product_sets 49 | AdProductSet.paginate("/#{id}/product_sets") 50 | end 51 | 52 | # has_many ad_products 53 | 54 | def ad_products 55 | AdProduct.paginate("/#{id}/products") 56 | end 57 | 58 | def create_ad_product(data) 59 | result = AdProductCatalog.post("/#{id}/products", query: data) 60 | AdProduct.find(result['id']) 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/facebook_ads/ad_product_feed.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FacebookAds 4 | # https://developers.facebook.com/docs/marketing-api/reference/product-feed 5 | class AdProductFeed < Base 6 | FIELDS = %w[id country created_time default_currency deletion_enabled delimiter encoding file_name latest_upload name product_count quoted_fields_mode schedule].freeze 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/facebook_ads/ad_product_set.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FacebookAds 4 | # https://developers.facebook.com/docs/marketing-api/reference/product-set 5 | class AdProductSet < Base 6 | FIELDS = %w[id auto_creation_url filter name product_catalog product_count].freeze 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/facebook_ads/ad_set.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FacebookAds 4 | # https://developers.facebook.com/docs/marketing-api/reference/ad-campaign 5 | class AdSet < Base 6 | FIELDS = %w[ 7 | id 8 | account_id 9 | campaign_id 10 | name 11 | status 12 | configured_status 13 | effective_status 14 | bid_strategy 15 | bid_amount 16 | billing_event 17 | optimization_goal 18 | pacing_type 19 | daily_budget 20 | budget_remaining 21 | lifetime_budget 22 | promoted_object 23 | targeting 24 | attribution_spec 25 | start_time 26 | end_time 27 | created_time 28 | updated_time 29 | ].freeze 30 | 31 | STATUSES = %w[ 32 | ACTIVE 33 | PAUSED 34 | DELETED 35 | PENDING_REVIEW 36 | DISAPPROVED 37 | PREAPPROVED 38 | PENDING_BILLING_INFO 39 | CAMPAIGN_PAUSED 40 | ARCHIVED 41 | ADSET_PAUSED 42 | ].freeze 43 | 44 | BILLING_EVENTS = %w[APP_INSTALLS IMPRESSIONS].freeze 45 | 46 | OPTIMIZATION_GOALS = %w[ 47 | NONE 48 | APP_INSTALLS 49 | BRAND_AWARENESS 50 | AD_RECALL_LIFT 51 | CLICKS 52 | ENGAGED_USERS 53 | EVENT_RESPONSES 54 | IMPRESSIONS 55 | LEAD_GENERATION 56 | LINK_CLICKS 57 | OFFER_CLAIMS 58 | OFFSITE_CONVERSIONS 59 | PAGE_ENGAGEMENT 60 | PAGE_LIKES 61 | POST_ENGAGEMENT 62 | REACH 63 | SOCIAL_IMPRESSIONS 64 | VIDEO_VIEWS 65 | APP_DOWNLOADS 66 | LANDING_PAGE_VIEWS 67 | ].freeze 68 | 69 | BID_STRATEGIES = %w[ 70 | LOWEST_COST_WITHOUT_CAP 71 | LOWEST_COST_WITH_BID_CAP 72 | COST_CAP 73 | LOWEST_COST_WITH_MIN_ROAS 74 | ].freeze 75 | 76 | # AdAccount 77 | 78 | def ad_account 79 | @ad_account ||= AdAccount.find("act_#{account_id}") 80 | end 81 | 82 | # AdCampaign 83 | 84 | def ad_campaign 85 | @ad_campaign ||= AdCampaign.find(campaign_id) 86 | end 87 | 88 | # Ad 89 | 90 | def ads(effective_status: ['ACTIVE'], limit: 100) 91 | Ad.paginate("/#{id}/ads", query: { effective_status: effective_status, limit: limit }) 92 | end 93 | 94 | def create_ad(name:, creative_id:, status: 'PAUSED') 95 | query = { name: name, adset_id: id, creative: { creative_id: creative_id }.to_json, status: status } 96 | result = Ad.post("/act_#{account_id}/ads", query: query) 97 | Ad.find(result['id']) 98 | end 99 | 100 | # AdInsight 101 | 102 | # levels = enum {ad, adset, campaign, account} 103 | # breakdowns = list 104 | def ad_insights(range: Date.today..Date.today, level: nil, breakdowns: [], fields: [], limit: 1_000) 105 | query = { 106 | time_range: { since: range.first.to_s, until: range.last.to_s }, 107 | level: level, 108 | breakdowns: breakdowns&.join(','), 109 | fields: fields&.join(','), 110 | limit: limit 111 | }.reject { |_key, value| value.nil? || (value.respond_to?(:empty?) && value.empty?) } 112 | 113 | AdInsight.paginate("/#{id}/insights", query: query) 114 | end 115 | 116 | def activities(from: Date.today.beginning_of_day, to: Date.today.end_of_day) 117 | query = { since: from, until: to } 118 | AdSetActivity.get("/#{id}/activities", query: query, objectify: true) 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/facebook_ads/ad_set_activity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FacebookAds 4 | # https://developers.facebook.com/docs/marketing-api/reference/ad-activity/ 5 | class AdSetActivity < Base 6 | FIELDS = %w[ 7 | actor_id 8 | actor_name 9 | event_time 10 | event_type 11 | extra_data 12 | ].freeze 13 | 14 | CATEGORIES = %w[ 15 | ACCOUNT 16 | AD 17 | AD_SET 18 | AUDIENCE 19 | BID 20 | BUDGET 21 | CAMPAIGN 22 | DATE 23 | STATUS 24 | TARGETING 25 | ].freeze 26 | 27 | EVENT_TYPES = %w[ 28 | ad_account_update 29 | spend_limit 30 | ad_account_reset_spend_limit 31 | ad_account_remove_spend_limit 32 | ad_account_set_business_information 33 | ad_account_update_status 34 | ad_account_add_user_to_role 35 | ad_account_remove_user_from_role 36 | add_images 37 | edit_images 38 | delete_images 39 | ad_account_billing_charge 40 | ad_account_billing_charge_failed 41 | ad_account_billing_chargeback 42 | ad_account_billing_chargeback_reversal 43 | ad_account_billing_decline 44 | ad_account_billing_refund 45 | billing_event 46 | add_funding_source 47 | remove_funding_source 48 | create_campaign_group 49 | update_campaign_name 50 | update_campaign_run_status 51 | update_campaign_group_spend_cap 52 | create_campaign_legacy 53 | update_campaign_budget 54 | update_campaign_duration 55 | campaign_ended 56 | create_ad_set 57 | update_ad_set_bidding 58 | update_ad_set_bid_strategy 59 | update_ad_set_budget 60 | update_ad_set_duration 61 | update_ad_set_run_status 62 | update_ad_set_name 63 | update_ad_set_optimization_goal 64 | update_ad_set_target_spec 65 | update_ad_set_bid_adjustments 66 | create_ad 67 | ad_review_approved 68 | ad_review_declined 69 | update_ad_creative 70 | edit_and_update_ad_creative 71 | update_ad_bid_info 72 | update_ad_bid_type 73 | update_ad_run_status 74 | update_ad_run_status_to_be_set_after_review 75 | update_ad_friendly_name 76 | update_ad_targets_spec 77 | update_adgroup_stop_delivery 78 | first_delivery_event 79 | create_audience 80 | update_audience 81 | delete_audience 82 | share_audience 83 | receive_audience 84 | unshare_audience 85 | remove_shared_audience 86 | unknown 87 | account_spending_limit_reached 88 | campaign_spending_limit_reached 89 | lifetime_budget_spent 90 | funding_event_initiated 91 | funding_event_successful 92 | update_ad_labels 93 | di_ad_set_learning_stage_exit 94 | ].freeze 95 | 96 | # belongs_to ad_set 97 | 98 | def ad_set 99 | @ad_set ||= AdSet.find(ad_set_id) 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/facebook_ads/ad_targeting.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FacebookAds 4 | # https://developers.facebook.com/docs/marketing-api/targeting-specs 5 | class AdTargeting 6 | MEN = 1 7 | WOMEN = 2 8 | GENDERS = [MEN, WOMEN].freeze 9 | ANDROID_OS = 'Android' 10 | APPLE_OS = 'iOS' 11 | OSES = [ANDROID_OS, APPLE_OS].freeze 12 | ANDROID_DEVICES = %w[Android_Smartphone Android_Tablet].freeze 13 | APPLE_DEVICES = %w[iPhone iPad iPod].freeze 14 | DEVICES = ANDROID_DEVICES + APPLE_DEVICES 15 | INSTALLED = 'installed' 16 | NOT_INSTALLED = 'not_installed' 17 | APP_INSTALL_STATES = [INSTALLED, NOT_INSTALLED].freeze 18 | 19 | attr_accessor :genders, :age_min, :age_max, :countries, :user_os, :user_device, :app_install_state, :custom_locations, :income 20 | 21 | def initialize 22 | # self.genders = [WOMEN] # If nil, defaults to all genders. 23 | # self.age_min = 18 # If nil, defaults to 18. 24 | # self.age_max = 65 # If nil, defaults to 65+. 25 | # self.user_os = [ANDROID_OS] 26 | # self.user_device = ANDROID_DEVICES 27 | # self.app_install_state = NOT_INSTALLED 28 | self.income = [] # An a rray of objects with 'id' and optional 'name' 29 | end 30 | 31 | def geo_locations 32 | if custom_locations 33 | { custom_locations: custom_locations } 34 | elsif countries 35 | { countries: countries } 36 | else 37 | { countries: ['US'] } 38 | end 39 | end 40 | 41 | def validate! 42 | { gender: genders, countries: countries, user_os: user_os, user_device: user_device, custom_locations: custom_locations }.each_pair do |key, array| 43 | raise ArgumentError, "#{self.class.name}: #{key} must be an array" if !array.nil? && !array.is_a?(Array) 44 | end 45 | 46 | { genders: [genders, GENDERS], user_os: [user_os, OSES], user_device: [user_device, DEVICES] }.each_pair do |key, provided_and_acceptable| 47 | provided, acceptable = provided_and_acceptable 48 | 49 | if !provided.nil? && !(invalid = provided.detect { |value| !acceptable.include?(value) }).nil? 50 | raise ArgumentError, "#{self.class.name}: #{invalid} is an invalid #{key}" 51 | end 52 | end 53 | 54 | true 55 | end 56 | 57 | def to_hash 58 | { 59 | genders: genders, 60 | age_min: age_min, 61 | age_max: age_max, 62 | geo_locations: geo_locations, 63 | user_os: user_os, 64 | user_device: user_device, 65 | app_install_state: app_install_state, 66 | income: income 67 | }.reject { |_k, v| v.nil? } 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/facebook_ads/ad_user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FacebookAds 4 | # An ad user belongs to an ad account. 5 | # https://developers.facebook.com/docs/marketing-api/reference/ad-campaign-group 6 | class AdUser < Base 7 | FIELDS = %w[].freeze 8 | 9 | # belongs_to ad_account 10 | 11 | def ad_account 12 | @ad_account ||= AdAccount.find("act_#{account_id}") 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/facebook_ads/advertisable_application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FacebookAds 4 | # https://developers.facebook.com/docs/marketing-api/reference/ad-account/advertisable_applications 5 | class AdvertisableApplication < Base 6 | FIELDS = %w[].freeze 7 | 8 | # belongs_to ad_account 9 | 10 | def ad_account 11 | @ad_account ||= AdAccount.find("act_#{account_id}") 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/facebook_ads/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FacebookAds 4 | # The base class for all ads objects. 5 | class Base < Hashie::Mash 6 | class << self 7 | # HTTP verbs. 8 | 9 | def get(path, objectify:, query: {}) 10 | query = pack(query, objectify: objectify) 11 | uri = "#{FacebookAds.base_uri}#{path}?" + build_nested_query(query) 12 | FacebookAds.logger.debug "GET #{uri}" 13 | 14 | response = begin 15 | RestClient.get(uri, FacebookAds::REQUEST_HEADERS) 16 | rescue RestClient::Exception => e 17 | exception(:get, path, e) 18 | end 19 | 20 | unpack(response, objectify: objectify) 21 | end 22 | 23 | def post(path, query: {}) 24 | query = pack(query, objectify: false) 25 | uri = "#{FacebookAds.base_uri}#{path}" 26 | FacebookAds.logger.debug "POST #{uri} #{query}" 27 | 28 | response = begin 29 | RestClient.post(uri, query) 30 | rescue RestClient::Exception => e 31 | exception(:post, path, e) 32 | end 33 | 34 | unpack(response, objectify: false) 35 | end 36 | 37 | def delete(path, query: {}) 38 | query = pack(query, objectify: false) 39 | uri = "#{FacebookAds.base_uri}#{path}?" + build_nested_query(query) 40 | FacebookAds.logger.debug "DELETE #{uri}" 41 | 42 | response = begin 43 | RestClient.delete(uri) 44 | rescue RestClient::Exception => e 45 | exception(:delete, path, e) 46 | end 47 | 48 | unpack(response, objectify: false) 49 | end 50 | 51 | # Common idioms. 52 | 53 | def all(_query = {}) 54 | raise StandardError, 'Subclass must implement `all`.' 55 | end 56 | 57 | def find(id) 58 | get("/#{id}", objectify: true) 59 | end 60 | 61 | def find_by(conditions) 62 | all.detect do |object| 63 | conditions.all? do |key, value| 64 | object.send(key) == value 65 | end 66 | end 67 | end 68 | 69 | def paginate(path, query: {}) 70 | query = query.merge(limit: 100) unless query[:limit] 71 | query = query.merge(fields: self::FIELDS.join(',')) unless query[:fields] || self::FIELDS.empty? 72 | response = get(path, query: query, objectify: false) 73 | data = response['data'].nil? ? [] : response['data'] 74 | 75 | if data.length == query[:limit] 76 | while !(paging = response['paging']).nil? && !(url = paging['next']).nil? 77 | FacebookAds.logger.debug "GET #{url}" 78 | 79 | response = begin 80 | RestClient.get(url) 81 | rescue RestClient::Exception => e 82 | exception(:get, url, e) 83 | end 84 | 85 | response = unpack(response, objectify: false) 86 | data += response['data'] unless response['data'].nil? 87 | end 88 | end 89 | 90 | if data.nil? 91 | [] 92 | else 93 | data.map { |hash| instantiate(hash) } 94 | end 95 | end 96 | 97 | private 98 | 99 | def instantiate(hash) 100 | object = new 101 | 102 | hash.each_pair do |key, value| 103 | # https://github.com/intridea/hashie/blob/master/lib/hashie/mash.rb#L111 104 | # key = '_hash' if key == 'hash' 105 | object.custom_writer(key, value, false) 106 | end 107 | 108 | object 109 | end 110 | 111 | def pack(hash, objectify:) 112 | hash = hash.merge(access_token: FacebookAds.access_token) 113 | hash = hash.merge(appsecret_proof: FacebookAds.appsecret_proof) if FacebookAds.app_secret 114 | hash = hash.merge(fields: self::FIELDS.join(',')) if objectify && !hash[:fields] && !self::FIELDS.empty? 115 | hash.reject { |_key, value| value.nil? || (value.respond_to?(:empty?) && value.empty?) } 116 | end 117 | 118 | def unpack(response, objectify:) 119 | raise ArgumentError, 'Invalid nil response' if response.nil? 120 | response = response.body if response.is_a?(RestClient::Response) 121 | response = JSON.parse(response) if response.is_a?(String) 122 | 123 | raise ArgumentError, "Invalid response: #{response.class.name} #{response.inspect}" unless response.is_a?(Hash) 124 | raise ArgumentError, "[#{response['error']['code']}] #{response['error']['message']} - raw response: #{response.inspect}" unless response['error'].nil? 125 | return response unless objectify 126 | 127 | if response.key?('data') && (data = response['data']).is_a?(Array) 128 | data.map { |hash| instantiate(hash) } 129 | else 130 | instantiate(response) 131 | end 132 | end 133 | 134 | def escape(str) 135 | URI.encode_www_form_component(str) 136 | end 137 | 138 | # https://github.com/rack/rack/blob/master/lib/rack/utils.rb 139 | def build_nested_query(value, prefix = nil) 140 | case value 141 | when Array 142 | value.map { |v| build_nested_query(v, "#{prefix}[]") }.join('&') 143 | when Hash 144 | value.map { |k, v| build_nested_query(v, prefix ? "#{prefix}[#{escape(k)}]" : escape(k)) }.reject(&:empty?).join('&') 145 | when nil 146 | prefix 147 | else 148 | raise ArgumentError, 'value must be a Hash' if prefix.nil? 149 | "#{prefix}=#{escape(value)}" 150 | end 151 | end 152 | 153 | def exception(verb, path, exception) 154 | FacebookAds.logger.error exception.response 155 | response = exception.response 156 | 157 | message = if response.is_a?(String) 158 | begin 159 | if (error = JSON.parse(response)['error']).nil? 160 | response 161 | elsif error['error_subcode'].nil? || error['error_user_title'].nil? || error['error_user_msg'].nil? 162 | "#{error['type']} / #{error['code']}: #{error['message']}" 163 | else 164 | exception = AdException.new( 165 | code: error['error_subcode'], 166 | title: error['error_user_title'], 167 | message: error['error_user_msg'] 168 | ) 169 | "#{exception.code} / #{exception.title}: #{exception.message}" 170 | end 171 | rescue JSON::ParserError 172 | response 173 | end 174 | else 175 | response.inspect 176 | end 177 | 178 | FacebookAds.logger.error "#{verb.upcase} #{path} #{message}" 179 | raise exception 180 | end 181 | end 182 | 183 | def save 184 | return nil if changes.nil? || changes.length.zero? 185 | data = {} 186 | changes.each_key { |key| data[key] = self[key] } 187 | return nil unless update(data) 188 | self.class.find(id) 189 | end 190 | 191 | def update(data) 192 | return false if data.nil? 193 | response = self.class.post("/#{id}", query: data) 194 | response['success'] 195 | end 196 | 197 | def destroy(path: nil, query: {}) 198 | response = self.class.delete(path || "/#{id}", query: query) 199 | response['success'] 200 | end 201 | 202 | protected 203 | 204 | attr_accessor :changes 205 | 206 | def persisted? 207 | !id.nil? 208 | end 209 | 210 | private 211 | 212 | def []=(key, value) 213 | old_value = self[key] 214 | new_value = super(key, value) 215 | 216 | self.changes ||= {} 217 | 218 | if old_value != new_value 219 | self.changes[key] = [old_value, new_value] 220 | else 221 | self.changes.delete(key) 222 | end 223 | 224 | new_values 225 | end 226 | end 227 | end 228 | -------------------------------------------------------------------------------- /spec/facebook_ads/ad_account_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | # FACEBOOK_ACCESS_TOKEN=... rspec spec/facebook_ads/ad_account_spec.rb 6 | describe FacebookAds::AdAccount do 7 | let(:account) do 8 | FacebookAds::AdAccount.new( 9 | id: 'act_10152335766987003', 10 | account_id: '10152335766987003', 11 | account_status: 1, 12 | age: 466.0, 13 | created_time: '2014-10-24T14:38:31-0700', 14 | currency: 'USD', 15 | name: 'Chris Estreich' 16 | ) 17 | end 18 | 19 | describe '#all' do 20 | it 'lists all accounts', :vcr do 21 | accounts = FacebookAds::AdAccount.all 22 | verify(format: :json) { JSON.dump(accounts) } 23 | end 24 | end 25 | 26 | describe '#find_by' do 27 | it 'finds a specific account', :vcr do 28 | account = FacebookAds::AdAccount.find_by(name: 'iOS') 29 | verify(format: :json) { JSON.dump(account) } 30 | end 31 | end 32 | 33 | describe '.ad_campaigns' do 34 | it 'lists campaigns', :vcr do 35 | ad_campaigns = account.ad_campaigns 36 | verify(format: :json) { JSON.dump(ad_campaigns) } 37 | end 38 | end 39 | 40 | describe '.create_ad_campaign' do 41 | it 'creates a new ad campaign', :vcr do 42 | ad_campaign = account.create_ad_campaign(name: 'Test', objective: 'APP_INSTALLS') 43 | verify(format: :json) { JSON.dump(ad_campaign) } 44 | end 45 | end 46 | 47 | describe '.ad_images' do 48 | it 'lists images', :vcr do 49 | ad_images = account.ad_images(hashes: %w[d8fc613662fb5ef6cf5fb9d1fe779315]) 50 | verify(format: :json) { JSON.dump(ad_images) } 51 | expect(ad_images.first.hash).to eq('d8fc613662fb5ef6cf5fb9d1fe779315') 52 | end 53 | end 54 | 55 | describe '.create_ad_images' do 56 | it 'creates an image', :vcr do 57 | ad_images = account.create_ad_images(['https://img0.etsystatic.com/108/1/13006112/il_570xN.1047856494_l2gp.jpg']) 58 | verify(format: :json) { JSON.dump(ad_images) } 59 | expect(ad_images.first.hash).to eq('d8fc613662fb5ef6cf5fb9d1fe779315') 60 | end 61 | end 62 | 63 | describe '.ad_creatives' do 64 | it 'lists creatives', :vcr do 65 | ad_creatives = account.ad_creatives 66 | verify(format: :json) { JSON.dump(ad_creatives) } 67 | end 68 | end 69 | 70 | describe '.create_ad_creative' do 71 | it 'creates carousel ad creative', :vcr do 72 | ad_images = account.create_ad_images(%w[https://img0.etsystatic.com/108/1/13006112/il_570xN.1047856494_l2gp.jpg https://img1.etsystatic.com/143/0/13344107/il_570xN.1141249285_xciv.jpg]) 73 | carousel_ad_creative = account.create_ad_creative( 74 | { 75 | name: 'Test Carousel Creative', 76 | page_id: '300664329976860', # Add your Facebook Page ID here. 77 | link: 'http://play.google.com/store/apps/details?id=com.tophatter', # Add your Play Store ID here. 78 | message: 'A message.', 79 | assets: [ 80 | { hash: ad_images[0].hash, title: 'Image #1 Title' }, 81 | { hash: ad_images[1].hash, title: 'Image #2 Title' } 82 | ], 83 | call_to_action_type: 'SHOP_NOW', 84 | multi_share_optimized: true, 85 | multi_share_end_card: false 86 | }, 87 | creative_type: 'carousel' 88 | ) 89 | expect(carousel_ad_creative.id).to eq('120330000008134415') 90 | end 91 | end 92 | 93 | describe '.ad_sets' do 94 | it 'lists ad sets', :vcr do 95 | ad_sets = account.ad_sets 96 | verify(format: :json) { JSON.dump(ad_sets) } 97 | end 98 | end 99 | 100 | describe '.ads' do 101 | it 'lists ads', :vcr do 102 | ads = account.ads 103 | verify(format: :json) { JSON.dump(ads) } 104 | end 105 | end 106 | 107 | describe '.reach_estimate' do 108 | it 'estimates the reach of a targeting spec', :vcr do 109 | targeting = FacebookAds::AdTargeting.new 110 | targeting.genders = [FacebookAds::AdTargeting::WOMEN] 111 | targeting.age_min = 18 112 | targeting.age_max = 20 113 | targeting.countries = ['US'] 114 | reach = account.reach_estimate(targeting: targeting, optimization_goal: 'OFFSITE_CONVERSIONS') 115 | verify(format: :json) { JSON.dump(reach) } 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /spec/facebook_ads/ad_campaign_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | # FACEBOOK_ACCESS_TOKEN=... rspec spec/facebook_ads/ad_campaign_spec.rb 6 | describe FacebookAds::AdCampaign do 7 | let(:campaign) do 8 | FacebookAds::AdCampaign.new( 9 | id: '120330000008134915', 10 | account_id: '10152335766987003', 11 | buying_type: 'AUCTION', 12 | can_use_spend_cap: true, 13 | configured_status: 'PAUSED', 14 | created_time: '2017-08-25T15:47:51-0700', 15 | effective_status: 'PAUSED', 16 | name: 'Test Campaign', 17 | objective: 'CONVERSIONS', 18 | start_time: '1969-12-31T15:59:59-0800', 19 | updated_time: '2017-08-25T15:47:51-0700' 20 | ) 21 | end 22 | 23 | let(:targeting) do 24 | targeting = FacebookAds::AdTargeting.new 25 | targeting.genders = [FacebookAds::AdTargeting::WOMEN] 26 | targeting.age_min = 29 27 | targeting.age_max = 65 28 | targeting.countries = ['US'] 29 | targeting.user_os = [FacebookAds::AdTargeting::ANDROID_OS] 30 | targeting.user_device = FacebookAds::AdTargeting::ANDROID_DEVICES 31 | targeting.app_install_state = FacebookAds::AdTargeting::NOT_INSTALLED 32 | targeting 33 | end 34 | 35 | describe '.create_ad_set' do 36 | it 'creates_valid_ad_set', :vcr do 37 | ad_set = campaign.create_ad_set( 38 | name: 'Test Ad Set', 39 | targeting: targeting, 40 | optimization_goal: 'IMPRESSIONS', 41 | daily_budget: 500, # This is in cents, so the daily budget here is $5. 42 | billing_event: 'IMPRESSIONS', 43 | status: 'PAUSED', 44 | bid_strategy: 'LOWEST_COST_WITHOUT_CAP' 45 | ) 46 | expect(ad_set.id).to eq('120330000008135715') 47 | end 48 | end 49 | 50 | describe '.destroy' do 51 | it 'sets effective status to deleted', :vcr do 52 | ad_campaign = FacebookAds::AdCampaign.find('6076262142242') 53 | expect(ad_campaign.destroy).to be(true) 54 | ad_campaign = FacebookAds::AdCampaign.find(ad_campaign.id) 55 | expect(ad_campaign.effective_status).to eq('DELETED') 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/facebook_ads/ad_targeting_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe FacebookAds::AdTargeting do 6 | let(:targeting) { FacebookAds::AdTargeting.new } 7 | 8 | describe '#geo_locations' do 9 | let(:custom_locations) { [{ radius: 10, distance_unit: 'mile', address_string: '1601 Willow Road, Menlo Park, CA 94025' }] } 10 | let(:countries) { ['JP'] } 11 | 12 | it 'should return custom locations if specified' do 13 | targeting.custom_locations = custom_locations 14 | expect(targeting.geo_locations).to eq(custom_locations: custom_locations) 15 | end 16 | 17 | it 'should return countries if specified' do 18 | targeting.countries = countries 19 | expect(targeting.geo_locations).to eq(countries: countries) 20 | end 21 | 22 | it 'should default to US if nothing is specified' do 23 | expect(targeting.geo_locations).to eq(countries: ['US']) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/facebook_ads/facebook_ads_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | # rspec spec/facebook_ads/facebook_ads_spec.rb 6 | describe FacebookAds do 7 | describe '#base_uri' do 8 | subject { FacebookAds.base_uri } 9 | 10 | context 'when the version is not overridden' do 11 | it { is_expected.to eq('https://graph.facebook.com/v2.9') } 12 | end 13 | 14 | context 'when the version is overridden' do 15 | before { FacebookAds.api_version = '2.10' } 16 | after { FacebookAds.api_version = '2.9' } 17 | 18 | it { is_expected.to eq('https://graph.facebook.com/v2.10') } 19 | end 20 | end 21 | 22 | describe '#api_version' do 23 | subject { FacebookAds.api_version } 24 | 25 | context 'when the version is not overridden' do 26 | it { is_expected.to eq('2.9') } 27 | end 28 | 29 | context 'when the version is overridden' do 30 | before { FacebookAds.api_version = '2.10' } 31 | after { FacebookAds.api_version = '2.9' } 32 | 33 | it { is_expected.to eq('2.10') } 34 | end 35 | end 36 | 37 | describe '#stubbornly' do 38 | subject { FacebookAds.stubbornly { true } } 39 | 40 | it { is_expected.to eq(true) } 41 | end 42 | 43 | describe '#stubbornly_get', :vcr do 44 | subject(:response) { FacebookAds.stubbornly_get('http://worldtimeapi.org/api/ip') } 45 | 46 | let(:parsed_response) { JSON.parse(response) } 47 | 48 | it 'executes the request for the provided url' do 49 | expect(parsed_response).to eq( 50 | 'abbreviation' => 'AKDT', 51 | 'client_ip' => '66.58.139.118', 52 | 'datetime' => '2020-09-29T11:59:09.743279-08:00', 53 | 'day_of_week' => 2, 54 | 'day_of_year' => 273, 55 | 'dst' => true, 56 | 'dst_from' => '2020-03-08T11:00:00+00:00', 57 | 'dst_offset' => 3600, 58 | 'dst_until' => '2020-11-01T10:00:00+00:00', 59 | 'raw_offset' => -32_400, 60 | 'timezone' => 'America/Anchorage', 61 | 'unixtime' => 1_601_409_549, 62 | 'utc_datetime' => '2020-09-29T19:59:09.743279+00:00', 63 | 'utc_offset' => '-08:00', 64 | 'week_number' => 40 65 | ) 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/fixtures/approvals/facebookads_adaccount/ad_campaigns/lists_campaigns.approved.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "", 4 | "account_id": "", 5 | "buying_type": "AUCTION", 6 | "can_use_spend_cap": true, 7 | "configured_status": "ACTIVE", 8 | "created_time": "