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
- You need a Pro plan to access the API
- Go to your API Key settings to generate one
- 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:
title(Required) - The book titleslug(Required) - URL-friendly identifier (must be unique)sync_mode(Optional) - Writing mode:monaco(default) orgithublanguage_id(Optional) - Language ID (defaults to English)publisher_id(Optional) - Publisher ID (defaults to Leanpub)github_path(Required for GitHub) - GitHub repo path (e.g.,username/repo)publish_branch(Optional) - Branch for publishing (default:main)preview_branch(Optional) - Branch for previews (default:main)
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:
publish[email_readers](boolean): Send release notification to readers (default: false)publish[release_notes](string): Release notes included in the notification email (URL-encode if needed)
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:
num: Current step numbertotal: Total number of stepsjob_type: The type of job running (e.g.,GenerateBookJob#preview,GenerateBookJob#publish)message: Human-readable status messagestatus: Job status (working, etc.)time: Unix timestamp when the job started
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:
page(integer): Page number (default: 1, 50 items per page)
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:
coupon_code(required): Unique code for this couponpackage_discounts_attributes(required): Array of package discountsstart_date(required): Start date (YYYY-MM-DD)end_date(optional): End date (YYYY-MM-DD)max_uses(optional): Maximum number of uses (null = unlimited)note(optional): Internal note about this couponsuspended(optional): Whether the coupon is suspended (default: false)
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:
type(string): Filter by purchase type:all,book, orcourse(default:all)purchase_type(string): Alias fortype(for backwards compatibility)
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:
name(required): The name of the interested readeremail(required): Email address to notifyshare_email_with_author(boolean): Whether to share the email with the author (default: false)
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:
pdf_preview_url/epub_preview_url- Latest previewpdf_published_url/epub_published_url- Latest published version
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:
- 200: Success
- 201: Created (for POST requests)
- 401: Unauthorized - Invalid or missing API key
- 404: Not found - Book or resource doesn't exist
- 405: Method not allowed
- 422: Validation error - Check the error response for details
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:
- Poll
job_statusno more than once every 5 seconds - There are no strict rate limits, but please don't hammer our servers
- If you're building something that makes many requests, add small delays between calls
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:
- Self-published:
https://leanpub.com/c/your_course— needsyour_courseslug - Organization:
https://leanpub.com/courses/org_slug/course_slug— needs bothorg_slugandcourse_slug - University:
https://leanpub.com/universities/courses/uni_slug/course_slug— needs bothuni_slugandcourse_slug
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:
publish[email_readers](Optional) - Whether to notify learners (default: true)publish[release_notes](Optional) - Release notes to include in notificationpublish[percent_complete](Optional) - Set course percent complete (integer)
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:
/{slug}/royalties.xml- Royalties summary/{slug}/individual_purchases.xml- Individual purchases (supports pagination)/{slug}/coupons.xml- List coupons
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
- All JSON endpoints work identically
- All XML endpoints work identically (royalties, individual_purchases, coupons)
- Authentication via
api_keyquery param or POST body - Request/response formats are compatible
- Both
typeandpurchase_typework for reader_emails - Course API (
/c/...) endpoints work identically
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:
POST /books.json- Create a new book (Browser or GitHub mode)POST /{slug}/interested.json- Register interest in an unpublished bookGET /{slug}/interested_readers.json- Get list of interested readers (for authors)
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