GuidesPDF to EPUB · Published 1 May 2026 · Updated 2 Sep 2026 · toolkit.bot

PDF to EPUB API: Python, curl, Node, Go and more

If you're building a document pipeline, a publishing platform, or any workflow where PDFs need to become EPUBs at scale, doing it by hand is not an option. The toolkit.bot PDF-to-EPUB API is the same converter the browser tool uses, exposed as three plain HTTP calls — no SDK, no browser, just code. This page is the single API guide: the Python quick start first, then the same three calls in curl, Node.js, Go, Java, C#, PHP, Ruby, Rust and WordPress.

When You Need Programmatic PDF-to-EPUB Conversion

How the API Works: Upload, Poll, Download

Conversion is asynchronous. You upload a PDF and get a job back immediately; you poll the job until it completes; then you download the EPUB. Every language example on this page performs exactly these three requests.

  1. Upload: POST https://toolkit.bot/convert as multipart/form-data with a file field containing the PDF and a notify_email field. Returns 202 Accepted with a JSON body containing id, status, status_url and download_url. Both URLs are relative to https://toolkit.bot and already carry the job's access token.
  2. Poll: GET status_url every few seconds. status moves through queuedrunningcompleted (or failed). While queued the body also reports queue_ahead; once completed it reports page_count and duration_ms.
  3. Download: GET download_url. The body is the EPUB3 file (Content-Type: application/epub+zip).

Why notify_email matters for scripts. Downloads are tied to an email address. In the browser that happens through sign-in; from a script, pass the address in the notify_email form field when you upload. The download link is then authorised for that job, and you also receive an email with the link when the conversion finishes — useful for long scanned PDFs where you would rather not keep polling. Without it, the download URL returns a sign-in page instead of the file.

API keys. Paid API access sends Authorization: Bearer <key> on the upload request, which lifts the free-tier quota. Without a key you are on the free tier described under limits below; the request shape is identical.

Python Quick Start

Install requests if you haven't already (pip install requests), then:

import os
import time
import requests

BASE = "https://toolkit.bot"
NOTIFY_EMAIL = os.environ["NOTIFY_EMAIL"]   # the address the download is tied to

def convert_pdf_to_epub(pdf_path, epub_path, session=requests):
    # 1. Upload -> 202 Accepted with a job record
    with open(pdf_path, "rb") as pdf_file:
        response = session.post(
            f"{BASE}/convert",
            files={"file": (os.path.basename(pdf_path), pdf_file, "application/pdf")},
            data={"notify_email": NOTIFY_EMAIL},
        )
    response.raise_for_status()
    job = response.json()
    print(f"Job {job['id']} is {job['status']}")

    # 2. Poll until completed
    while True:
        status = session.get(BASE + job["status_url"]).json()
        if status["status"] == "completed":
            break
        if status["status"] == "failed":
            raise RuntimeError(status.get("error", "Conversion failed"))
        time.sleep(3)

    # 3. Download the EPUB
    epub = session.get(BASE + job["download_url"])
    epub.raise_for_status()
    with open(epub_path, "wb") as epub_file:
        epub_file.write(epub.content)
    print(f"Saved {len(epub.content):,} bytes to {epub_path} "
          f"({status['page_count']} pages in {status['duration_ms']} ms)")

convert_pdf_to_epub("manuscript.pdf", "manuscript.epub")

That's it. epub.content is the raw bytes of a valid EPUB3 file.

Python: batch conversion

Use one requests.Session() for the whole run so the session cookie persists, submit every PDF first, then collect the results. A small thread pool keeps a few conversions in flight without flooding the queue:

from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

INPUT_DIR = Path("pdfs/")
OUTPUT_DIR = Path("epubs/")
OUTPUT_DIR.mkdir(exist_ok=True)

session = requests.Session()

def convert_one(pdf_path):
    out = OUTPUT_DIR / pdf_path.with_suffix(".epub").name
    try:
        convert_pdf_to_epub(pdf_path, out, session=session)
    except requests.HTTPError as exc:
        print(f"{pdf_path.name}: HTTP {exc.response.status_code} {exc.response.text}")

