Ads APIs

Create Meta Ad Campaigns From an Excel Sheet With Python

Use Python and the Meta Marketing API to read an Excel sheet and create campaigns, ad sets, creatives and ads in bulk, paused and safe to review first.

TL;DR

Put one ad per row in a spreadsheet, read it with pandas, and use Meta's facebook-business Python SDK to create a campaign, ad set, image, creative and ad for each row. Create everything as PAUSED, log every ID back to the sheet, and review in Ads Manager before you turn anything on.

You can create Meta campaigns from a spreadsheet with about 100 lines of Python. Put one ad per row, read the sheet with pandas, and for each row create a campaign, an ad set, an image, a creative and an ad through the Meta Marketing API. Create everything paused, write the new IDs back to the sheet, and check it in Ads Manager before you turn it on.

The video is a short demo of a script that does exactly this. It loads data from an Excel sheet and creates the campaigns, ad sets, ad creatives and ads. This post walks through how to build that script yourself, with code that matches the current API.

When this is worth doing

Ads Manager has its own bulk import, and for a one-off it's fine. A script pays off when:

  • You launch the same structure for many clients or products every week.
  • The sheet comes from somewhere else: a product feed, a planner's template, a client brief.
  • You want naming conventions and UTM parameters applied the same way every time.
  • You want a log of what was created, with IDs, without copying them by hand.

What you need before you start

  • An access token with ads_management on the ad account. The long-lived token guide shows how to get one that doesn't expire.
  • The ad account ID (act_...) and the Facebook Page ID the ads run from.
  • Python 3.9+ and these packages:
pip install facebook-business pandas openpyxl python-dotenv

Step 1: Design the sheet

One row is one ad. Campaign and ad set columns repeat on rows that share them. Here's the layout I use:

campaign_nameobjectiveadset_namedaily_budgetcountriesage_minage_maxad_nameimage_pathheadlineprimary_textlinkcta
Spring SaleOUTCOME_TRAFFICUS 25-4520US2545Ad Aimages/a.jpgSpring sale20% off this weekhttps://example.com/saleSHOP_NOW
Spring SaleOUTCOME_TRAFFICUS 25-4520US2545Ad Bimages/b.jpgNew arrivalsSee what's newhttps://example.com/newLEARN_MORE

A few rules that save pain later:

  • daily_budget is in the account currency in the sheet. The script converts it to minor units (cents) for the API.
  • countries is a comma-separated list of two-letter codes.
  • objective uses Meta's outcome objectives: OUTCOME_TRAFFIC, OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_ENGAGEMENT, OUTCOME_AWARENESS, OUTCOME_APP_PROMOTION.
  • cta uses Meta's call-to-action types, like SHOP_NOW, LEARN_MORE, SIGN_UP.

Step 2: Set up the SDK

# bulk_create.py
import os
import pandas as pd
from dotenv import load_dotenv
from facebook_business.api import FacebookAdsApi
from facebook_business.adobjects.adaccount import AdAccount
from facebook_business.exceptions import FacebookRequestError

load_dotenv()

FacebookAdsApi.init(
    app_id=os.environ["META_APP_ID"],
    app_secret=os.environ["META_APP_SECRET"],
    access_token=os.environ["META_TOKEN"],
)

account = AdAccount(os.environ["META_AD_ACCOUNT_ID"])  # "act_1234567890"
PAGE_ID = os.environ["META_PAGE_ID"]

Step 3: Create campaigns and ad sets once each

Rows share campaigns and ad sets, so cache the IDs. Otherwise you'd create a new campaign for every ad.

campaign_ids: dict[str, str] = {}
adset_ids: dict[tuple[str, str], str] = {}

def get_campaign(row) -> str:
    key = row.campaign_name
    if key not in campaign_ids:
        campaign = account.create_campaign(params={
            "name": row.campaign_name,
            "objective": row.objective,
            "status": "PAUSED",
            "special_ad_categories": [],  # required, even when empty
            # Budgets live on the ad set here, so say so explicitly.
            "is_adset_budget_sharing_enabled": False,
        })
        campaign_ids[key] = campaign["id"]
    return campaign_ids[key]

def get_adset(row, campaign_id: str) -> str:
    key = (row.campaign_name, row.adset_name)
    if key not in adset_ids:
        adset = account.create_ad_set(params={
            "name": row.adset_name,
            "campaign_id": campaign_id,
            "daily_budget": int(round(float(row.daily_budget) * 100)),  # minor units
            "billing_event": "IMPRESSIONS",
            "optimization_goal": "LINK_CLICKS",
            "bid_strategy": "LOWEST_COST_WITHOUT_CAP",
            "targeting": {
                "geo_locations": {
                    "countries": [c.strip() for c in str(row.countries).split(",")],
                },
                "age_min": int(row.age_min),
                "age_max": int(row.age_max),
                "targeting_automation": {"advantage_audience": 0},
            },
            "status": "PAUSED",
        })
        adset_ids[key] = adset["id"]
    return adset_ids[key]

The optimization goal has to fit the objective. LINK_CLICKS with IMPRESSIONS billing fits OUTCOME_TRAFFIC. For OUTCOME_SALES you'd optimize for OFFSITE_CONVERSIONS and pass a promoted_object with your pixel ID and event. If you mix objectives in one sheet, add an optimization_goal column instead of hard-coding it.

Step 4: Upload the image and create the creative

Meta wants images uploaded first. You get back a hash that the creative references.

