Leanpub Header

Skip to main content

Leanpub API Documentation

The Leanpub API allows authors to programmatically preview, publish, and manage their books and courses.

Quickstart

Replace YOUR_API_KEY with your actual API key, your-book with your book's slug, and yourname with your username.

This is based on an API test script written in Ruby which is shown at the bottom of this page.

# ===========================================
# PART 1: Verify API Key
# ===========================================

# Verify your API key
curl "https://leanpub.com/current_user.json?api_key=YOUR_API_KEY"

# ===========================================
# PART 2: Book Lifecycle
# ===========================================

# Check if book exists (before creating)
curl "https://leanpub.com/your-book/exists.json?api_key=YOUR_API_KEY"

# Create a new book (Browser mode)
curl -X POST -H "Content-Type: application/json" \
  -d '{"api_key":"YOUR_API_KEY","title":"My Book","slug":"your-book","sync_mode":"monaco"}' \
  "https://leanpub.com/books.json"

# Check if book exists (after creating)
curl "https://leanpub.com/your-book/exists.json?api_key=YOUR_API_KEY"

# Get book summary
curl "https://leanpub.com/your-book.json?api_key=YOUR_API_KEY"

# Preview book
curl -X POST -d "api_key=YOUR_API_KEY" \
  "https://leanpub.com/your-book/preview.json"

# Check job status (poll until empty {} response)
curl "https://leanpub.com/your-book/job_status.json?api_key=YOUR_API_KEY"

# Register interest in the unpublished book
curl -X POST -H "Content-Type: application/json" \
  -d '{"api_key":"YOUR_API_KEY","name":"Test User","email":"test@example.com","share_email_with_author":true}' \
  "https://leanpub.com/your-book/interested.json"

# Get interested readers (before publish)
curl "https://leanpub.com/your-book/interested_readers.json?api_key=YOUR_API_KEY"

# Publish book
curl -X POST \
  -d "api_key=YOUR_API_KEY" \
  -d "publish[email_readers]=false" \
  -d "publish[release_notes]=Initial publish via API" \
  "https://leanpub.com/your-book/publish.json"

# Check job status (poll until empty {} response)
curl "https://leanpub.com/your-book/job_status.json?api_key=YOUR_API_KEY"

# Unpublish book
curl -X POST -d "api_key=YOUR_API_KEY" \
  "https://leanpub.com/your-book/unpublish.json"

# Check book state (should be unpublished)
curl "https://leanpub.com/your-book/exists.json?api_key=YOUR_API_KEY"

# Re-publish book (required before retire)
curl -X POST \
  -d "api_key=YOUR_API_KEY" \
  -d "publish[email_readers]=false" \
  "https://leanpub.com/your-book/publish.json"

# Check job status (poll until empty {} response)
curl "https://leanpub.com/your-book/job_status.json?api_key=YOUR_API_KEY"

# Retire book
curl -X POST -d "api_key=YOUR_API_KEY" \
  "https://leanpub.com/your-book/retire.json"

# Check book state (should be retired)
curl "https://leanpub.com/your-book/exists.json?api_key=YOUR_API_KEY"

# Close book
curl -X POST -d "api_key=YOUR_API_KEY" \
  "https://leanpub.com/your-book/close.json"

# Check book state (should be closed)
curl "https://leanpub.com/your-book/exists.json?api_key=YOUR_API_KEY"

# ===========================================
# PART 3: Existing Book Endpoints
# ===========================================

# Get royalties (JSON)
curl "https://leanpub.com/your-book/royalties.json?api_key=YOUR_API_KEY"

# Get royalties (XML)
curl "https://leanpub.com/your-book/royalties.xml?api_key=YOUR_API_KEY"

# Get book reader emails
curl "https://leanpub.com/your-book/reader_emails.json?api_key=YOUR_API_KEY"

# Get individual purchases (JSON)
curl "https://leanpub.com/your-book/individual_purchases.json?api_key=YOUR_API_KEY"

# Get individual purchases (XML)
curl "https://leanpub.com/your-book/individual_purchases.xml?api_key=YOUR_API_KEY"

# List coupons (JSON)
curl "https://leanpub.com/your-book/coupons.json?api_key=YOUR_API_KEY"

# List coupons (XML)
curl "https://leanpub.com/your-book/coupons.xml?api_key=YOUR_API_KEY"

# Create coupon
curl -X POST -H "Content-Type: application/json" \
  -d '{"api_key":"YOUR_API_KEY","coupon":{"coupon_code":"TESTCODE","package_discounts_attributes":[{"package_slug":"book","discounted_price":4.99}],"start_date":"2026-01-01","end_date":"2026-12-31","max_uses":100,"note":"Test coupon"}}' \
  "https://leanpub.com/your-book/coupons.json"

# Get single coupon
curl "https://leanpub.com/your-book/coupons/TESTCODE.json?api_key=YOUR_API_KEY"

# Update coupon (suspend it)
curl -X PUT \
  -d "api_key=YOUR_API_KEY" \
  -d "suspended=true" \
  -d "note=Suspended via API" \
  "https://leanpub.com/your-book/coupons/TESTCODE.json"

Authentication

All API requests require authentication via an API key.

Getting Your API Key

  1. You need a Pro plan to access the API
  2. Go to your API Key settings to generate one
  3. Keep your API key secret—it provides full access to your books

Using Your API Key

