Back to Intel
Meta Ads Aug 5, 2026 • 12 min read

Build a Meta Ads ROAS Dashboard in Python

Working Python code to pull Meta Marketing API data, adjust ROAS for COD returns, and output a recommended action per campaign — the reporting most Indian D2C teams are missing.

below are used by the site. The meta and JSON-LD here are ignored at build time — the real canonical, description and Article schema come from src/data/gdocsPosts.ts and src/pages/blog/[slug].astro. -->

Every performance team has the same 9 AM ritual. Open Ads Manager, squint at yesterday, open Shopify in another tab, squint at that, and try to decide whether the thing that looks bad is actually bad.

The problem was never a shortage of data. Meta gives you more columns than anyone can read. The problem is that the numbers you need to compare live in different places, arrive at different times, and disagree with each other — so the answer to “how did we do yesterday?” takes twenty minutes and still feels like a guess.

A dashboard fixes that, but only if it answers a specific question. Not “show me the data” — that is what Ads Manager already does. The question is:

What changed since yesterday, and what should I do about it before lunch?

This guide builds exactly that in Python: pull from the Meta Marketing API, reconcile against your real Shopify revenue, adjust for COD returns, and output a table with a recommended action per campaign. Around 150 lines in total. You need to be comfortable running a Python script — you do not need to be a developer.

Decide what the dashboard is for before you write any code

Most internal dashboards die within a month. They die because they were built to display everything, so reading them became its own chore and people went back to Ads Manager.

The ones that survive answer a small number of questions and end with a recommendation. Before you start, write down the four or five decisions you make every morning. For most D2C teams they are:

  • Which campaigns should I cut or reduce today?
  • Which campaigns can take more budget?
  • Is anything broken — spend running with no purchases recorded?
  • Which creatives are fatiguing?
  • Are we actually profitable this week, after returns?

Every metric in your dashboard should serve one of those. Anything that does not is a column you will scroll past forever.

Step 1: Pull the data from the Meta Marketing API

You can start with CSV exports from Ads Manager, and if you want the design settled before you touch authentication, that is a reasonable first week. But CSV exports are a manual step, and a dashboard with a manual step is a dashboard that stops being updated by the third week. Go to the API sooner than feels necessary.

You need a system user access token with ads_read from your Business Manager, and your ad account ID. Keep both in environment variables — never in the script, and never in a repo.

import os
import requests
import pandas as pd

# Pin the API version explicitly. Check Meta's Graph API changelog for the
# current one — versions are deprecated on a rolling basis and an unpinned
# call will break silently one morning.
API_VERSION = "v21.0"
AD_ACCOUNT_ID = os.environ["META_AD_ACCOUNT_ID"]   # e.g. "act_1234567890"
ACCESS_TOKEN = os.environ["META_ACCESS_TOKEN"]


def fetch_insights(date_preset="last_14d", level="campaign"):
    """Pull daily insights from the Meta Marketing API into a DataFrame."""
    url = f"https://graph.facebook.com/{API_VERSION}/{AD_ACCOUNT_ID}/insights"

    params = {
        "level": level,
        "date_preset": date_preset,
        "time_increment": 1,          # one row per campaign per day
        "fields": ",".join([
            "date_start",
            "campaign_name",
            "campaign_id",
            "spend",
            "impressions",
            "clicks",
            "frequency",
            "actions",
            "action_values",
        ]),
        "limit": 500,
        "access_token": ACCESS_TOKEN,
    }

    rows = []
    while url:
        response = requests.get(url, params=params, timeout=60)
        response.raise_for_status()
        payload = response.json()
        rows.extend(payload.get("data", []))

        # Meta paginates. Follow `next` until it disappears, or you will
        # silently analyse only the first page.
        url = payload.get("paging", {}).get("next")
        params = None

    return pd.DataFrame(rows)
The pagination trap. Forgetting to follow paging.next is the most common bug in home-built Meta reporting. Nothing errors — you just get the first 500 rows and quietly under-report spend for weeks.

Purchases and revenue are nested, not columns

