DEV Community

SDVSignal
SDVSignal

Posted on

You changed the file. Your buyers are still downloading the old one.

This one cost me an evening, so here it is in case it saves you the same evening.

I sell a few small zip files. Templates, config packs, that kind of thing. They are paid downloads, so they cannot sit in a public bucket where anyone with the URL can grab them. The setup I landed on is a Cloudflare Pages Function that checks a key in the query string and streams the archive back:

/d/my-pack?k=<key>   ->  200 + application/zip
/d/my-pack           ->  403
/d/my-pack?k=wrong   ->  403
Enter fullscreen mode Exit fullscreen mode

The file itself is not an asset in the repo. It is base64 in the function, generated by a build step. That part is deliberate. If the zip were a static file in the Pages project, Pages would serve it at its own path and the key check would be theatre, because anyone could skip the function and hit the file directly.

That design is fine. The trap is what it does to your update loop.

The trap

I changed a template inside one of the packs. Re-zipped. Committed. Moved on.

Buyers kept getting the old pack.

Nothing failed. No error, no warning, no red anywhere. The zip on my disk was correct. The zip in git was correct. Every check I had was looking at the zip on disk, and the zip on disk was right.

The bytes buyers actually receive live in the function, and the function is only as fresh as the last time I ran the build step and deployed. Re-zipping updates one of three copies:

  1. the source folder
  2. the built zip in out/
  3. the base64 blob compiled into the function
  4. and then, separately, whatever is actually deployed

I had been treating that as one step. It is four, and three of them can silently disagree.

The fix is not a better memory

My first instinct was a note in the runbook. "Remember to re-run the build step." That is not a fix, that is a wish. The runbook already said it. I had read it. I still shipped the stale pack, because reading a step and doing it are different events and only one of them leaves a trace.

What actually fixed it was making the smoke test compare the deployed bytes, not the built ones. The check downloads the live file over https, exactly the way a buyer does, and diffs it against the build output:

# what a buyer gets, right now, from production
curl -s -o /tmp/live.zip "https://your.site/d/my-pack?k=$KEY"

# what the build says they should get
BUILT=~/packs/out/my-pack.zip

# compare CONTENTS, not the archive bytes
cmp <(unzip -p /tmp/live.zip | shasum) <(unzip -p "$BUILT" | shasum) \
  && echo "deployed == built" \
  || echo "STALE: production is serving an older pack"
Enter fullscreen mode Exit fullscreen mode

Note what that last bit is doing. Do not shasum the two .zip files against each other. Zip archives store a modification time per entry, so rebuilding the exact same content a second later gives you a different archive hash:

$ shasum a1.zip a2.zip          # same files inside, rebuilt 1s apart
3db6cd34...  a1.zip
dbf6dcd8...  a2.zip

$ unzip -p a1.zip | shasum      # the contents
f572d396...
$ unzip -p a2.zip | shasum
f572d396...
Enter fullscreen mode Exit fullscreen mode

So the moment your build step runs for any reason, an archive-bytes check goes red while nothing is actually wrong. You will learn to ignore it, and then it is worse than having no check at all. Unpack and compare what came out.

Same reason unzip -l counts are a bad assertion: it lists directory entries too, so "7 files" in your build script and "8 lines" from unzip -l are both correct and will still make you doubt a green run at 1am.

While you are in there, check the other end

The download route was only half of it. The other half was the redirect.

Stripe Payment Links have an "after payment" setting. If you never set it, the default is a hosted confirmation page, which is a bare Stripe receipt with no link to anything. The buyer paid. Nothing arrives. Nothing is broken enough to alert you, and they are not going to email you about it, they are going to file a dispute or just eat it and never come back.

Worth reading the config back from the API rather than trusting your memory of a dashboard click:

curl -s https://api.stripe.com/v1/payment_links?limit=100 -u "$STRIPE_KEY:" \
| python3 -c "
import json,sys
for p in json.load(sys.stdin)['data']:
    ac = p.get('after_completion') or {}
    url = (ac.get('redirect') or {}).get('url','')
    print(p.get('active'), ac.get('type'), url)
"
Enter fullscreen mode Exit fullscreen mode

Then take every URL that prints and actually curl it. A redirect Stripe is holding is worth exactly what that URL returns today. The key embedded in it can be stale, the route can have moved, and Stripe will keep cheerfully sending people there forever.

When I ran that across my own account I found 15 of 16 links fine and one leftover from a product I had retired months earlier, still active, still chargeable, still pointed at a dead end. Nothing links to it. An old bookmark would have been enough.

The thing I would tell past me

A green build is not proof that a feature works. I knew that. What I did not have was a check positioned on the buyer's side of the wire.

Every verification I owned was upstream of the deploy. Source correct, zip correct, commit correct, all green, all true, all useless, because none of them could see what production was serving. The check has to start from the public URL and work backwards, or it is checking your intentions rather than your product.

Two questions worth asking about any paid download you run:

  • If I change the file and forget a step, does anything tell me, or do buyers just quietly get the old one?
  • If I never set the after-payment behaviour on that checkout link, where does the buyer land?

If the honest answer to either is "I would find out when someone complains", that is the thing to go fix tonight.


I write these up as I hit them while building Kit, a set of small Claude Code and MCP setup packs. The checks above are the ones running against my own delivery rail right now. Steal them, they are not complicated.

Top comments (0)