Include your API key in every request using one of these methods:

Query parameter (GET requests):

GET https://leanpub.com/your-book.json?api_key=YOUR_API_KEY

Form data (POST/PUT requests):

curl -d "api_key=YOUR_API_KEY" https://leanpub.com/your-book/preview.json

JSON body (POST/PUT requests):

curl -H "Content-Type: application/json" \
  -d '{"api_key":"YOUR_API_KEY"}' \
  https://leanpub.com/your-book/preview.json

Book Information

Get Book Summary

Returns detailed information about a book.

GET https://leanpub.com/{slug}.json

Example:

curl "https://leanpub.com/your-book.json?api_key=YOUR_API_KEY"

Response:

{
  "slug": "your-book",
  "title": "Your Book Title",
  "subtitle": "An Optional Subtitle",
  "about_the_book": "Description of the book...",
  "author_string": "Author Name",
  "url": "https://leanpub.com/your-book",
  "title_page_url": "https://...",
  "image": "https://...",
  "minimum_paid_price": "9.99",
  "suggested_price": "19.99",
  "page_count": 150,
  "page_count_published": 148,
  "word_count": 45000,
  "word_count_published": 44500,
  "total_copies_sold": 1234,
  "total_revenue": "15000.00",
  "last_published_at": "2024-01-15T19:21:50Z",
  "meta_description": "SEO description",
  "possible_reader_count": 50,
  "pdf_preview_url": "https://leanpub.com/s/...",
  "epub_preview_url": "https://leanpub.com/s/...",
  "pdf_published_url": "https://leanpub.com/s/...",
  "epub_published_url": "https://leanpub.com/s/..."
}

The pdf_preview_url, epub_preview_url, pdf_published_url, and epub_published_url fields are secret URLs for downloading your book files. Keep them private.

Create Book

Creates a new book. Supports two writing modes: Browser (monaco) and GitHub.

POST https://leanpub.com/books.json

Parameters:

Example (Browser mode):

curl -X POST -H "Content-Type: application/json" \
  -d '{
    "api_key": "YOUR_API_KEY",
    "title": "My New Book",
    "slug": "my-new-book",
    "sync_mode": "monaco"
  }' \
  https://leanpub.com/books.json

Example (GitHub mode):

curl -X POST -H "Content-Type: application/json" \
  -d '{
    "api_key": "YOUR_API_KEY",
    "title": "My GitHub Book",
    "slug": "my-github-book",
    "sync_mode": "github",
    "github_path": "myusername/my-book-repo",
    "publish_branch": "main",
    "preview_branch": "main"
  }' \
  https://leanpub.com/books.json

Response (success):

{
  "success": true,
  "book": {
    "slug": "my-new-book",
    "title": "My New Book",
    "state": "unpublished",
    "url": "https://leanpub.com/my-new-book"
  }
}

Response (error):

{
  "success": false,
  "errors": ["Slug has already been taken"]
}

Check Book Exists

Checks if a book exists and you are an author of it. Returns book info if you have access, or 404 if the book doesn't exist or you're not an author.

GET https://leanpub.com/{slug}/exists.json

Example:

curl "https://leanpub.com/my-book/exists.json?api_key=YOUR_API_KEY"

Response (success - you are an author):

{
  "exists": true,
  "book": {
    "slug": "my-book",
    "title": "My Book",
    "state": "published"
  }
}

Response (not found or not an author):

{
  "exists": false,
  "error": "Book not found or you are not an author of this book"
}

Preview & Publish

Preview Book

Starts a full preview generation of your book.

POST https://leanpub.com/{slug}/preview.json

Example:

curl -X POST -d "api_key=YOUR_API_KEY" "https://leanpub.com/your-book/preview.json"

Response:

{
  "success": true
}

Preview Subset

Generates a preview using only the files listed in Subset.txt. This creates only a PDF for faster iteration.

POST https://leanpub.com/{slug}/preview/subset.json

Example:

curl -X POST -d "api_key=YOUR_API_KEY" "https://leanpub.com/your-book/preview/subset.json"

Response:

{
  "success": true
}

Preview Single File

Generates a preview from raw Markdown content. Useful for previewing a single chapter without modifying Subset.txt. The output is saved as {slug}-single-file.pdf in your Dropbox previews folder.

POST https://leanpub.com/{slug}/preview/single.json
Content-Type: text/plain

# Chapter Title

Your markdown content here...

Example with curl:

curl -X POST \
  -H "Content-Type: text/plain" \
  --data-binary "# Test Chapter

This is a test chapter for the single file preview API.

## Section 1

Some content here." \
  "https://leanpub.com/your-book/preview/single.json?api_key=YOUR_API_KEY"

Ruby Script Example:

#!/usr/bin/env ruby
require "httpclient"

slug = ARGV[0]
filename = ARGV[1]
api_key = ENV["LEANPUB_API_KEY"]

content = File.read(filename)
headers = { "Content-Type" => "text/plain"}

url = "https://leanpub.com/#{slug}/preview/single.json?api_key=#{api_key}"
HTTPClient.new.post(url, :body => content, :header => headers)

Response:

{
  "success": true
}

Publish Book

Publishes your book, making the latest version available to readers.

POST https://leanpub.com/{slug}/publish.json

Parameters:

Examples:

Publish without notifying readers:

curl -d "api_key=YOUR_API_KEY" \
  https://leanpub.com/your-book/publish.json