This catches everyone the first time. Meta does not return purchases and purchase_value as flat fields. They arrive inside the actions and action_values lists, and you have to dig them out by action type.

def extract_action(action_list, action_type="purchase"):
    """Pull one action type out of Meta's nested actions/action_values list."""
    if not isinstance(action_list, list):
        return 0.0
    for item in action_list:
        if item.get("action_type") == action_type:
            return float(item.get("value", 0) or 0)
    return 0.0


def flatten(df):
    df = df.copy()
    df["purchases"] = df["actions"].apply(extract_action)
    df["revenue"] = df["action_values"].apply(extract_action)
    return df.drop(columns=["actions", "action_values"])

A note worth knowing before it confuses you: if your store runs a separate checkout, the action type you want may not be purchase. Print the distinct action types once and look at what your account actually sends before hardcoding it.

Step 2: Clean the data so the dashboard can be trusted

Meta returns numbers as strings, missing values as absent keys, and dates as text. If you skip this step, your first divide-by-zero will produce an inf ROAS, somebody will screenshot it, and nobody will trust the dashboard again.

import numpy as np

NUMERIC_COLS = ["spend", "impressions", "clicks", "frequency", "purchases", "revenue"]


def clean(df):
    df = df.copy()
    df["date"] = pd.to_datetime(df["date_start"])

    for col in NUMERIC_COLS:
        df[col] = pd.to_numeric(df.get(col), errors="coerce").fillna(0.0)

    # Campaign names get renamed mid-flight. Group on campaign_id, and keep the
    # most recent name for display, or one campaign becomes two rows.
    latest_names = (
        df.sort_values("date")
          .groupby("campaign_id")["campaign_name"]
          .last()
    )
    df["campaign_name"] = df["campaign_id"].map(latest_names)

    return df


def safe_divide(numerator, denominator):
    """Zero spend means undefined, not infinity."""
    return np.where(denominator > 0, numerator / denominator.replace(0, np.nan), np.nan)

Two rules that prevent most reporting arguments:

  • Spend above zero with zero revenue is ROAS 0 — a real, bad result.
  • Zero spend is ROAS blank — not zero. A paused campaign is not a failing one.

Step 3: Calculate the metrics, and the comparison

A single ROAS number tells you almost nothing. ROAS next to last week’s ROAS tells you what is happening. The comparison is the entire value of the dashboard — without it you are just reading Ads Manager in a different font.

def summarise(df, days):
    """Aggregate the last N days per campaign."""
    cutoff = df["date"].max() - pd.Timedelta(days=days - 1)
    window = df[df["date"] >= cutoff]

    g = window.groupby(["campaign_id", "campaign_name"], as_index=False).agg(
        spend=("spend", "sum"),
        revenue=("revenue", "sum"),
        purchases=("purchases", "sum"),
        clicks=("clicks", "sum"),
        impressions=("impressions", "sum"),
    )

    g["roas"] = safe_divide(g["revenue"], g["spend"])
    g["cpa"] = safe_divide(g["spend"], g["purchases"])
    g["ctr"] = safe_divide(g["clicks"], g["impressions"])
    return g


def with_comparison(df, days=7):
    """Last N days vs the N days before that."""
    current = summarise(df, days)

    cutoff = df["date"].max() - pd.Timedelta(days=days - 1)
    prior_df = df[df["date"] < cutoff]
    previous = summarise(prior_df, days)[["campaign_id", "roas", "spend"]]
    previous = previous.rename(columns={"roas": "roas_prev", "spend": "spend_prev"})

    merged = current.merge(previous, on="campaign_id", how="left")
    merged["roas_change_pct"] = safe_divide(
        merged["roas"] - merged["roas_prev"], merged["roas_prev"]
    ) * 100
    return merged
Compare 7 days against 7 days, not today against yesterday. Daily ROAS in a COD business is mostly noise — order confirmation lags the click, and a single large order swings a small campaign. Anyone making daily decisions on daily ROAS is reacting to randomness.

Step 4: The correction almost nobody makes — net ROAS after returns

