How to build a Gumroad alternative

How to build a Gumroad alternative with a product page, hosted checkout, and a signed download. Follow the prompts and keep the file private.

  • guide
  • vibe-coding
  • gumroad

How to build a Gumroad alternative is a product page, a hosted checkout, and a signed download. Gumroad is that product. The catalog calls it “Sell your files and keep Gumroad’s ten percent cut for yourself.”

You pay $10–$50 a month. The price is for the account. The verdict is “Keep the ten percent”. The first estimate is 12 hours: product pages, checkout, signed delivery links.

The audience is one creator selling a handful of digital products directly. If a marketplace already brings the buyers, keep paying. A physical catalog with a cart is Shopify, $39–$399 a month, about 5 days, and the Shopify post is that split. A course with lesson progress is Teachable, $39–$159 a month, about 4 days. A page with no payment is the Carrd guide, at $2–$4 a month.

How to build a Gumroad alternative as one paid file

The Gumroad page splits the work into three rows.

The first row is the one you want. A public product page. Hosted checkout. A signed link in email after the payment is real. That is the 12-hour v1. The catalog names the project Sellshelf.

The second row is 4 days, 32 hours. Discount codes, file versions, pay-what-you-want, and receipts. The third row is 3 months and up, 480 hours. Marketplace discovery, memberships, affiliates, and VAT handling. The non-goal also names subscriptions and physical goods.

If you tell an agent to “clone Gumroad”, it aims at the third row. Paste the prompts below, and keep each one on one file, one price, and one verified sale.

Flavio sells “Astro notes”, a PDF, for $29. The row stores 2900, because money is integer cents. Ada pays. She gets one email. The email holds a download page, and that page mints a short-lived file URL. A second copy of the same payment webhook does not send a second email.

The parts and where a payment lands

Every product prompt ships in two flavors: Node.js plus Astro, and TanStack Start. The capability line for Gumroad is Astro, D1, and R2. Checkout is Stripe Checkout, which is the integration on the card. This site is an Astro app on Cloudflare, so that is the path I default to. Use TanStack Start if React is your weekday language. The stack post is which tab you copy. The prompt post is how you pin the first estimate.

The public page is a normal request. Buy creates a Checkout session and sends Ada to Stripe. Card data stays there. Sellshelf never sees a card number.

The grant happens on the webhook, which is also a request. Verify the signature. Insert one sale for that payment id. Email the download page. D1 holds the product, the sale, and the token. R2 holds the PDF in a private bucket. The download request checks the token, then signs a URL for a few minutes.

There is no cron. A failed email stays on the sale so Flavio can resend it. TanStack Start keeps the same screens, with server functions and a SQL database with migrations. The hosting post is the R2 binding when you leave localhost.

ADA'S BROWSER Product page, then download No account required STRIPE CHECKOUT Card stays on Stripe WEBHOOK ON THE REQUEST Verify, then one sale Email the download page D1 ROWS Product, sale, token R2 PRIVATE astro-notes.pdf DOWNLOAD PAGE Signs the file for minutes OUTSIDE V1 Discounts, VAT, memberships
The success page is a receipt. The webhook is the only thing that grants the file.

The data one sale carries

The card lists Product, ProductFile, Sale, DownloadLink, and LicenseKey. This v1 needs the first four. A license key is a second secret. Leave it until a buyer has already downloaded a PDF.

Flavio is the only seller. He signs in with a session cookie. The password hash comes from the environment. Ada has no account.

A product carries a title, a slug, a description, a price in minor units, a published flag, and one private object key. “Astro notes” uses slug astro-notes and price 2900. Drafts have no public page. The file name shown to Ada is astro-notes.pdf. The key stays off the page.

A sale carries the product, Ada’s email, the amount, a status of paid or refunded, and the provider payment id. That payment id is unique. The download token is unguessable, points at the sale, expires seven days after payment, and stores how many downloads remain. This v1 allows five. The email status is sent or failed.

Here is the sale after the webhook commits.

const sale = {
  id: 'sale-ada-astro-notes',
  productId: 'prod-astro-notes',
  buyerEmail: '[email protected]',
  amountMinor: 2900,
  status: 'paid',
}

The provider payment id sits beside that row. A repeat webhook finds it and stops.

FLAVIO The only seller ASTRO NOTES Published, $29, 2900 FILE astro-notes.pdf SALE Ada, paid TOKEN 7 days, 5 downloads LICENSE KEY Later
One product, one file, one sale. The payment id on the sale is unique.

The screens you ship

Four surfaces. Keep them boxes and labels. If the agent starts a storefront theme editor, send it back to these boxes.

The product admin is /products. Flavio sees drafts and published rows. The editor asks for a title, a price in dollars that you store as cents, a short description, and one file. Publish is disabled until the file upload has finished. Unpublish takes the public page down and leaves old sales downloadable.