Publish and notify readers:

curl -d "api_key=YOUR_API_KEY" \
  -d "publish[email_readers]=true" \
  -d "publish[release_notes]=Fixed typos in chapter 3" \
  https://leanpub.com/your-book/publish.json

Publish with multi-line release notes (URL-encoded):

curl -d "api_key=YOUR_API_KEY" \
  -d "publish[email_readers]=true" \
  -d "publish[release_notes]=New+in+this+release%3A%0A%0A-+Fixed+typos%0A-+Added+chapter+4" \
  https://leanpub.com/your-book/publish.json

Response:

{
  "success": true
}

Unpublish Book

Unpublishes a book, making it no longer available for purchase. Only the primary author can unpublish a book.

POST https://leanpub.com/{slug}/unpublish.json

Example:

curl -X POST -d "api_key=YOUR_API_KEY" \
  https://leanpub.com/your-book/unpublish.json

Response (success):

{
  "success": true,
  "book": {
    "slug": "your-book",
    "state": "unpublished"
  }
}

Response (not primary author):

{
  "success": false,
  "error": "Only the primary author can unpublish a book"
}

Retire Book

Retires a published book. Retired books are no longer available for new purchases, but existing readers retain access. Only the primary author can retire a book.

POST https://leanpub.com/{slug}/retire.json

Example:

curl -X POST -d "api_key=YOUR_API_KEY" \
  https://leanpub.com/your-book/retire.json

Response (success):

{
  "success": true,
  "book": {
    "slug": "your-book",
    "state": "retired"
  }
}

Response (not primary author):

{
  "success": false,
  "error": "Only the primary author can retire a book"
}

Close Book

Closes a book completely. Closed books are hidden from the store and cannot be purchased. Only the primary author can close a book.

POST https://leanpub.com/{slug}/close.json

Example:

curl -X POST -d "api_key=YOUR_API_KEY" \
  https://leanpub.com/your-book/close.json

Response (success):

{
  "success": true,
  "book": {
    "slug": "your-book",
    "state": "closed"
  }
}

Response (not primary author):

{
  "success": false,
  "error": "Only the primary author can close a book"
}

Get Job Status

Check the status of a running preview or publish job.

GET https://leanpub.com/{slug}/job_status.json

Example:

curl "https://leanpub.com/your-book/job_status.json?api_key=YOUR_API_KEY"

Response (job in progress):

{
  "num": 8,
  "total": 28,
  "job_type": "GenerateBookJob#preview",
  "message": "Generating PDF...",
  "status": "working",
  "name": "Preview your-book",
  "time": 1376073552,
  "options": {
    "requested_by": "you@example.com",
    "slug": "your-book",
    "action": "preview"
  }
}

Response fields:

The num and total fields can be used to display progress like "Step 8 of 28".

Response (job complete): Returns an empty object {} when no job is running.

Poll this endpoint to track progress, but limit requests to once every 5 seconds.

Utility Script: Poll Until Complete

while true; do
  status=$(curl -s "https://leanpub.com/your-book/job_status.json?api_key=YOUR_API_KEY")
  echo "$status" | jq .
  if [ "$status" = "{}" ]; then
    echo "Job complete!"
    break
  fi
  sleep 5
done

Sales & Royalties

Get Royalties Summary

Returns a summary of your book's sales and royalties. Available in both JSON and XML formats.

JSON:

GET https://leanpub.com/{slug}/royalties.json

XML:

GET https://leanpub.com/{slug}/royalties.xml

Here's an example with curl:

# JSON
curl "https://leanpub.com/SLUG/royalties.json?api_key=YOUR_API_KEY"

# XML
curl "https://leanpub.com/SLUG/royalties.xml?api_key=YOUR_API_KEY"

Response (JSON):

{
  "total_royalties": 12500.00,
  "royalties_bundled": 500.00,
  "royalties_unbundled": 12000.00,
  "last_week_royalties": 250.00,
  "royalties_to_revenue_ratio": 0.80,
  "total_revenue": 15625.00,
  "revenue_bundled": 625.00,
  "revenue_unbundled": 15000.00,
  "total_copies_sold": 1234,
  "num_copies_sold_bundled": 50,
  "num_copies_sold_unbundled": 1184
}

Get Individual Purchases

Returns a paginated list of individual purchases for your book. Available in both JSON and XML formats.

JSON:

GET https://leanpub.com/{slug}/individual_purchases.json

XML:

GET https://leanpub.com/{slug}/individual_purchases.xml

Parameters:

Examples:

# JSON
curl "https://leanpub.com/your-book/individual_purchases.json?api_key=YOUR_API_KEY"

# XML
curl "https://leanpub.com/your-book/individual_purchases.xml?api_key=YOUR_API_KEY"

# JSON with pagination
curl "https://leanpub.com/your-book/individual_purchases.json?api_key=YOUR_API_KEY&page=2"

# XML with pagination
curl "https://leanpub.com/your-book/individual_purchases.xml?api_key=YOUR_API_KEY&page=2"

Response (JSON):

[
  {
    "id": "123",
    "author_royalties": 8.00,
    "state": "paid",
    "payable_at": "2024-02-01T00:00:00Z",
    "purchased_package_id": "456",
    "free": false,
    "user_email": "reader@example.com",
    "username": "reader123"
  }
]

Coupons

List Coupons