Here is where a dashboard built for an Indian D2C store has to diverge from every tutorial written for a US audience.

If a meaningful share of your orders are COD, Meta’s reported revenue counts orders that will come back to you unsold. You pay forward shipping, return shipping, and handling on every one. A campaign showing 3.2 ROAS with a 30% RTO rate is not a 3.2 campaign, and if you are allocating budget on the raw number you are systematically overfunding whatever attracts your least committed buyers.

The fix is not complicated. It just has to be done deliberately.

# Measure these from your own last 90 days of order data — do not guess,
# and re-measure quarterly. RTO varies by region, price point and campaign.
RTO_RATE = 0.28          # share of COD orders that come back
COD_SHARE = 0.65         # share of orders that are COD
RETURN_COST_PER_ORDER = 120.0   # forward + reverse shipping, in rupees


def add_net_metrics(df):
    df = df.copy()

    # Revenue that never actually lands.
    lost_share = COD_SHARE * RTO_RATE
    df["net_revenue"] = df["revenue"] * (1 - lost_share)

    # Returned orders still cost money to ship twice.
    returned_orders = df["purchases"] * lost_share
    df["return_cost"] = returned_orders * RETURN_COST_PER_ORDER

    df["net_roas"] = safe_divide(
        df["net_revenue"] - df["return_cost"], df["spend"]
    )
    return df

Put net_roas next to roas in the output and leave both visible. The gap between the two columns is usually the most uncomfortable and most useful number on the page. Teams that add this column almost always discover that one or two campaigns they had been protecting were never profitable.

Reconcile against Shopify weekly

Meta’s revenue figure is attributed, not banked. It will not match Shopify, and it is not supposed to — but the size of the gap tells you whether your tracking is healthy. Pull your Shopify revenue for the same window and put the ratio on the dashboard.

A stable gap is fine. A gap that suddenly widens is a tracking failure, not a performance failure, and it is worth checking your Pixel and CAPI setup before you touch a single budget in response.

Step 5: Turn numbers into a recommended action

This is the step that separates a dashboard people use from a dashboard people admire once. Encode the decision rules you already apply informally, and let the script write the recommendation in a column.

TARGET_ROAS = 2.5
MIN_SPEND = 2000.0      # rupees; below this, the sample is too small to act on


def recommend(row):
    # Tracking failures first — they look like performance failures.
    if row["spend"] > MIN_SPEND and row["purchases"] == 0:
        return "CHECK TRACKING — spend with zero recorded purchases"

    if row["spend"] < MIN_SPEND:
        return "Hold — not enough spend to judge"

    if row["net_roas"] < TARGET_ROAS * 0.6:
        return "Cut — well below target after returns"

    if row["net_roas"] < TARGET_ROAS:
        return "Reduce 20% — below target"

    if row["net_roas"] >= TARGET_ROAS and row["roas_change_pct"] > -10:
        return "Scale 20% — above target and stable"

    return "Hold"


summary["action"] = summary.apply(recommend, axis=1)

Note the order of those checks. Tracking failures are tested first because a campaign with spend and no recorded purchases looks identical to a catastrophic campaign, and the wrong response — pausing it — destroys a campaign that may have been working fine while the pixel was broken.

Keep the thresholds in one place at the top of the file. You will tune them, and hunting for a magic number buried in a function three weeks later is how these scripts get abandoned.

Step 6: Output somewhere your team will actually look

The best dashboard is the one that appears where people already are. For most teams that is Google Sheets — no login to remember, works on a phone, and anyone can add a column.

import gspread
from datetime import datetime

COLUMNS = [
    "campaign_name", "spend", "revenue", "net_revenue",
    "roas", "net_roas", "roas_change_pct", "cpa", "action",
]


def push_to_sheet(df, sheet_name="Meta ROAS Dashboard"):
    gc = gspread.service_account(filename=os.environ["GSPREAD_CREDENTIALS"])
    worksheet = gc.open(sheet_name).sheet1

    out = df[COLUMNS].round(2).sort_values("spend", ascending=False)

    worksheet.clear()
    worksheet.update(
        [["Last updated", datetime.now().strftime("%d %b %Y, %H:%M")]]
        + [COLUMNS]
        + out.fillna("").values.tolist()
    )