PRODUCT Flavio Astro notes Price · 29 USD A short PDF of notes astro-notes.pdf · uploaded Publish
Publish waits until the PDF is in the private bucket.

The public page is /p/astro-notes. Ada sees the title, the description, the price, and Buy. The HTML stops there. After Stripe, the return page says to check her email. The file stays on the download token.

ASTRO NOTES Public A short PDF of notes from the workshop $29 Buy Return page: check your email
Buy leaves the site for Checkout. The file stays put until the webhook.

The download page is /d/k7m2n9p4q1. Ada sees the product name, the file name, the expiry date, and how many downloads remain. Download mints a signed URL. An expired or spent token shows one recovery form: her email. A match sends a fresh token to that address. A miss shows the same sentence.

The sales list is /sales, and only Flavio can open it. Each row shows the time, the product, the email, the amount, paid or refunded, and whether the email went out. Resend shows up when email status is failed, or when Ada asks for another copy. Refund calls Stripe and waits for the provider before the row flips.

SALES Flavio 2 Nov · Astro notes [email protected] $29 · paid · email sent Resend Refund
One payment, one row. Refund waits for Stripe before the file closes.

The path from publish to a refund

Ada can pay only after the product is published. The file moves only after the webhook. Refund closes the next download.

1 · PUBLISH File uploaded, price 2900 2 · ADA PAYS Hosted Checkout, then return 3 · WEBHOOK ONCE One sale, one email DUPLICATE No second mail 4 · DOWNLOAD Signed URL, attempts drop EXPIRED TOKEN Fresh link to Ada REFUND After Stripe says yes Next signed URL is refused SUCCESS URL ALONE Shows the check-email line Creates no sale
Publish, pay, webhook, download. The return URL never grants the PDF.
  1. Flavio creates Astro notes, uploads the PDF, sets the price to 29 dollars, and publishes. /p/astro-notes shows $29 and Buy.
  2. Ada clicks Buy. Stripe hosts the card form. She pays. The return page tells her to check email and offers no file.
  3. Stripe posts the webhook. Sellshelf verifies it, writes one paid sale for [email protected], and emails /d/k7m2n9p4q1. The sales list shows one row, email sent.
  4. Stripe delivers the same event again. The list still has one row. Ada still has one email.
  5. Ada opens the token and downloads. The address bar stays on /d/k7m2n9p4q1. Remaining downloads drop from 5 to 4.
  6. Flavio refunds. The button waits until Stripe confirms. The row says refunded. Ada’s next click does not get a signed URL.

Prompt 1. A seller and one draft product

Build Sellshelf, a shop for
one creator selling files.

Use Node.js and Astro.
Server-rendered pages.
Ordinary HTML forms.
Cloudflare D1 for the rows.
Private R2 for the files.
If you use TanStack Start
instead, use server functions
and a SQL database with
migrations. Same screens.
Same rules.

Flavio is the only seller.
He signs in with a session
cookie. The password lives
in env, not in the repo.
Every admin page and every
mutation checks that
session on the server.

A product has a title, a
slug, a description, a
price in integer cents,
a published flag, and one
file. The editor shows
dollars. Save 29 as 2900.
Publish stays disabled
until the upload has
finished. The object is
private. The key never
appears in HTML.

No buyer accounts. No
cart. No discounts.

Done when Flavio can save
a draft of Astro notes,
and a signed-out visitor
cannot open /products.

What to check after the draft exists

Sign in as Flavio. Create Astro notes at $29. Reload the editor. The price still shows 29, and the stored amount is 2900.

Open /products with no cookie. You get a login wall. The draft has no public URL yet.

Prompt 2. Publish the page and start Checkout

Add the public product page
at /p/:slug and the buy
action.

A published product shows
the title, the description,
the price in dollars, and
Buy. A draft slug and an
unknown slug render the
same generic sentence.
No file name from storage.
No buyer list.

Buy creates a Stripe
Checkout session for that
product's cent amount and
redirects Ada there. Card
data stays on Stripe.
The success URL says to
check email. It must not
create a sale, send mail,
or mint a download.

Unpublish removes Buy.
Existing sales keep their
tokens.

No license key field.
No pay-what-you-want box.

Done when Ada can open
/p/astro-notes and land
on Checkout, and a draft
slug tells her nothing
about the product.

What to check after Buy works

Publish. Open the page in a private window. You see $29 and Buy. View source. No object key.

Click Buy and cancel on Stripe. The sales list is empty. Visit the success URL directly. It still creates nothing.

Unpublish. The public URL and a guessed slug show the same sentence.

Prompt 3. One sale from a verified webhook

The card’s rule is the whole prompt. Access starts when a verified webhook commits, and a repeated event stays one sale.

Add the Stripe webhook.

Verify the signature with
the webhook secret from
the environment. Reject
a bad signature before
any write.

On a paid event, insert
one sale in the same
transaction as the
provider payment id.
That id is unique. Store
the buyer email and the
amount in cents. Status
is paid.

