<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Emcy</title>
    <description>The latest articles on DEV Community by Emcy (@itsemcy).</description>
    <link>https://dev.to/itsemcy</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3933191%2F3adebeea-bec6-4347-96bb-0121b2a4a069.jpg</url>
      <title>DEV Community: Emcy</title>
      <link>https://dev.to/itsemcy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/itsemcy"/>
    <language>en</language>
    <item>
      <title>I replaced a SaaS tool with a Python script because my ad spend was embarrassing</title>
      <dc:creator>Emcy</dc:creator>
      <pubDate>Fri, 15 May 2026 15:38:00 +0000</pubDate>
      <link>https://dev.to/itsemcy/i-replaced-a-saas-tool-with-a-python-script-because-my-ad-spend-was-embarrassing-3604</link>
      <guid>https://dev.to/itsemcy/i-replaced-a-saas-tool-with-a-python-script-because-my-ad-spend-was-embarrassing-3604</guid>
      <description>&lt;p&gt;Last year I opened my Meta Ads dashboard, did the math, and just stared at the screen for a second.&lt;/p&gt;

&lt;p&gt;I had spent more on running ads that week than the store had made.&lt;/p&gt;

&lt;p&gt;Not close. Actually more.&lt;/p&gt;

&lt;p&gt;So yeah. That's where this series starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  A bit of context
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So I built it myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I made
&lt;/h2&gt;

&lt;p&gt;I wrote &lt;code&gt;meta_ad_uploader.py&lt;/code&gt; with Claude over two evenings. It talks to the Meta Graph API and does exactly what I needed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Upload the image to the ad account's image library&lt;/li&gt;
&lt;li&gt;Find or create the ad set&lt;/li&gt;
&lt;li&gt;Build the creative with copy, headline, CTA, and UTM params&lt;/li&gt;
&lt;li&gt;Create the ad as &lt;strong&gt;paused&lt;/strong&gt;. 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.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The thing I'm weirdly proud of:
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;AD_COPY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chaos&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Some developers have a staging environment.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Some have a rollback plan.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Some have a very good story about why production was down for 47 minutes on a Thursday.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;This shirt is for the last group.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;conference&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;There&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s a specific moment at every tech conference.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Someone walks in and another developer across the room sees their shirt and immediately understands.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Not everyone gets it. That&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s the point.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="c1"&gt;# ... more angles
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Premium developer apparel. Built for the ones who stay calm when the monitors go red.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;match_copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;copy&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;AD_COPY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;copy&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;AD_COPY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Name your file &lt;code&gt;chaos_v3.jpg&lt;/code&gt; and it picks up the "testing in prod" copy. Name it &lt;code&gt;conference_final.png&lt;/code&gt; and it gets the conference angle. Rename your files, change your copy. No database, no UI, no config file.&lt;/p&gt;

&lt;p&gt;There's also a &lt;code&gt;--list-copy&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  One thing the Meta docs don't make obvious
&lt;/h2&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;upload_image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;mime_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mimetypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;guess_type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image_path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/act_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;AD_ACCOUNT&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/adimages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;access_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;META_TOKEN&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;filename&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image_path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mime_type&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;images&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;images&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
    &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;iter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;images&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Image upload failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# this is what you need, not an "id"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Build the dry-run first, not last
&lt;/h2&gt;

&lt;p&gt;I added &lt;code&gt;--dry-run&lt;/code&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# see which copy each image gets, no API calls&lt;/span&gt;
python3 meta_ad_uploader.py &lt;span class="nt"&gt;--list-copy&lt;/span&gt; chaos_v1.jpg conference_v2.jpg

&lt;span class="c"&gt;# print the full payload without posting anything&lt;/span&gt;
python3 meta_ad_uploader.py &lt;span class="nt"&gt;--dry-run&lt;/span&gt; chaos_v1.jpg

&lt;span class="c"&gt;# actually run it&lt;/span&gt;
python3 meta_ad_uploader.py &lt;span class="nt"&gt;--folder&lt;/span&gt; /path/to/creatives/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Did it work
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The SaaS subscription is gone.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;More coming.&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>ai</category>
      <category>startup</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