Returns all coupons for a book. Available in both JSON and XML formats.

JSON:

GET https://leanpub.com/{slug}/coupons.json

XML:

GET https://leanpub.com/{slug}/coupons.xml

Examples:

# JSON
curl "https://leanpub.com/your-book/coupons.json?api_key=YOUR_API_KEY"

# XML
curl "https://leanpub.com/your-book/coupons.xml?api_key=YOUR_API_KEY"

Response (JSON):

[
  {
    "coupon_code": "LAUNCH50",
    "created_at": "2024-01-01T00:00:00Z",
    "package_discounts": [
      {
        "package_slug": "book",
        "discounted_price": 4.99
      }
    ],
    "start_date": "2024-01-01",
    "end_date": "2024-12-31",
    "max_uses": 100,
    "num_uses": 25,
    "note": "Launch promotion",
    "suspended": false,
    "book_slug": "your-book"
  }
]

Get Single Coupon

Returns details for a specific coupon.

GET https://leanpub.com/{slug}/coupons/{coupon_code}.json

Example:

curl "https://leanpub.com/your-book/coupons/LAUNCH50.json?api_key=YOUR_API_KEY"

Response:

{
  "coupon_code": "LAUNCH50",
  "created_at": "2024-01-01T00:00:00Z",
  "package_discounts": [
    {
      "package_slug": "book",
      "discounted_price": 4.99
    }
  ],
  "start_date": "2024-01-01",
  "end_date": "2024-12-31",
  "max_uses": 100,
  "num_uses": 25,
  "note": "Launch promotion",
  "suspended": false,
  "book_slug": "your-book"
}

Create Coupon

Creates a new coupon for your book.

POST https://leanpub.com/{slug}/coupons.json

Parameters:

Example (JSON):

curl -H "Content-Type: application/json" \
  -d '{
    "api_key": "YOUR_API_KEY",
    "coupon": {
      "coupon_code": "SAVE50",
      "package_discounts_attributes": [
        {"package_slug": "book", "discounted_price": 4.99}
      ],
      "start_date": "2024-01-01",
      "end_date": "2024-12-31",
      "max_uses": 100,
      "note": "Half price promotion"
    }
  }' \
  https://leanpub.com/your-book/coupons.json

Example (form data):

curl -d "api_key=YOUR_API_KEY" \
  -d "coupon[coupon_code]=SAVE50" \
  -d "coupon[package_discounts_attributes][][package_slug]=book" \
  -d "coupon[package_discounts_attributes][][discounted_price]=4.99" \
  -d "coupon[start_date]=2024-01-01" \
  -d "coupon[end_date]=2024-12-31" \
  https://leanpub.com/your-book/coupons.json

Response: Returns the created coupon (see Get Single Coupon).

Update Coupon

Updates an existing coupon. Only include fields you want to change.

PUT https://leanpub.com/{slug}/coupons/{coupon_code}.json

Example (JSON):

curl -X PUT \
  -H "Content-Type: application/json" \
  -d '{"api_key": "YOUR_API_KEY", "suspended": true, "note": "Updated via API"}' \
  https://leanpub.com/your-book/coupons/SAVE50.json

Example (form data):

curl -X PUT \
  -d "api_key=YOUR_API_KEY" \
  -d "suspended=false" \
  -d "max_uses=200" \
  https://leanpub.com/your-book/coupons/SAVE50.json

Response: Returns the updated coupon (see Get Single Coupon).


Account

Verify API Key

Validates your API key and returns information about the authenticated user. Useful for testing your integration.

GET https://leanpub.com/current_user.json

Example:

curl "https://leanpub.com/current_user.json?api_key=YOUR_API_KEY"

Response:

{
  "username": "yourname",
  "email": "you@example.com"
}

Get Reader Emails

Returns email addresses of readers who have opted to share their email with you.

GET https://leanpub.com/u/{username}/reader_emails.json

Parameters:

Examples:

# All readers
curl "https://leanpub.com/u/yourname/reader_emails.json?api_key=YOUR_API_KEY"

# Books only (new parameter)
curl "https://leanpub.com/u/yourname/reader_emails.json?api_key=YOUR_API_KEY&type=book"

# Courses only (new parameter)
curl "https://leanpub.com/u/yourname/reader_emails.json?api_key=YOUR_API_KEY&type=course"

# Books only (legacy parameter - still works)
curl "https://leanpub.com/u/yourname/reader_emails.json?api_key=YOUR_API_KEY&purchase_type=book"

Response:

[
  "reader1@example.com",
  "reader2@example.com"
]

Get Book Reader Emails

Returns email addresses of readers who have purchased a specific book and opted to share their email with you.

GET https://leanpub.com/{slug}/reader_emails.json

Example:

curl "https://leanpub.com/your-book/reader_emails.json?api_key=YOUR_API_KEY"

Response:

[
  "reader1@example.com",
  "reader2@example.com"
]

Register Interest

Registers an email address to be notified when a book is published. This is useful for unpublished books where readers want to know when it becomes available.

POST https://leanpub.com/{slug}/interested.json

Parameters:

Example:

curl -X POST -H "Content-Type: application/json" \
  -d '{
    "api_key": "YOUR_API_KEY",
    "name": "Test Reader",
    "email": "reader@example.com",
    "share_email_with_author": true
  }' \
  https://leanpub.com/your-book/interested.json

Response (success):