with ThreadPoolExecutor(max_workers=3) as pool:
    pool.map(convert_one, INPUT_DIR.glob("*.pdf"))

Python: retrying on server errors

Uploads are stateless until the 202 comes back, so retrying a failed upload is always safe. Do not retry a 400 (not a PDF), 402 (quota) or 413 (too large) — those will fail the same way again.

def upload_with_retry(session, pdf_path, retries=3):
    for attempt in range(retries):
        with open(pdf_path, "rb") as f:
            resp = session.post(
                f"{BASE}/convert",
                files={"file": (pdf_path.name, f, "application/pdf")},
                data={"notify_email": NOTIFY_EMAIL},
            )
        if resp.status_code == 202:
            return resp.json()
        if resp.status_code >= 500 and attempt < retries - 1:
            time.sleep(2 ** attempt)
            continue
        resp.raise_for_status()
    raise RuntimeError(f"Failed after {retries} attempts")

The Same Three Calls in Other Languages

Every example below does exactly what the Python code does: POST /convert, poll status_url until completed, GET download_url. Set NOTIFY_EMAIL in the environment first.

curl (bash, macOS, Linux, Windows WSL)

Windows 10 and 11 ship curl natively; the one-shot script needs only bash, curl and jq.

# 1. Upload (202 Accepted; save the job JSON)
curl -sS -X POST https://toolkit.bot/convert \
  -F "file=@document.pdf" \
  -F "notify_email=$NOTIFY_EMAIL" \
  -o job.json

# 2. Poll
curl -sS "https://toolkit.bot$(jq -r .status_url job.json)"

# 3. Download once status is "completed"
curl -sSL "https://toolkit.bot$(jq -r .download_url job.json)" -o document.epub

As one script that handles upload, poll and download without manual steps:

#!/usr/bin/env bash
set -euo pipefail
PDF="${1:?Usage: convert.sh file.pdf}"
BASE="https://toolkit.bot"

JOB=$(curl -sSf -X POST "$BASE/convert" -F "file=@$PDF" -F "notify_email=$NOTIFY_EMAIL")
STATUS_URL="$BASE$(echo "$JOB" | jq -r .status_url)"
DOWNLOAD_URL="$BASE$(echo "$JOB" | jq -r .download_url)"

while :; do
  STATUS=$(curl -sSf "$STATUS_URL" | jq -r .status)
  echo "  $STATUS"
  [ "$STATUS" = "completed" ] && break
  [ "$STATUS" = "failed" ] && { echo "Conversion failed" >&2; exit 1; }
  sleep 3
done

curl -sSfL "$DOWNLOAD_URL" -o "${PDF%.pdf}.epub"
echo "Done: ${PDF%.pdf}.epub"

Batch from the shell: for f in *.pdf; do ./convert.sh "$f"; done, or ls *.pdf | xargs -P 3 -n 1 ./convert.sh to keep three conversions in flight.

Node.js / TypeScript

Node 18+ has fetch, FormData and Blob built in, so no packages are needed.

import { readFile, writeFile } from 'node:fs/promises';
import { basename } from 'node:path';

const BASE = 'https://toolkit.bot';
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));

interface Job { id: string; status: 'queued' | 'running' | 'completed' | 'failed';
                status_url: string; download_url: string; error?: string; }

async function convertPdfToEpub(pdfPath: string): Promise<Buffer> {
  // 1. Upload
  const form = new FormData();
  form.append('file', new Blob([await readFile(pdfPath)], { type: 'application/pdf' }), basename(pdfPath));
  form.append('notify_email', process.env.NOTIFY_EMAIL!);
  const submit = await fetch(`${BASE}/convert`, { method: 'POST', body: form });
  if (submit.status !== 202) throw new Error(`Upload failed: ${submit.status} ${await submit.text()}`);
  const job = (await submit.json()) as Job;

  // 2. Poll
  let status: Job;
  do {
    await sleep(3000);
    status = (await (await fetch(BASE + job.status_url)).json()) as Job;
  } while (status.status === 'queued' || status.status === 'running');
  if (status.status === 'failed') throw new Error(status.error ?? 'Conversion failed');

  // 3. Download
  const epub = await fetch(BASE + job.download_url);
  if (!epub.ok) throw new Error(`Download failed: ${epub.status}`);
  return Buffer.from(await epub.arrayBuffer());
}

