Publishing a digital product manually — upload file, write description, set price, add discount codes, verify everything is live — takes 30–45 minutes of copy-pasting and clicking. If you publish multiple products or update them frequently, that time compounds. This article automates the entire Gumroad launch flow from Python: create/update the listing, upload the EPUB, generate the description from your chapter summaries, configure discount codes, and run a pre-launch checklist before going live.
Gumroad API: Create and Update a Product
Gumroad's API uses form-encoded POST requests and returns JSON. Authentication is a bearer token from your account settings.
import os
import requests
from pathlib import Path
GUMROAD_API_BASE = "https://api.gumroad.com/v2"
ACCESS_TOKEN = os.environ["GUMROAD_ACCESS_TOKEN"]
def _gumroad_headers() -> dict:
return {"Authorization": f"Bearer {ACCESS_TOKEN}"}
def create_product(
name: str,
price_cents: int,
description: str,
preview_url: str | None = None,
) -> dict:
"""Create a new Gumroad product. Returns the product dict."""
payload = {
"name": name,
"price": price_cents,
"description": description,
"published": False, # don't publish until checklist passes
}
if preview_url:
payload["preview_url"] = preview_url
r = requests.post(
f"{GUMROAD_API_BASE}/products",
headers=_gumroad_headers(),
data=payload,
)
r.raise_for_status()
product = r.json()["product"]
print(f"Product created: {product['id']} — {product['name']}")
return product
def update_product(product_id: str, **fields) -> dict:
"""Update fields on an existing Gumroad product."""
r = requests.put(
f"{GUMROAD_API_BASE}/products/{product_id}",
headers=_gumroad_headers(),
data=fields,
)
r.raise_for_status()
return r.json()["product"]
Uploading a File to a Product
Gumroad requires the file upload as a multipart form. For large EPUB files, stream from disk rather than loading into memory:
def upload_product_file(product_id: str, file_path: Path) -> dict:
"""Upload a file to a Gumroad product. Returns the product file dict."""
if not file_path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
file_size_mb = file_path.stat().st_size / (1024 * 1024)
print(f"Uploading {file_path.name} ({file_size_mb:.1f} MB)...")
with file_path.open("rb") as f:
r = requests.post(
f"{GUMROAD_API_BASE}/products/{product_id}/product_files",
headers=_gumroad_headers(),
files={"file": (file_path.name, f, "application/epub+zip")},
)
r.raise_for_status()
product_file = r.json()["product_file"]
print(f"Upload complete: {product_file['id']}")
return product_file
Generating a Product Description from Chapter Summaries
Rather than writing the description manually, generate it from the chapter summaries your pipeline already produced:
import anthropic
import json
from pathlib import Path
def generate_product_description(summaries_path: Path, title: str) -> str:
"""
Read chapter summaries from a JSON file and generate a Gumroad
product description using an LLM.
"""
summaries = json.loads(summaries_path.read_text())
chapter_list = "\n".join(
f"- Chapter {s['number']}: {s['title']} — {s['summary']}"
for s in summaries
)
client = anthropic.Anthropic()
message = client.messages.create(
model="claude-opus-4-5",
max_tokens=1024,
messages=[{
"role": "user",
"content": (
f"Write a Gumroad product description for a technical ebook titled '{title}'.\n\n"
f"Chapter summaries:\n{chapter_list}\n\n"
"Requirements:\n"
"- 200-300 words\n"
"- Lead with the core problem the book solves\n"
"- List 5-7 specific things the reader will be able to do\n"
"- No hype words (revolutionary, game-changing, etc.)\n"
"- End with who this book is NOT for\n"
"- Plain text, no Markdown headings"
),
}],
)
return message.content[0].text
Setting Up Discount Codes via API
Launch pricing and promo codes can be configured programmatically:
def create_offer_code(
product_id: str,
code: str,
amount_off_cents: int,
max_purchase_count: int | None = None,
) -> dict:
"""
Create a discount code for a product.
amount_off_cents: discount amount in cents (e.g. 500 = $5 off).
"""
payload = {
"offer_code": code,
"amount_off": amount_off_cents,
}
if max_purchase_count:
payload["max_purchase_count"] = max_purchase_count
r = requests.post(
f"{GUMROAD_API_BASE}/products/{product_id}/offer_codes",
headers=_gumroad_headers(),
data=payload,
)
r.raise_for_status()
offer = r.json()["offer_code"]
print(f"Offer code created: {offer['name']} — {amount_off_cents/100:.2f} off")
return offer
Webhook Listener for Sales Events
A minimal FastAPI webhook to track sales in real-time:
from fastapi import FastAPI, Request
import logging
app = FastAPI()
log = logging.getLogger("gumroad.webhook")
@app.post("/webhooks/gumroad")
async def gumroad_sale(request: Request):
"""
Gumroad sends a POST with form data on each sale.
Verify the seller_id matches yours before processing.
"""
data = await request.form()
sale_data = dict(data)
seller_id = sale_data.get("seller_id")
expected_seller_id = os.environ["GUMROAD_SELLER_ID"]
if seller_id != expected_seller_id:
log.warning(f"Webhook seller_id mismatch: {seller_id}")
return {"status": "ignored"}
product_name = sale_data.get("product_name")
sale_price = sale_data.get("price")
buyer_email = sale_data.get("email")
sale_id = sale_data.get("sale_id")
log.info(f"Sale {sale_id}: {product_name} — ${int(sale_price)/100:.2f} — {buyer_email}")
# Add your fulfillment logic here: send a receipt, update a dashboard, etc.
return {"status": "ok"}
Run with: uvicorn webhook:app --host 0.0.0.0 --port 8000
Pre-Launch Checklist as Code
Before publishing, run a script that verifies everything is in place:
import sys
def run_launch_checklist(product_id: str, epub_path: Path) -> bool:
checks = []
# 1. EPUB exists and is a valid size
if epub_path.exists() and epub_path.stat().st_size > 10_000:
checks.append(("EPUB file exists and >10KB", True, ""))
else:
checks.append(("EPUB file exists and >10KB", False, str(epub_path)))
# 2. Product exists on Gumroad
r = requests.get(
f"{GUMROAD_API_BASE}/products/{product_id}",
headers=_gumroad_headers(),
)
if r.ok:
product = r.json()["product"]
checks.append(("Product exists on Gumroad", True, ""))
# 3. Price is set
price_ok = product.get("price", 0) > 0
checks.append(("Price is set", price_ok, f"price={product.get('price')}"))
# 4. Description is not empty
desc_ok = len(product.get("description", "")) > 100
checks.append(("Description is >100 chars", desc_ok, ""))
# 5. At least one product file is attached
files_r = requests.get(
f"{GUMROAD_API_BASE}/products/{product_id}/product_files",
headers=_gumroad_headers(),
)
files_ok = files_r.ok and len(files_r.json().get("product_files", [])) > 0
checks.append(("Product file is uploaded", files_ok, ""))
else:
checks.append(("Product exists on Gumroad", False, f"HTTP {r.status_code}"))
# 6. At least one discount code exists
codes_r = requests.get(
f"{GUMROAD_API_BASE}/products/{product_id}/offer_codes",
headers=_gumroad_headers(),
)
codes_ok = codes_r.ok and len(codes_r.json().get("offer_codes", [])) > 0
checks.append(("Discount code configured", codes_ok, ""))
# Print results
all_pass = True
print("\n--- Launch Checklist ---")
for label, passed, note in checks:
status = "PASS" if passed else "FAIL"
print(f" [{status}] {label}" + (f" ({note})" if note else ""))
if not passed:
all_pass = False
print(f"\nResult: {'READY TO PUBLISH' if all_pass else 'NOT READY — fix failures above'}")
return all_pass
if __name__ == "__main__":
product_id = os.environ["GUMROAD_PRODUCT_ID"]
epub_path = Path("outputs/book.epub")
if not run_launch_checklist(product_id, epub_path):
sys.exit(1)
# Only publish if checklist passes
update_product(product_id, published=True)
print("Product is now live.")
The checklist approach means a bad deploy fails loudly with a specific failure message, not silently with a misconfigured listing.
Full pipeline + source code: germy5.gumroad.com/l/xhxkzz — $19.99, 30-day refund.
📋 Free resource: AI Publishing Checklist — 7 steps to ship a technical ebook with Python (free PDF)
Full pipeline + 10 scripts: germy5.gumroad.com/l/xhxkzz — $12.99 launch price
Top comments (0)