close

DEV Community

German Yamil
German Yamil

Posted on

Automating Digital Product Launches: From EPUB to Gumroad Listing with Python

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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.")
Enter fullscreen mode Exit fullscreen mode

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)