await writeFile('document.epub', await convertPdfToEpub('document.pdf'));

For a directory, wrap convertPdfToEpub in p-limit (or any small promise pool) with a concurrency of 3 and Promise.all the tasks. Exponential backoff on 5xx works the same way as in the Python example.

Go

Standard library only: net/http, mime/multipart, encoding/json. Go 1.16 or later.

const base = "https://toolkit.bot"

type job struct {
	ID          string `json:"id"`
	Status      string `json:"status"`
	StatusURL   string `json:"status_url"`
	DownloadURL string `json:"download_url"`
}

func convertPDFToEPUB(pdfPath, outPath string) error {
	// 1. Upload
	f, err := os.Open(pdfPath)
	if err != nil { return err }
	defer f.Close()
	var buf bytes.Buffer
	mw := multipart.NewWriter(&buf)
	part, _ := mw.CreateFormFile("file", filepath.Base(pdfPath))
	io.Copy(part, f)
	mw.WriteField("notify_email", os.Getenv("NOTIFY_EMAIL"))
	mw.Close()

	client := &http.Client{Timeout: 120 * time.Second}
	resp, err := client.Post(base+"/convert", mw.FormDataContentType(), &buf)
	if err != nil { return err }
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusAccepted {
		return fmt.Errorf("upload: HTTP %d", resp.StatusCode)
	}
	var j job
	json.NewDecoder(resp.Body).Decode(&j)

	// 2. Poll
	for j.Status != "completed" {
		time.Sleep(3 * time.Second)
		r, err := client.Get(base + j.StatusURL)
		if err != nil { return err }
		var s job
		json.NewDecoder(r.Body).Decode(&s)
		r.Body.Close()
		if s.Status == "failed" { return fmt.Errorf("conversion failed") }
		j.Status = s.Status
	}

	// 3. Download
	r, err := client.Get(base + j.DownloadURL)
	if err != nil { return err }
	defer r.Body.Close()
	out, err := os.Create(outPath)
	if err != nil { return err }
	defer out.Close()
	_, err = io.Copy(out, r.Body)
	return err
}

For batches, run convertPDFToEPUB in goroutines behind a buffered channel used as a semaphore (capacity 3) and wait with sync.WaitGroup. Pass a context.Context via http.NewRequestWithContext if you need an overall deadline across the three calls.

Java

Java 11+ java.net.http.HttpClient has no multipart builder, so the body is assembled by hand. If OkHttp is already on your classpath, MultipartBody.Builder().addFormDataPart("file", name, body).addFormDataPart("notify_email", email) does the same job.

static final String BASE = "https://toolkit.bot";
static final ObjectMapper JSON = new ObjectMapper();   // Jackson, for the job record

static void convertPdfToEpub(Path pdf, Path out) throws Exception {
    HttpClient client = HttpClient.newHttpClient();
    String boundary = "----JavaBoundary" + System.currentTimeMillis();
    String email = System.getenv("NOTIFY_EMAIL");

    // 1. Upload (multipart body built by hand)
    ByteArrayOutputStream body = new ByteArrayOutputStream();
    body.write(("--" + boundary + "\r\nContent-Disposition: form-data; name=\"file\"; filename=\""
        + pdf.getFileName() + "\"\r\nContent-Type: application/pdf\r\n\r\n").getBytes());
    body.write(Files.readAllBytes(pdf));
    body.write(("\r\n--" + boundary + "\r\nContent-Disposition: form-data; name=\"notify_email\"\r\n\r\n"
        + email + "\r\n--" + boundary + "--\r\n").getBytes());

    HttpResponse<String> upload = client.send(HttpRequest.newBuilder()
        .uri(URI.create(BASE + "/convert"))
        .header("Content-Type", "multipart/form-data; boundary=" + boundary)
        .POST(BodyPublishers.ofByteArray(body.toByteArray())).build(), BodyHandlers.ofString());
    if (upload.statusCode() != 202) throw new RuntimeException("Upload failed: " + upload.body());
    JsonNode job = JSON.readTree(upload.body());

    // 2. Poll
    String status;
    do {
        TimeUnit.SECONDS.sleep(3);
        HttpResponse<String> poll = client.send(HttpRequest.newBuilder()
            .uri(URI.create(BASE + job.get("status_url").asText())).GET().build(), BodyHandlers.ofString());
        status = JSON.readTree(poll.body()).get("status").asText();
    } while (status.equals("queued") || status.equals("running"));
    if (status.equals("failed")) throw new RuntimeException("Conversion failed");

    // 3. Download
    client.send(HttpRequest.newBuilder()
        .uri(URI.create(BASE + job.get("download_url").asText())).GET().build(), BodyHandlers.ofFile(out));
}