{
  "success": true,
  "message": "You will be notified when this book is published"
}

Response (error):

{
  "success": false,
  "errors": ["Email has already registered interest"]
}

Get Interested Readers

Returns a list of people who registered interest in a book and opted to share their email with the author.

GET https://leanpub.com/{slug}/interested_readers.json

Example:

curl "https://leanpub.com/your-book/interested_readers.json?api_key=YOUR_API_KEY"

Response:

[
  {
    "name": "Test Reader",
    "email": "reader@example.com"
  }
]

Only readers who set share_email_with_author: true when registering interest will appear in this list.


Downloading Book Files

The book summary endpoint returns secret URLs for downloading your book files:

Get the download URLs first:

curl "https://leanpub.com/your-book.json?api_key=YOUR_API_KEY" | \
  jq '{pdf_preview_url, epub_preview_url, pdf_published_url, epub_published_url}'

Download using the secret URLs:

These URLs redirect to S3. Use the -L flag with curl to follow redirects:

# Download using the secret URLs (use actual URLs from above)
curl -L "https://leanpub.com/s/YOUR-SECRET-ID.pdf" > book.pdf
curl -L "https://leanpub.com/s/YOUR-SECRET-ID.epub" > book.epub

Error Handling

HTTP Status Codes:

Error Response:

{
  "success": false,
  "errors": ["Coupon code already exists"]
}

Usage Guidelines

The API is designed for individual authors automating their publishing workflow. Please be reasonable:

API access may be revoked if we notice abusive patterns.


Your Book's Slug

Your book's slug is the URL-friendly identifier in your book's URL:

https://leanpub.com/your-book
                     ^^^^^^^^^ this is the slug

Course API

Leanpub courses can be previewed and published via the API, similar to books.

Your Course's Slug

Courses have different URL patterns depending on how they're published:

Preview Course

Starts a preview generation of your course.

Self-published course:

POST https://leanpub.com/c/{slug}/preview.json

Organization course:

POST https://leanpub.com/c/{organization_slug}/{slug}/preview.json

University course:

POST https://leanpub.com/c/{university_slug}/{slug}/preview.json

Examples:

# Self-published course
curl -d "api_key=YOUR_API_KEY" https://leanpub.com/c/your-course/preview.json

# Organization course
curl -d "api_key=YOUR_API_KEY" https://leanpub.com/c/your-org/your-course/preview.json

# University course
curl -d "api_key=YOUR_API_KEY" https://leanpub.com/c/your-university/your-course/preview.json

Response (success):

{
  "success": true
}

Response (course not found):

{
  "success": false
}

Publish Course

Publishes your course, making the latest version available to learners.

Self-published course:

POST https://leanpub.com/c/{slug}/publish.json

Organization course:

POST https://leanpub.com/c/{organization_slug}/{slug}/publish.json

University course:

POST https://leanpub.com/c/{university_slug}/{slug}/publish.json

Parameters:

Examples:

# Self-published course - publish without notifying learners
curl -d "api_key=YOUR_API_KEY" https://leanpub.com/c/your-course/publish.json

# Self-published course - publish with release notes and notification
curl -d "api_key=YOUR_API_KEY" \
  -d "publish[email_readers]=true" \
  -d "publish[release_notes]=Updated lesson content" \
  https://leanpub.com/c/your-course/publish.json

# Organization course
curl -d "api_key=YOUR_API_KEY" \
  -d "publish[email_readers]=true" \
  -d "publish[release_notes]=New lessons added" \
  https://leanpub.com/c/your-org/your-course/publish.json

# University course
curl -d "api_key=YOUR_API_KEY" https://leanpub.com/c/your-university/your-course/publish.json

Response (success):

{
  "success": true
}

Response (course not found):

{
  "success": false
}

XML Endpoints

XML endpoints are supported for backwards compatibility with the legacy API. The following endpoints support both JSON and XML formats:

Simply use the .xml extension instead of .json to get XML responses:

# JSON
curl "https://leanpub.com/your-book/royalties.json?api_key=YOUR_API_KEY"

# XML
curl "https://leanpub.com/your-book/royalties.xml?api_key=YOUR_API_KEY"

Migration Notes from Legacy API

If you have an existing integration with the Leanpub API, here's what you need to know:

What's the Same

What's Different

Full compatibility has been achieved with the legacy API. Your existing integrations should continue to work without any changes.

New endpoints added:


API Test Script

The following Ruby script demonstrates the complete API workflow, including creating a book, previewing, publishing, managing lifecycle states, and testing various endpoints. You can use this as a reference for your own integrations.

Usage:

./test_api.rb --api-key YOUR_API_KEY --user yourname

# With existing book tests
./test_api.rb --api-key YOUR_API_KEY --user yourname --existing-book your-book

Script:

#!/usr/bin/env ruby
# frozen_string_literal: true

require "net/http"
require "uri"
require "json"
require "optparse"
require "securerandom"

options = {}

OptionParser.new do |opts|
  opts.banner = "Usage: #{$0} [options]"

  opts.on("--api-key KEY", "Your Leanpub API key (required)") do |v|
    options[:api_key] = v
  end

  opts.on("--user USERNAME", "Username for existing book tests (required)") do |v|
    options[:user] = v
  end

  opts.on("--existing-book SLUG", "Test existing book endpoints with this slug (optional)") do |v|
    options[:existing_book] = v
  end

  opts.on("-h", "--help", "Show this help") do
    puts opts
    puts
    puts "Examples:"
    puts "  #{$0} --api-key YOUR_API_KEY --user yourname"
    puts "  #{$0} --api-key YOUR_API_KEY --user yourname --existing-book your-book"
    exit
  end
