← toolkit.bot

PDF to EPUB Conversion in Rust — toolkit.bot API Integration

June 12, 2026  ·  8 min read

This guide shows how to call the toolkit.bot REST API from Rust to convert PDF files to EPUB. The implementation uses reqwest for HTTP and tokio for async runtime — both standard choices in the Rust ecosystem.

Dependencies

Add to Cargo.toml:

[dependencies]
reqwest = { version = "0.12", features = ["multipart", "json"] }
tokio  = { version = "1",    features = ["full"] }
serde  = { version = "1",    features = ["derive"] }
serde_json = "1"

Complete async example

use reqwest::{Client, multipart};
use serde::Deserialize;
use std::{env, path::Path, time::Duration};
use tokio::{fs, time};

#[derive(Deserialize, Debug)]
struct JobResponse {
    job_id:       Option<String>,
    status:       Option<String>,
    download_url: Option<String>,
}

const API_BASE: &str = "https://toolkit.bot/api/v1";

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let api_key  = env::var("TOOLKIT_API_KEY")?;
    let pdf_path = env::args().nth(1).expect("Usage: converter input.pdf");
    let out_path = pdf_path.replace(".pdf", ".epub");

    convert_pdf_to_epub(&api_key, &pdf_path, &out_path).await?;
    println!("Saved: {out_path}");
    Ok(())
}

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

    // --- Upload ---
    let file_bytes = fs::read(pdf_path).await?;
    let filename   = Path::new(pdf_path)
        .file_name().unwrap().to_string_lossy().into_owned();

    let part = multipart::Part::bytes(file_bytes)
        .file_name(filename)
        .mime_str("application/pdf")?;
    let form = multipart::Form::new().part("file", part);

    let upload_resp: JobResponse = client
        .post(format!("{API_BASE}/jobs"))
        .bearer_auth(api_key)
        .multipart(form)
        .send().await?
        .json().await?;

    let job_id = upload_resp.job_id.expect("Missing job_id in response");
    println!("Job ID: {job_id}");

    // --- Poll ---
    let download_url = loop {
        time::sleep(Duration::from_secs(4)).await;
        let status: JobResponse = client
            .get(format!("{API_BASE}/jobs/{job_id}"))
            .bearer_auth(api_key)
            .send().await?
            .json().await?;

        println!("Status: {:?}", status.status);
        match status.status.as_deref() {
            Some("done")   => break status.download_url.expect("Missing download_url"),
            Some("failed") => return Err("Conversion failed".into()),
            _              => continue,
        }
    };

    // --- Download ---
    let epub_bytes = client
        .get(&download_url)
        .bearer_auth(api_key)
        .send().await?
        .bytes().await?;

    fs::write(out_path, &epub_bytes).await?;
    Ok(())
}

Build and run:

cargo build --release
TOOLKIT_API_KEY=tk_xxx ./target/release/converter document.pdf

Synchronous variant

If you prefer blocking I/O (e.g., in a CLI tool where async adds complexity), use reqwest's blocking client:

[dependencies]
reqwest = { version = "0.12", features = ["multipart", "json", "blocking"] }
serde       = { version = "1", features = ["derive"] }
serde_json  = "1"
use reqwest::blocking::{Client, multipart};

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

    let part = multipart::Part::bytes(file)
        .file_name("document.pdf")
        .mime_str("application/pdf")?;
    let form = multipart::Form::new().part("file", part);

    let upload: serde_json::Value = client
        .post("https://toolkit.bot/api/v1/jobs")
        .bearer_auth(api_key)
        .multipart(form)
        .send()?.json()?;

    let job_id = upload["job_id"].as_str().unwrap().to_string();

    loop {
        std::thread::sleep(std::time::Duration::from_secs(4));
        let status: serde_json::Value = client
            .get(format!("https://toolkit.bot/api/v1/jobs/{job_id}"))
            .bearer_auth(api_key)
            .send()?.json()?;

        match status["status"].as_str() {
            Some("done") => {
                let url = status["download_url"].as_str().unwrap();
                let epub = client.get(url).bearer_auth(api_key).send()?.bytes()?;
                std::fs::write(out_path, &epub)?;
                return Ok(());
            }
            Some("failed") => return Err("failed".into()),
            _ => {}
        }
    }
}

Error handling

For production use, replace Box<dyn std::error::Error> with a typed error enum using thiserror:

[dependencies]
thiserror = "1"
#[derive(thiserror::Error, Debug)]
enum ConversionError {
    #[error("HTTP error: {0}")]
    Http(#[from] reqwest::Error),
    #[error("Conversion failed on server")]
    ServerFailed,
    #[error("Timed out waiting for job to complete")]
    Timeout,
    #[error("Environment variable not set: {0}")]
    Env(#[from] std::env::VarError),
    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),
}

FAQ

Which async runtime should I use?

Tokio is the standard choice and what reqwest defaults to. If your project uses async-std, feature-flag reqwest with default-features = false, features = ["async-std-runtime", ...].

How do I add a timeout to the polling loop?

Use tokio::time::timeout around the poll loop:

let download_url = tokio::time::timeout(
    Duration::from_secs(300),
    poll_for_completion(&client, api_key, &job_id),
).await??;

Is there a Rust crate for toolkit.bot?

Not yet — the REST API is simple enough that the implementations above are production-ready. Consider wrapping them in your own crate if you use the API across multiple projects.

Get your free API key
Sign up at toolkit.bot/api — free tier, no credit card required.