Last year I opened my Meta Ads dashboard, did the math, and just stared at the screen for a second.
I had spent more on running ads that week than the store had made.
Not close. Actually more.
So yeah. That's where this series starts.
A bit of context
I run Code Culture on the side. It's a developer apparel brand, shirts with terminal jokes on them, hoodies for the kind of person who has opinions about tab width. I work a full-time job in data during the day and do this in whatever's left over.
Running it solo means every subscription you're paying for is money that could've been product, or ads, or just staying in your pocket. And for a while I was paying for a SaaS tool that bulk uploads ad creatives to Meta. It was fine. It did the job. But it was also a monthly cost on top of an ad budget that clearly wasn't working yet.
At some point I just stopped and thought about what the tool was actually doing. And the answer was: it uploads your images to Meta, creates a creative, wires it to an ad set, saves it as a draft. That's the whole thing.
So I built it myself.
What I made
I wrote meta_ad_uploader.py with Claude over two evenings. It talks to the Meta Graph API and does exactly what I needed:
- Upload the image to the ad account's image library
- Find or create the ad set
- Build the creative with copy, headline, CTA, and UTM params
- Create the ad as paused. Nothing goes live on its own. That last bit was the non-negotiable part. I didn't want a script that could accidentally start spending money at 2am because I forgot to double-check something. Every ad it creates is a draft. I go in manually, look at it, then activate.
The thing I'm weirdly proud of:
I was testing a bunch of different creative angles at the same time. Lifestyle photos, product shots, stuff leaning into specific dev jokes. Each one needed different primary copy to match the vibe of the image.
I didn't want to build a whole config system for this. I just wanted something simple that worked. So I went with filename matching.
AD_COPY = {
"chaos": (
"Some developers have a staging environment.\n\n"
"Some have a rollback plan.\n\n"
"Some have a very good story about why production was down for 47 minutes on a Thursday.\n\n"
"This shirt is for the last group."
),
"conference": (
"There's a specific moment at every tech conference.\n\n"
"Someone walks in and another developer across the room sees their shirt and immediately understands.\n\n"
"Not everyone gets it. That's the point."
),
# ... more angles
"default": "Premium developer apparel. Built for the ones who stay calm when the monitors go red.",
}
def match_copy(filename: str) -> str:
name = Path(filename).stem.lower()
for keyword, copy in AD_COPY.items():
if keyword != "default" and keyword in name:
return copy
return AD_COPY["default"]
Name your file chaos_v3.jpg and it picks up the "testing in prod" copy. Name it conference_final.png and it gets the conference angle. Rename your files, change your copy. No database, no UI, no config file.
There's also a --list-copy flag that prints which copy each file would get before you run anything. Genuinely useful when you've got 10 images and you want to sanity-check it first.
One thing the Meta docs don't make obvious
When you upload an image through the API, you get back a hash, not an ID. And that hash is what you need when you build the creative. I spent more time than I'd like to admit figuring that out because the docs kind of gloss over it.
def upload_image(image_path: Path) -> str:
mime_type, _ = mimetypes.guess_type(str(image_path))
with open(image_path, "rb") as f:
resp = requests.post(
f"{BASE_URL}/act_{AD_ACCOUNT}/adimages",
data={"access_token": META_TOKEN},
files={"filename": (image_path.name, f, mime_type)},
)
resp.raise_for_status()
data = resp.json()
images = data.get("images", {})
entry = next(iter(images.values()), None)
if not entry:
raise RuntimeError(f"Image upload failed: {data}")
return entry["hash"] # this is what you need, not an "id"
Build the dry-run first, not last
I added --dry-run before I got the rest of the script working, and it saved me from creating a bunch of garbage draft ads while I was still figuring out the API shape.
# see which copy each image gets, no API calls
python3 meta_ad_uploader.py --list-copy chaos_v1.jpg conference_v2.jpg
# print the full payload without posting anything
python3 meta_ad_uploader.py --dry-run chaos_v1.jpg
# actually run it
python3 meta_ad_uploader.py --folder /path/to/creatives/
If you're building anything that touches a paid API or creates real objects in someone else's system, just build the dry-run path first. You'll thank yourself.
Did it work
The script works fine. I drop images into a folder, run it, and a couple minutes later I've got paused draft ads sitting in Ads Manager ready to review.
The SaaS subscription is gone.
The part that actually surprised me wasn't the code. The Meta API is reasonably documented and Claude handled most of the boilerplate. What surprised me was how much headspace the SaaS tool had been using. You don't fully trust something you don't understand, so you end up double-checking things, second-guessing the output, wondering if it did what you think it did. Building it yourself removes that whole layer.
I still spent too much on ads that didn't convert. That's a targeting and creative problem and no script fixes it. But at least when something goes wrong now, I know exactly where to look.
This is the first post in a series I'm writing about building Code Culture with AI tools. The store is developer apparel. The operational stack is Python, Claude, and a lot of trial and error. The budget is not VC-backed.
More coming.
Top comments (0)