Meta Lead Ads to a CSV in the client's inbox, every morning

Christian Drexler

I wrote a version of this post in 2024. The API has moved on, the shape has not, and it is still the single automation clients thank me for most often, because it replaces "log in to a dashboard" with "open the mail".

Meta Lead Ads keep the form answers inside the platform. A client who does not live in Ads Manager finds out about new leads late or not at all, and a lead that waits a day is half a lead. The fix is a script that runs every morning, fetches everything from the last 24 hours, and mails it as a CSV with the columns in a fixed order.

The three pieces

A long-lived page access token with the leads_retrieval permission, stored in an environment variable. The form id, which you read once from the Ads Manager URL. And a mail account that can send from your domain.

The call is GET /{form_id}/leads with a filtering parameter on time_created greater than yesterday's timestamp, paged with the cursor the response gives back. Each lead comes with a field_data list of name and values pairs, in whatever order the form has them. The script flattens that into one row per lead and keeps the column order stable across days, because a client who sorts in Excel will notice if the columns shuffle.

def flatten(lead, columns):
    row = {"lead_id": lead["id"], "created": lead["created_time"]}
    for f in lead.get("field_data", []):
        row[f["name"]] = ", ".join(f.get("values", []))
    return [row.get(c, "") for c in columns]

columns is a list in a small config next to the script: lead id and timestamp first, then the form fields in the order the client reads them. A field that is not in the config still gets written, at the end, so a new form question never silently disappears.

Small decisions that made it stick

Send the mail even when there are zero leads. A subject line saying "0 leads, Tuesday" tells the client the system ran. Silence tells them nothing and they will ask on day three whether it is broken.

Keep a file of lead ids already sent. The 24-hour window and the morning schedule overlap by a few minutes, and without the file the same lead shows up two days in a row. With it, the script sends each one exactly once, and if the job fails one morning the next run picks up what was missed instead of losing a day.

Put the date in the filename and the lead count in the subject. leads-2026-08-19.csv, "7 new leads, Tue 19 Aug". That is the entire mail body; the CSV is the content.

Use UTF-8 with a BOM at the top of the file. Excel on Windows will otherwise mangle every umlaut in a name, and the client will think the form is broken.

Schedule it for 06:30 in the client's time zone, not the server's. Meta's created_time is UTC; convert it before writing, and say the zone in the column header.

What it is not

It is not a CRM integration, it does not de-duplicate against an existing customer list, and it does not reply to the lead. All of those are reasonable next steps and all of them are a different project. This one is a cron line, sixty lines of Python and a mail, and it has been running for one client for over a year without a change.