end.parse!

if options[:api_key].nil? || options[:user].nil?
  puts "Error: --api-key and --user are required."
  puts
  puts "Usage: #{$0} --api-key KEY --user USERNAME"
  puts
  puts "Examples:"
  puts "  #{$0} --api-key YOUR_API_KEY --user yourname"
  puts "  #{$0} --api-key YOUR_API_KEY --user yourname --existing-book your-book"
  exit 1
end

API_KEY = options[:api_key]
BASE_URL = "https://leanpub.com"
TEST_USER = options[:user]
EXISTING_BOOK = options[:existing_book]

# Generate a unique test book slug
TEST_BOOK_SLUG = "api-test-#{SecureRandom.uuid}"
TEST_BOOK_TITLE = "API Test Book #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
TEST_INTERESTED_EMAIL = "#{SecureRandom.uuid}@test.com"

def create_http(uri)
  http = Net::HTTP.new(uri.host, uri.port)
  if uri.scheme == "https"
    http.use_ssl = true
  end
  http
end

def prompt_continue(step_name)
  print "\n\033[33mPress Enter to continue to: #{step_name} (or 'q' to quit)\033[0m "
  input = gets.chomp
  exit(0) if input.downcase == "q"
end

def build_curl_command(method, path, body: nil, content_type: nil)
  uri = URI("#{BASE_URL}#{path}")

  case method
  when :get
    query_params = { api_key: API_KEY }.merge(URI.decode_www_form(uri.query || "").to_h)
    uri.query = URI.encode_www_form(query_params)
    "curl -s \"#{uri}\""
  when :post
    if content_type == "application/json"
      json_body = body.to_json
      "curl -s -X POST -H \"Content-Type: application/json\" \\\n  -d '#{json_body}' \\\n  \"#{uri}\""
    else
      form_data = (body || {}).merge(api_key: API_KEY)
      data_args = form_data.map { |k, v| "-d \"#{k}=#{v}\"" }.join(" \\\n  ")
      "curl -s -X POST \\\n  #{data_args} \\\n  \"#{uri}\""
    end
  when :put
    form_data = (body || {}).merge(api_key: API_KEY)
    data_args = form_data.map { |k, v| "-d \"#{k}=#{v}\"" }.join(" \\\n  ")
    "curl -s -X PUT \\\n  #{data_args} \\\n  \"#{uri}\""
  end
end

def run_request(method, path, body: nil, content_type: nil, format: :json)
  # Show curl command
  curl_cmd = build_curl_command(method, path, body: body, content_type: content_type)
  puts "\033[90m$ #{curl_cmd}\033[0m"
  puts

  uri = URI("#{BASE_URL}#{path}")

  http = create_http(uri)

  case method
  when :get
    uri.query = URI.encode_www_form({ api_key: API_KEY }.merge(URI.decode_www_form(uri.query || "").to_h))
    request = Net::HTTP::Get.new(uri)
  when :post
    request = Net::HTTP::Post.new(uri)
    if content_type == "application/json"
      request["Content-Type"] = "application/json"
      request.body = body.to_json
    else
      request.set_form_data((body || {}).merge(api_key: API_KEY))
    end
  when :put
    request = Net::HTTP::Put.new(uri)
    request.set_form_data((body || {}).merge(api_key: API_KEY))
  end

  response = http.request(request)

  puts "\033[36mStatus: #{response.code}\033[0m"

  if format == :json && response.body && !response.body.empty?
    begin
      parsed = JSON.parse(response.body)
      puts JSON.pretty_generate(parsed)
    rescue JSON::ParserError
      puts response.body
    end
  else
    puts response.body
  end

  response
end

def test_step(number, name, &block)
  puts "\n" + "=" * 60
  puts "\033[32m#{number}. #{name}\033[0m"
  puts "=" * 60
  block.call
end

def wait_for_job(slug, max_wait: 120)
  puts "\033[90mWaiting for job to complete (max #{max_wait}s)...\033[0m"
  start_time = Time.now

  loop do
    uri = URI("#{BASE_URL}/#{slug}/job_status.json")
    uri.query = URI.encode_www_form({ api_key: API_KEY })

    http = create_http(uri)
    request = Net::HTTP::Get.new(uri)
    response = http.request(request)

    if response.body == "{}" || response.body.nil? || response.body.empty?
      puts "\033[32mJob complete!\033[0m"
      return true
    end

    begin
      status = JSON.parse(response.body)
      if status["message"]
        print "\r\033[90m  #{status['message']} (#{status['num']}/#{status['total']})\033[0m"
        print " " * 20 # Clear any remaining characters
      end
    rescue JSON::ParserError
      # Ignore parse errors
    end

    if Time.now - start_time > max_wait
      puts "\n\033[31mTimeout waiting for job!\033[0m"
      return false
    end

    sleep 3
  end
end

def fetch_book_info(slug)
  uri = URI("#{BASE_URL}/#{slug}.json")
  uri.query = URI.encode_www_form({ api_key: API_KEY })

  http = create_http(uri)
  request = Net::HTTP::Get.new(uri)
  response = http.request(request)

  return nil unless response.code == "200"

  JSON.parse(response.body)