Email one download page
to that buyer. The page
token is unguessable,
expires seven days after
the sale, and allows five
downloads. If the email
send fails, keep the sale
and mark the email failed.

A second delivery of the
same payment id writes
nothing and sends no
second email. The success
URL is still not a grant.

Done when three identical
paid webhooks leave one
sale and one email.

What to check after Ada pays

Pay for Astro notes with a Stripe test card. The sales list shows one row for [email protected], $29, paid.

Replay the webhook. You still have one row and one message. A request with a bad signature leaves the table unchanged.

If the mail provider is down, the row exists with email failed, and the PDF is still private.

Prompt 4. Sign the file, then refund

Build /d/:token and the
sales actions.

Ada sees the product title,
the download name, the
expiry, and the remaining
attempts. Download rechecks
the token: paid sale, not
expired, attempts left.
Only then mint a signed
URL for that one object,
valid for a few minutes.
Decrement the attempts
when the grant is issued.

The email and the page
never contain a bucket
URL or an object key.

An expired or exhausted
token shows a form that
asks for an email. If it
matches the sale, send a
fresh token to that
address. Any other input
shows the same sentence
and sends nothing.

Resend on the sales list
sends the current token
again to the buyer.
Refund calls Stripe. Flip
the sale to refunded only
after Stripe confirms.
A refunded sale refuses
the next signed URL.

Done when Ada saves the
PDF, a sixth download is
refused, and a confirmed
refund blocks the next
grant.

What to check after the PDF saves

Open the token from the email. Download. The address bar stays on /d/k7m2n9p4q1. View source has no r2.dev host.

Copy the signed URL and wait out the TTL. It fails. Refresh the download page and download again. Attempts fall.

Use the fifth grant, then try a sixth. The page offers the email form. Submit Ada’s address and receive a new token. Submit a different address and see the same sentence, with no new mail.

Refund. Refresh Ada’s page. The next click does not sign a URL. The sales row says refunded.

Failures the agent ships anyway

A sale created by the success URL

You notice a paid row after visiting /checkout/success with no webhook, or a missing row when Ada closes the tab before the redirect. The webhook is the grant. The return page only tells her to check email. The Teachable card uses the same rule for course access.

A public object in the email

The message links to r2.dev or a path that includes the bucket key. Then the token and the refund do nothing, because the PDF is already copied. Sign on the download request. The file transfer guide and the DocSend guide keep the same line.

Two emails for one payment

The sales list shows one charge and Ada has two messages, or two sale rows share a story. The provider payment id has to be unique inside the insert. A replay finds that row and returns.

Dollars stored as a float

The editor shows 29 and the charge goes out as 28.99 or 2900.00 in a decimal column. Store 2900. Format dollars only on the page.

A refund that flips first

The row says refunded while Stripe still shows the charge as paid, or the file stays available after Stripe has refunded. Wait for the provider. Then refuse the next signed URL.

A discount box on the 12-hour row

A coupon field, a pay-what-you-want input, a license key, or a VAT line means the agent left the first estimate. Those are the 4-day row and the 480-hour row. One price, one file.

Deploy the shop

Sellshelf wants Astro, D1, private R2, Stripe Checkout, and a transactional email provider. Put the site on a subdomain you already own, sell.flaviocopes.com. The webhook has to be reachable from Stripe, so localhost is only for the pages.

The hosting post is the adapter, the bindings, and the custom domain. Put the webhook secret and the Stripe secret in the environment. Keep them off the client.

Attach the domain. Publish Astro notes. Buy it with a test card from your phone. Confirm one email, one download, and one sales row. Refund from the laptop. Refresh the phone. Confirm the next download is refused.

Take a D1 backup and an R2 listing before you delete a paid Gumroad account. The files and the buyer emails are what you will miss.

Use it for one real product. If you miss a coupon, a second file version, or a receipt PDF, those are the 32-hour row. Then cancel Gumroad, or keep paying if the marketplace is where the buyers come from.

What to skip on purpose

Leave out marketplace discovery, memberships, affiliates, VAT calculation, and physical goods.

12-HOUR V1 One product page, one file Hosted checkout at 2900 Signed download after pay SKIP THIS HALF Coupons and license keys VAT and affiliates A marketplace of sellers
Ship the 12-hour file. Leave the marketplace on the skip row.

Discount codes, file versions, pay-what-you-want, and receipts wait until one stranger has paid and downloaded. License keys wait with them. The same v1 also replaces Lemon Squeezy, Payhip, and Sellfy when the job is a file and a payment.

More writeups land on the guides index. The audit is the catalog if the next ship should be a course or a cart.

Start now

Go to Build your own Gumroad. Copy the Astro prompt, or TanStack Start if that is what you write.

Tell the agent the first estimate only: product pages, checkout, signed delivery links. Twelve hours.

Sell Astro notes to one person you can email before you add a coupon field.