def upload_image(path: str) -> str:
    image = account.create_ad_image(params={"filename": path})
    return image["hash"]

def create_creative(row, image_hash: str) -> str:
    creative = account.create_ad_creative(params={
        "name": f"{row.ad_name} creative",
        "object_story_spec": {
            "page_id": PAGE_ID,
            "link_data": {
                "image_hash": image_hash,
                "link": row.link,
                "message": row.primary_text,
                "name": row.headline,
                "call_to_action": {
                    "type": row.cta,
                    "value": {"link": row.link},
                },
            },
        },
    })
    return creative["id"]

Step 5: Create the ad and log everything

def create_ad(row, adset_id: str, creative_id: str) -> str:
    ad = account.create_ad(params={
        "name": row.ad_name,
        "adset_id": adset_id,
        "creative": {"creative_id": creative_id},
        "status": "PAUSED",
    })
    return ad["id"]

def main(path: str):
    df = pd.read_excel(path)
    results = []
    for row in df.itertuples(index=False):
        try:
            campaign_id = get_campaign(row)
            adset_id = get_adset(row, campaign_id)
            image_hash = upload_image(row.image_path)
            creative_id = create_creative(row, image_hash)
            ad_id = create_ad(row, adset_id, creative_id)
            results.append({"ad_name": row.ad_name, "status": "created",
                            "campaign_id": campaign_id, "adset_id": adset_id,
                            "ad_id": ad_id, "error": ""})
        except FacebookRequestError as e:
            results.append({"ad_name": row.ad_name, "status": "failed",
                            "error": e.body().get("error", {}).get("error_user_msg")
                                     or e.api_error_message()})
    out = df.merge(pd.DataFrame(results), on="ad_name", how="left")
    out.to_excel(path.replace(".xlsx", "_results.xlsx"), index=False)

if __name__ == "__main__":
    main("campaigns.xlsx")

The results file is the part people skip and later wish they had. When a client asks "which ad is this?", you have the ID next to the row that made it.

Step 6: Review, then activate

Open Ads Manager and check a few things by eye: budgets, countries, the creative preview, the landing URL. When you're happy, activate from Ads Manager, or add a second script that sets status to ACTIVE for the IDs in the results file.

I don't let a script create ads as ACTIVE. A wrong decimal in a budget cell turns $20 a day into $2,000 a day.

Common errors and how to fix them

Meta errors come back as (#100) Invalid parameter with the useful part in error_user_msg. Always log that field, not just the message.

special_ad_categories missing. It's required on every campaign. Pass [] if none apply, or the right category (for example HOUSING, EMPLOYMENT, FINANCIAL_PRODUCTS_SERVICES) if one does.

Asked to set is_adset_budget_sharing_enabled. Newer API versions want you to say explicitly whether ad sets can share budget when the campaign has no budget of its own. Set it to True or False.

Asked about Advantage audience. Newer versions want an explicit choice in targeting.targeting_automation.advantage_audience: 1 to let Meta expand targeting, 0 to keep it as written.

Optimization goal not valid for the objective. Check Meta's outcome-driven objectives mapping and change optimization_goal or billing_event.

Budget too low. Each account currency has a minimum daily budget. Raise it in the sheet.

Page or image permission errors on the creative. The token's user needs access to the Page, and the Page must be connected to the business that owns the ad account.

Error 17 or 80004. You're hitting rate limits. Add a short sleep between rows, or retry with backoff. Big launches should run in batches.

Making it production-grade

The script above is the core. For an agency team, I add:

  • Validation before any API call. Check every row for missing fields, bad country codes and budget ranges, and refuse to start if one fails.
  • Idempotency. Look up campaigns by name before creating, so re-running after a failure doesn't make duplicates.
  • A dry-run flag that prints what it would create.
  • A Google Sheet instead of an Excel file, so the media buyer edits it and the script reads it on a schedule.

How Scalvo can help

If your team builds the same campaign structure over and over, this is a good first automation. Our AI automation sprint turns it into a tool your media buyers run without touching code. If you're paying for a bulk-launch SaaS and only use one feature, look at custom tool replicas, or book a call.

Frequently asked questions

Can I create Facebook ads from a spreadsheet without Ads Manager's bulk import?

Yes. A short Python script with the facebook-business SDK can read an Excel or CSV file and call the campaigns, adsets, adimages, adcreatives and ads endpoints for each row.

What permissions do I need to create Meta ads via the API?

An access token with ads_management on the ad account, plus access to the Facebook Page the ads run from. A non-expiring system-user token works well for scripts.

Why does Meta reject my ad set with an invalid parameter error?

Usually the objective, optimization goal and billing event don't match, the budget is below the minimum, or targeting is missing a required field. Read error_user_msg in the response; it says which field.

Should bulk-created ads go live immediately?

No. Create them with status PAUSED, check them in Ads Manager, then activate. One typo in a budget column can spend real money.

Tarek Walid
Written by

Tarek Walid

Founder of Scalvo. Ex-Python developer who has spent years building on the Meta, Google Ads and TikTok marketing APIs. Top Rated on Upwork with 100% Job Success.

More about Tarek

Bring one workflow that eats your week

30 minutes with Tarek. You leave with a clear answer on what to automate first, even if we never work together.

“Scalvo replaced a roadmap item that would've eaten a full quarter. We connected Meta, Google, and TikTok in a single afternoon and never looked back.”

Mohit Chopra, CTO & Product Manager, Cliquify