rescue JSON::ParserError
  nil
end

def prompt_open_url(url, description)
  return unless url && !url.empty?

  puts "\n\033[36m#{description}:\033[0m #{url}"
  print "\033[33mPress Enter to open in browser (or 's' to skip)\033[0m "
  input = gets.chomp
  return if input.downcase == "s"

  # Open URL in default browser (macOS)
  system("open", url)
end

# Start testing
puts "\033[1m\033[35m"
puts "=" * 60
puts "  LEANPUB API TEST SCRIPT"
puts "=" * 60
puts "\033[0m"
puts
puts "\033[1m\033[41m\033[37m WARNING \033[0m\033[1m\033[31m Job completion detection is not working correctly!\033[0m"
puts "\033[31m         Please manually verify that preview/publish jobs complete before proceeding.\033[0m"
puts
puts "Base URL: #{BASE_URL}"
puts "API Key: #{API_KEY[0..10]}..."
puts "Test User: #{TEST_USER}"
puts
puts "\033[1mBook Lifecycle Test:\033[0m"
puts "  Slug: #{TEST_BOOK_SLUG}"
puts "  Title: #{TEST_BOOK_TITLE}"
puts "  Interested Email: #{TEST_INTERESTED_EMAIL}"

step = 0

# ============================================================
# PART 1: Verify API Key
# ============================================================

prompt_continue("Verify API Key")

test_step(step += 1, "Verify API Key") do
  run_request(:get, "/current_user.json")
end

# ============================================================
# PART 2: Book Lifecycle (Create -> Preview -> Publish -> Unpublish -> Retire -> Close)
# ============================================================

prompt_continue("Check if test book exists (should return exists: false)")

test_step(step += 1, "Check Book Exists (expect exists: false)") do
  run_request(:get, "/#{TEST_BOOK_SLUG}/exists.json")
end

prompt_continue("Create Book (Browser mode)")

test_step(step += 1, "Create Book") do
  response = run_request(:post, "/books.json",
    body: {
      api_key: API_KEY,
      title: TEST_BOOK_TITLE,
      slug: TEST_BOOK_SLUG,
      sync_mode: "monaco"
    },
    content_type: "application/json"
  )
  if response.code == "200" || response.code == "201"
    prompt_open_url("#{BASE_URL}/#{TEST_BOOK_SLUG}", "Book Landing Page")
  end
end

prompt_continue("Verify book now exists")

test_step(step += 1, "Check Book Exists (expect 200)") do
  run_request(:get, "/#{TEST_BOOK_SLUG}/exists.json")
end

prompt_continue("Get Book Summary")

test_step(step += 1, "Get Book Summary") do
  run_request(:get, "/#{TEST_BOOK_SLUG}.json")
end

prompt_continue("Preview Book")

test_step(step += 1, "Preview Book") do
  response = run_request(:post, "/#{TEST_BOOK_SLUG}/preview.json")
  if response.code == "200"
    puts
    if wait_for_job(TEST_BOOK_SLUG)
      book_info = fetch_book_info(TEST_BOOK_SLUG)
      if book_info
        prompt_open_url(book_info["pdf_preview_url"], "PDF Preview")
        prompt_open_url(book_info["epub_preview_url"], "EPUB Preview")
      end
    end
  end
end

prompt_continue("Register interest in the unpublished book")

test_step(step += 1, "Register Interest (#{TEST_INTERESTED_EMAIL})") do
  puts "\033[90mThis email will be notified when the book is published:\033[0m"
  puts "  #{TEST_INTERESTED_EMAIL}"
  puts
  run_request(:post, "/#{TEST_BOOK_SLUG}/interested.json",
    body: {
      api_key: API_KEY,
      name: "API Test User",
      email: TEST_INTERESTED_EMAIL,
      share_email_with_author: true
    },
    content_type: "application/json"
  )
end

prompt_continue("Get Interested Readers (before publish)")

test_step(step += 1, "Get Interested Readers") do
  puts "\033[90mChecking if #{TEST_INTERESTED_EMAIL} appears (they opted to share email):\033[0m"
  puts
  run_request(:get, "/#{TEST_BOOK_SLUG}/interested_readers.json")
end

prompt_continue("Publish Book")

test_step(step += 1, "Publish Book") do
  response = run_request(:post, "/#{TEST_BOOK_SLUG}/publish.json",
    body: {
      "publish[email_readers]" => false,
      "publish[release_notes]" => "Initial publish via API test"
    }
  )
  if response.code == "200"
    puts
    if wait_for_job(TEST_BOOK_SLUG)
      book_info = fetch_book_info(TEST_BOOK_SLUG)
      if book_info
        prompt_open_url(book_info["pdf_published_url"], "PDF Published")
        prompt_open_url(book_info["epub_published_url"], "EPUB Published")
      end
      prompt_open_url("#{BASE_URL}/#{TEST_BOOK_SLUG}", "Book Landing Page (Published)")
    end
  end
end

prompt_continue("Unpublish Book")

test_step(step += 1, "Unpublish Book") do
  response = run_request(:post, "/#{TEST_BOOK_SLUG}/unpublish.json")
  if response.code == "200"
    prompt_open_url("#{BASE_URL}/#{TEST_BOOK_SLUG}", "Book Landing Page (Unpublished)")
  end