In Spring Boot, the same three calls fit a @Service using RestTemplate with a LinkedMultiValueMap body (file as a ByteArrayResource that overrides getFilename(), plus notify_email).

C# / .NET

HttpClient with MultipartFormDataContent; no NuGet packages. Works on .NET 6+ and, with Thread.Sleep in place of Task.Delay, on .NET Framework 4.5+.

const string Base = "https://toolkit.bot";
static readonly HttpClient Http = new();

static async Task ConvertAsync(string pdfPath, string epubPath)
{
    // 1. Upload
    await using var fs = File.OpenRead(pdfPath);
    using var form = new MultipartFormDataContent();
    var pdf = new StreamContent(fs);
    pdf.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
    form.Add(pdf, "file", Path.GetFileName(pdfPath));
    form.Add(new StringContent(Environment.GetEnvironmentVariable("NOTIFY_EMAIL")!), "notify_email");

    var upload = await Http.PostAsync($"{Base}/convert", form);
    if (upload.StatusCode != HttpStatusCode.Accepted)
        throw new Exception($"Upload failed: {(int)upload.StatusCode}");
    var job = await upload.Content.ReadFromJsonAsync<JsonElement>();
    var statusUrl = Base + job.GetProperty("status_url").GetString();
    var downloadUrl = Base + job.GetProperty("download_url").GetString();

    // 2. Poll
    string status;
    do
    {
        await Task.Delay(3000);
        var s = await Http.GetFromJsonAsync<JsonElement>(statusUrl);
        status = s.GetProperty("status").GetString()!;
    } while (status is "queued" or "running");
    if (status == "failed") throw new Exception("Conversion failed");

    // 3. Download
    await File.WriteAllBytesAsync(epubPath, await Http.GetByteArrayAsync(downloadUrl));
}

In ASP.NET Core, register a named client with AddHttpClient("toolkit", c => c.BaseAddress = new Uri("https://toolkit.bot/")), put the three calls in a scoped service, and return the bytes with File(epubBytes, "application/epub+zip", name). Pass a CancellationToken through every await for an overall timeout.

PHP

Plain cURL below; the Guzzle equivalent is $client->post('/convert', ['multipart' => [...]]) against base_uri https://toolkit.bot. In Laravel, put the three calls in a queued ShouldQueue job so a web request never blocks on the poll loop.

<?php
const BASE = 'https://toolkit.bot';

function httpGet(string $url): string {
    $c = curl_init($url);
    curl_setopt_array($c, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true]);
    $body = curl_exec($c);
    curl_close($c);
    return $body;
}

function convertPdfToEpub(string $pdfPath, string $epubPath): void {
    // 1. Upload
    $c = curl_init(BASE . '/convert');
    curl_setopt_array($c, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => [
            'file'         => new CURLFile($pdfPath, 'application/pdf', basename($pdfPath)),
            'notify_email' => getenv('NOTIFY_EMAIL'),
        ],
    ]);
    $response = curl_exec($c);
    $code = curl_getinfo($c, CURLINFO_HTTP_CODE);
    curl_close($c);
    if ($code !== 202) throw new RuntimeException("Upload failed: HTTP $code");
    $job = json_decode($response, true);

    // 2. Poll
    do {
        sleep(3);
        $status = json_decode(httpGet(BASE . $job['status_url']), true)['status'];
    } while ($status === 'queued' || $status === 'running');
    if ($status === 'failed') throw new RuntimeException('Conversion failed');

    // 3. Download
    file_put_contents($epubPath, httpGet(BASE . $job['download_url']));
}