The Last updated timestamp is not decoration. Without it, nobody can tell a broken script from a quiet day, and a silently stale dashboard is worse than no dashboard — people keep making decisions on it.

Prefer a real interface? streamlit run dashboard.py gives you a browsable app from roughly the same code with filters and charts. Just be honest about whether your team will open a second tool. Most won’t.

Step 7: Schedule it, and make failures loud

Run it every morning before anyone looks — a cron job on any always-on machine, a GitHub Action on a schedule, or a small cloud function. The mechanism matters much less than the discipline.

What does matter is that a failed run announces itself. Wrap the whole run in a try/except and push the error somewhere a human sees it, even if that is just a WhatsApp message to yourself.

if __name__ == "__main__":
    try:
        raw = fetch_insights(date_preset="last_28d")
        df = clean(flatten(raw))
        summary = add_net_metrics(with_comparison(df, days=7))
        summary["action"] = summary.apply(recommend, axis=1)
        push_to_sheet(summary)
        print(f"OK — {len(summary)} campaigns updated")
    except Exception as exc:
        # Silence is the enemy. A dashboard that stops updating without
        # telling anyone will be trusted for weeks after it broke.
        notify_failure(f"ROAS dashboard failed: {exc}")
        raise
Access tokens expire. System user tokens can be issued long-lived, but they still get revoked when someone leaves Business Manager or permissions change. Expect this to break at least once and make sure it breaks loudly.

What to leave out

The temptation once the script works is to keep adding. Resist it. A few things that are usually not worth the build:

  • Hourly refresh. No good decision is made on four hours of Meta data. Daily is right for almost everyone.
  • Ad-level detail on page one. Campaign level for the morning read; drill down only when something looks wrong.
  • A custom attribution model. Enormous effort, endless disagreement. Use MER — total revenue divided by total ad spend — as your honest top-line number instead.
  • More than four charts. ROAS trend, spend vs revenue, ROAS by campaign, ROAS by placement. That is the whole useful set.

Common questions

Do I need to know Python to build this?

You need to be able to install packages, set environment variables and run a script. You do not need to write Python from scratch — the code above is most of a working dashboard. Budget a day for the first version if you have done a little scripting before.

Why doesn't my dashboard's revenue match Shopify?

It shouldn’t match exactly. Meta reports attributed revenue within its attribution window; Shopify reports every order from every source. A stable gap is normal. A gap that widens suddenly is a tracking problem — check for missing COD orders and duplicate pixels first.

Can I do this in Google Sheets or Looker Studio instead?

For a straight view of Meta data, yes, and Looker Studio’s connector is quicker to set up. Python earns its place the moment you need logic those tools handle badly: joining Shopify and Meta data, adjusting for RTO, and writing a recommended action per campaign. If you only need charts, use the connector.

How often should the dashboard refresh?

Once a day, early morning, before the team looks. More frequent refreshes encourage reacting to noise, which is the specific failure this dashboard exists to prevent.

What ROAS target should I set?

Your break-even ROAS plus the margin you want, calculated after COD returns — not a number from a case study. Work backwards from contribution margin per order. Every campaign judged against a borrowed benchmark is being judged against someone else’s cost structure.

The point of all this

A ROAS dashboard is not a reporting exercise. It is a way of making the same five decisions every morning in ten minutes instead of an hour, on numbers that account for returns rather than flattering you.

Build the smallest version that produces an action column, run it for two weeks, and only add what you find yourself missing. Most of what you think you need on day one, you will never look at.

Related reading: fixing your Pixel and CAPI setup so the numbers feeding this are real, and how to scale Meta ads once they are.

Want this built for your account? We set up reporting like this for the brands we run ads for. If you would rather not maintain a script, book a free 30-minute call and we will show you what your numbers look like after returns.

Apply this to your business

Enjoyed the article? Let's discuss how we can implement these specific insights to scale your brand's performance.