end

prompt_continue("Check book state after unpublish")

test_step(step += 1, "Check Book State (should be unpublished)") do
  run_request(:get, "/#{TEST_BOOK_SLUG}/exists.json")
end

# Note: To retire, book must be published first. Let's re-publish then retire.
prompt_continue("Re-publish Book (required before retire)")

test_step(step += 1, "Re-publish Book") do
  response = run_request(:post, "/#{TEST_BOOK_SLUG}/publish.json",
    body: {
      "publish[email_readers]" => false
    }
  )
  if response.code == "200"
    puts
    if wait_for_job(TEST_BOOK_SLUG)
      book_info = fetch_book_info(TEST_BOOK_SLUG)
      if book_info
        prompt_open_url(book_info["pdf_published_url"], "PDF Published")
      end
      prompt_open_url("#{BASE_URL}/#{TEST_BOOK_SLUG}", "Book Landing Page (Re-published)")
    end
  end
end

prompt_continue("Retire Book")

test_step(step += 1, "Retire Book") do
  response = run_request(:post, "/#{TEST_BOOK_SLUG}/retire.json")
  if response.code == "200"
    prompt_open_url("#{BASE_URL}/#{TEST_BOOK_SLUG}", "Book Landing Page (Retired)")
  end
end

prompt_continue("Check book state after retire")

test_step(step += 1, "Check Book State (should be retired)") do
  run_request(:get, "/#{TEST_BOOK_SLUG}/exists.json")
end

prompt_continue("Close Book")

test_step(step += 1, "Close Book") do
  response = run_request(:post, "/#{TEST_BOOK_SLUG}/close.json")
  if response.code == "200"
    prompt_open_url("#{BASE_URL}/#{TEST_BOOK_SLUG}", "Book Landing Page (Closed)")
  end
end

prompt_continue("Check book state after close")

test_step(step += 1, "Check Book State (should be closed)") do
  run_request(:get, "/#{TEST_BOOK_SLUG}/exists.json")
end

# ============================================================
# PART 3: Existing Book Tests (if --existing-book provided)
# ============================================================

if EXISTING_BOOK
  puts "\n" + "=" * 60
  puts "\033[1m\033[35m  EXISTING BOOK TESTS (#{EXISTING_BOOK})\033[0m"
  puts "=" * 60

  prompt_continue("Royalties (JSON)")

  test_step(step += 1, "Royalties (JSON)") do
    run_request(:get, "/#{EXISTING_BOOK}/royalties.json")
  end

  prompt_continue("Royalties (XML)")

  test_step(step += 1, "Royalties (XML)") do
    run_request(:get, "/#{EXISTING_BOOK}/royalties.xml", format: :xml)
  end

  prompt_continue("Book Reader Emails")

  test_step(step += 1, "Book Reader Emails") do
    run_request(:get, "/#{EXISTING_BOOK}/reader_emails.json")
  end

  prompt_continue("Individual Purchases (JSON)")

  test_step(step += 1, "Individual Purchases (JSON)") do
    run_request(:get, "/#{EXISTING_BOOK}/individual_purchases.json")
  end

  prompt_continue("Individual Purchases (XML)")

  test_step(step += 1, "Individual Purchases (XML)") do
    run_request(:get, "/#{EXISTING_BOOK}/individual_purchases.xml", format: :xml)
  end

  prompt_continue("List Coupons (JSON)")

  test_step(step += 1, "List Coupons (JSON)") do
    run_request(:get, "/#{EXISTING_BOOK}/coupons.json")
  end

  prompt_continue("List Coupons (XML)")

  test_step(step += 1, "List Coupons (XML)") do
    run_request(:get, "/#{EXISTING_BOOK}/coupons.xml", format: :xml)
  end

  prompt_continue("Create Coupon")

  coupon_code = "TEST#{Time.now.to_i}"

  test_step(step += 1, "Create Coupon (#{coupon_code})") do
    run_request(:post, "/#{EXISTING_BOOK}/coupons.json",
      body: {
        api_key: API_KEY,
        coupon: {
          coupon_code: coupon_code,
          package_discounts_attributes: [
            { package_slug: "book", discounted_price: 4.99 }
          ],
          start_date: "2026-01-01",
          end_date: "2026-12-31",
          max_uses: 100,
          note: "Test coupon from API script"
        }
      },
      content_type: "application/json"
    )
  end

  prompt_continue("Get Single Coupon")

  test_step(step += 1, "Get Single Coupon (#{coupon_code})") do
    run_request(:get, "/#{EXISTING_BOOK}/coupons/#{coupon_code}.json")
  end

  prompt_continue("Update Coupon")

  test_step(step += 1, "Update Coupon (suspend #{coupon_code})") do
    run_request(:put, "/#{EXISTING_BOOK}/coupons/#{coupon_code}.json",
      body: { suspended: true, note: "Suspended via API test" }
    )
  end
end

# ============================================================
# Summary
# ============================================================

puts "\n" + "=" * 60
puts "\033[1m\033[32m  ALL TESTS COMPLETE!\033[0m"
puts "=" * 60
puts
puts "Test book created: #{TEST_BOOK_SLUG}"
puts "Final state: closed"
puts
if EXISTING_BOOK
  puts "Coupon '#{coupon_code}' was created and suspended on '#{EXISTING_BOOK}'."
end
puts