convertPdfToEpub('/path/to/document.pdf', '/path/to/document.epub');

Ruby

Standard library net/http can send multipart with set_form. With Faraday, add faraday-multipart and post { file: Faraday::UploadIO.new(path, 'application/pdf'), notify_email: ... }. In Rails, wrap the three calls in an ActiveJob.

require 'net/http'
require 'json'

BASE = URI('https://toolkit.bot')

def convert_pdf_to_epub(pdf_path, epub_path)
  Net::HTTP.start(BASE.host, BASE.port, use_ssl: true) do |http|
    # 1. Upload
    req = Net::HTTP::Post.new('/convert')
    File.open(pdf_path, 'rb') do |f|
      req.set_form([['file', f, { filename: File.basename(pdf_path), content_type: 'application/pdf' }],
                    ['notify_email', ENV.fetch('NOTIFY_EMAIL')]], 'multipart/form-data')
      resp = http.request(req)
      raise "Upload failed: #{resp.code} #{resp.body}" unless resp.code == '202'
      @job = JSON.parse(resp.body)
    end

    # 2. Poll
    loop do
      sleep 3
      status = JSON.parse(http.get(@job['status_url']).body)['status']
      break if status == 'completed'
      raise 'Conversion failed' if status == 'failed'
    end

    # 3. Download
    File.binwrite(epub_path, http.get(@job['download_url']).body)
  end
end

convert_pdf_to_epub('document.pdf', 'document.epub')

Rust

reqwest with the multipart and json features, tokio for the runtime, serde for the job record. The blocking client (reqwest::blocking) is a drop-in swap for a CLI.

use reqwest::{Client, StatusCode, multipart};
use serde::Deserialize;
use std::{env, time::Duration};

const BASE: &str = "https://toolkit.bot";

#[derive(Deserialize)]
struct Job { status: String, status_url: String, download_url: String }

async fn convert_pdf_to_epub(pdf_path: &str, out_path: &str)
    -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::new();

    // 1. Upload
    let part = multipart::Part::bytes(tokio::fs::read(pdf_path).await?)
        .file_name(pdf_path.to_string()).mime_str("application/pdf")?;
    let form = multipart::Form::new()
        .part("file", part)
        .text("notify_email", env::var("NOTIFY_EMAIL")?);
    let resp = client.post(format!("{BASE}/convert")).multipart(form).send().await?;
    if resp.status() != StatusCode::ACCEPTED {
        return Err(format!("upload failed: {}", resp.status()).into());
    }
    let job: Job = resp.json().await?;

    // 2. Poll
    loop {
        tokio::time::sleep(Duration::from_secs(3)).await;
        let s: Job = client.get(format!("{BASE}{}", job.status_url)).send().await?.json().await?;
        match s.status.as_str() {
            "completed" => break,
            "failed" => return Err("conversion failed".into()),
            _ => continue,
        }
    }

    // 3. Download
    let epub = client.get(format!("{BASE}{}", job.download_url)).send().await?.bytes().await?;
    tokio::fs::write(out_path, &epub).await?;
    Ok(())
}

Wrap the poll loop in tokio::time::timeout for an overall deadline, and model errors with a thiserror enum (HTTP, server failed, timeout, I/O) once the prototype works.

WordPress

Three ways to get EPUBs onto a WordPress site, from no code to fully automated:

  1. Manual: convert the PDF with toolkit.bot, upload the EPUB to the Media Library, link to it. Fine for a few files a month.
  2. WooCommerce digital download: convert manually, then create a product marked Virtual + Downloadable and attach the EPUB. Customers get a download link after purchase; no plugin needed.
  3. Automated: hook wp_handle_upload so every PDF uploaded to the Media Library is submitted to the API, then finish the poll and download in a WP-Cron event so the upload request returns immediately:
<?php
// functions.php or a small plugin. Uses the PHP convertPdfToEpub() pieces above.
add_filter('wp_handle_upload', function ($upload) {
    if ($upload['type'] !== 'application/pdf') return $upload;

    $c = curl_init('https://toolkit.bot/convert');
    curl_setopt_array($c, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => [
            'file'         => new CURLFile($upload['file'], 'application/pdf', basename($upload['file'])),
            'notify_email' => get_option('admin_email'),
        ],
    ]);
    $job = json_decode(curl_exec($c), true);
    curl_close($c);

    if (!empty($job['status_url'])) {
        // Poll and download later, off the request path
        wp_schedule_single_event(time() + 15, 'toolkit_fetch_epub',
            [$job['status_url'], $job['download_url'], $upload['file']]);
    }
    return $upload;
});

add_action('toolkit_fetch_epub', function ($statusUrl, $downloadUrl, $pdfPath) {
    $status = json_decode(wp_remote_retrieve_body(wp_remote_get('https://toolkit.bot' . $statusUrl)), true);
    if (($status['status'] ?? '') !== 'completed') {
        if (($status['status'] ?? '') !== 'failed') {
            wp_schedule_single_event(time() + 15, 'toolkit_fetch_epub', [$statusUrl, $downloadUrl, $pdfPath]);
        }
        return;
    }
    $epub = wp_remote_retrieve_body(wp_remote_get('https://toolkit.bot' . $downloadUrl, ['timeout' => 60]));
    file_put_contents(preg_replace('/\.pdf$/i', '.epub', $pdfPath), $epub);
}, 10, 3);

If you later add an API key, keep it in wp-config.php (define('TOOLKIT_BOT_API_KEY', ...)) rather than in the database, and send it as Authorization: Bearer on the upload.

Handling the Response

CodeWhenWhat to do
202Upload acceptedRead status_url and download_url from the JSON body and start polling.
200Status or downloadStatus: JSON job record. Download: the EPUB bytes.
400Not a PDFThe file lacks a .pdf name or a PDF header. Do not retry.
402Free tier limit reachedBody includes remaining: 0. Wait for the monthly reset or see pricing.
404Unknown job or bad tokenUse the URLs from the 202 body verbatim; they carry the token.
409Download before completionKeep polling status_url.
410Download link expiredArtifacts are retained for a limited time. Convert again.
413File too large50 MB on the free tier, 500 MB on Pro; at most 1,000 pages.
503 / 5xxQueue unavailable or server errorRetry the upload with exponential backoff. Nothing is billed until a 202 comes back.

Batch Conversion Notes

Completion Notifications

There is no webhook. The notify_email address receives an email with the download link when the job completes, which covers the "tell me when it's done" case for long OCR jobs; for machine-to-machine flows, poll status_url. Because the download URL from the 202 body is stable, you can persist it (as the WordPress example does) and fetch the file from a separate process later.

Limits and Free Tier

FreePro
Conversions5 per monthUnlimited
Max file size50 MB500 MB
Max pages1,000
AuthenticationNone; notify_email on uploadSign-in, or Authorization: Bearer API key

No credit card is needed to start. For production volumes see toolkit.bot/pricing.

Integrating Into a CI/CD Pipeline

If you generate PDFs as a build artifact (from LaTeX, Pandoc, WeasyPrint), add a conversion step that produces EPUBs automatically. A GitHub Actions step using the Python script above:

- name: Convert PDF to EPUB
  env:
    NOTIFY_EMAIL: ${{ secrets.NOTIFY_EMAIL }}
  run: python scripts/convert_to_epub.py --input dist/output.pdf --output dist/output.epub

OpenAPI Spec

The full API schema is available at toolkit.bot/openapi.json. Import it into Postman, Insomnia, or any OpenAPI-compatible client to explore request/response shapes and generate client stubs for languages not covered here.

What the API Produces

Output is valid EPUB3 with UTF-8 encoded text, embedded images extracted from the PDF, EPUB Accessibility 1.1 metadata, and structural navigation from detected headings. The EPUB passes EPUBCheck validation for standard-conformant PDFs, and scanned PDFs go through the same OCR pipeline as the browser tool.

Try the API with your first five conversions free — no credit card required.

Get Started →

Related guides