> ## Documentation Index
> Fetch the complete documentation index at: https://markdown2pdf.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Markdown to PDF conversion, for AI agents

export const MarkdownToPDFPlayground = () => {
  const [markdown, setMarkdown] = useState('');
  const [invoice, setInvoice] = useState(null);
  const [qrSvg, setQrSvg] = useState(null);
  const [documentUrl, setDocumentUrl] = useState(null);
  const [isProcessing, setIsProcessing] = useState(false);
  const [progress, setProgress] = useState(0);
  const [statusMessage, setStatusMessage] = useState('');
  const [error, setError] = useState(null);
  const isCancelledRef = useRef(false);
  const pollTimeoutRef = useRef(null);
  const API_CONFIG = {
    URI: 'https://api.markdown2pdf.ai',
    PATH: '/markdown',
    DOCUMENT_NAME: 'demonstration.pdf',
    DOCUMENT_TITLE: 'Playground demonstration',
    POLL_INTERVAL: 3000
  };
  const STATUS_MESSAGES = {
    WAITING_PAYMENT: 'Waiting for payment...',
    PAYMENT_DETAILS: 'Obtaining payment details...',
    GENERATING: 'Generating your PDF',
    READY: 'Your PDF is ready!',
    ERROR: 'Something went wrong. Please try again.',
    PAYMENT_ERROR: 'Error with payment. Please refresh and try again.',
    GENERATION_ERROR: 'PDF generation failed.',
    DOWNLOAD_ERROR: 'Failed to retrieve final PDF link.',
    POLLING_ERROR: 'Document polling error.',
    DOWNLOAD_FAILED: 'Download failed. Please try again.',
    COPIED: 'Copied!',
    DOCUMENT_FINAL_URL: 'Document ready for retrieval.'
  };
  const resetState = (shouldCancel = false) => {
    if (shouldCancel) isCancelledRef.current = true;
    if (pollTimeoutRef.current) clearTimeout(pollTimeoutRef.current);
    setInvoice(null);
    setQrSvg(null);
    setDocumentUrl(null);
    setIsProcessing(false);
    setProgress(0);
    setStatusMessage('');
    setError(null);
  };
  const handleError = errorMessage => {
    setIsProcessing(false);
    setStatusMessage(errorMessage);
    setError(errorMessage);
  };
  const convert = async () => {
    isCancelledRef.current = false;
    resetState(false);
    setIsProcessing(true);
    const date = new Date();
    const formatted_date = new Intl.DateTimeFormat('en-GB', {
      day: 'numeric',
      month: 'long',
      year: 'numeric'
    }).format(date);
    const payload = {
      data: {
        text_body: markdown,
        meta: {
          title: API_CONFIG.DOCUMENT_TITLE,
          date: formatted_date
        }
      },
      options: {
        document_name: API_CONFIG.DOCUMENT_NAME
      }
    };
    try {
      const response = await fetch(`${API_CONFIG.URI}${API_CONFIG.PATH}`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(payload)
      });
      if (response.status === 402) {
        const offerData = await response.json();
        const offer = offerData.offers[0];
        offer.type = offer.type || 'one-off';
        const paymentDetailsRes = await fetch(offerData.payment_request_url, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({
            payment_context_token: offerData.payment_context_token,
            offer_id: offer.id,
            payment_method: offer.payment_methods
          })
        });
        if (!paymentDetailsRes.ok) throw new Error('Payment details request failed');
        const paymentDetails = await paymentDetailsRes.json();
        setInvoice(paymentDetails.payment_request.payment_request);
        setQrSvg(paymentDetails.payment_request.payment_qr_svg);
        setStatusMessage(STATUS_MESSAGES.WAITING_PAYMENT);
        setProgress(10);
        await pollPayment(payload);
        return;
      }
      if (!response.ok) throw new Error('Unexpected response');
      const {path} = await response.json();
      setStatusMessage(STATUS_MESSAGES.GENERATING);
      await pollDocument(path);
    } catch (err) {
      console.error(err);
      handleError(STATUS_MESSAGES.ERROR);
    }
  };
  const pollPayment = async payload => {
    if (isCancelledRef.current) {
      setIsProcessing(false);
      setStatusMessage('');
      setError(null);
      return;
    }
    try {
      const response = await fetch(`${API_CONFIG.URI}${API_CONFIG.PATH}`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(payload)
      });
      if (response.status === 402) {
        pollTimeoutRef.current = setTimeout(() => pollPayment(payload), API_CONFIG.POLL_INTERVAL);
        return;
      }
      if (!response.ok) throw new Error('Unexpected response');
      const {path} = await response.json();
      setProgress(30);
      setInvoice(null);
      setQrSvg(null);
      setStatusMessage(STATUS_MESSAGES.GENERATING);
      await pollDocument(`${API_CONFIG.URI}${path}`);
    } catch {
      handleError(STATUS_MESSAGES.PAYMENT_ERROR);
    }
  };
  const pollDocument = async documentStatusUrl => {
    if (isCancelledRef.current) {
      setIsProcessing(false);
      setStatusMessage('');
      setError(null);
      return;
    }
    const poll = async () => {
      if (isCancelledRef.current) {
        setIsProcessing(false);
        setStatusMessage('');
        setError(null);
        return;
      }
      try {
        const response = await fetch(documentStatusUrl);
        if (response.status !== 200) {
          handleError(STATUS_MESSAGES.GENERATION_ERROR);
          return;
        }
        const {status: documentStatus, path} = await response.json();
        if (documentStatus !== "Done") {
          setProgress(80);
          pollTimeoutRef.current = setTimeout(poll, API_CONFIG.POLL_INTERVAL);
          return;
        }
        const finalResponse = await fetch(`${API_CONFIG.URI}${path}`);
        if (!finalResponse.ok) {
          handleError(STATUS_MESSAGES.DOWNLOAD_ERROR);
          return;
        }
        const {url} = await finalResponse.json();
        setDocumentUrl(url);
        setIsProcessing(false);
        setProgress(100);
        setStatusMessage(STATUS_MESSAGES.READY);
      } catch (err) {
        console.error(err);
        handleError(STATUS_MESSAGES.POLLING_ERROR);
      }
    };
    await poll();
  };
  const downloadDocument = async () => {
    try {
      const fileRes = await fetch(documentUrl);
      const blob = await fileRes.blob();
      const objectUrl = URL.createObjectURL(blob);
      const link = document.createElement("a");
      link.href = objectUrl;
      link.download = API_CONFIG.DOCUMENT_NAME;
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
      URL.revokeObjectURL(objectUrl);
    } catch (err) {
      console.error(err);
      handleError(STATUS_MESSAGES.DOWNLOAD_FAILED);
    }
  };
  const copyInvoice = () => {
    navigator.clipboard.writeText(invoice);
    setStatusMessage(STATUS_MESSAGES.COPIED);
    setTimeout(() => setStatusMessage(STATUS_MESSAGES.WAITING_PAYMENT), 1000);
  };
  const showPaymentUI = invoice && qrSvg && !documentUrl;
  const showDownloadUI = documentUrl;
  const showMarkdown = !showPaymentUI;
  return <div className="w-ful">
      <h2 className="text-xxl font-bold text-black dark:text-white">Playground</h2>
      <div className="border border-zinc-200 dark:border-zinc-800 rounded-2xl p-6 space-y-4">
        <h5 className="m-0 font-bold text-black dark:text-white">Try it out here</h5>
        <p className="prose m-0 font-normal text-sm leading-6 text-gray-600 dark:text-gray-400">
          Copy and paste your markdown and then press the button to receive a Lightning payment invoice, allowing you to generate a PDF using any Bitcoin wallet supporting Lightning payments.
        </p>

          {error && <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-2xl p-4 text-sm text-red-600 dark:text-red-400">
              {error}
            </div>}

          {showMarkdown && <div className="h-[250px] flex flex-col justify-center items-center mb-4">
              <textarea className="w-full h-[250px] font-mono text-sm p-4 rounded-2xl border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:outline-none focus:border-indigo-600 focus:ring-1 focus:ring-indigo-200 dark:focus:border-indigo-400 dark:focus:ring-indigo-900 transition resize-none" value={markdown} onChange={e => setMarkdown(e.target.value)} placeholder="Paste markdown here." disabled={isProcessing} />
            </div>}

          {showPaymentUI && <div className="h-[250px] flex flex-col justify-center bg-zinc-50 dark:bg-zinc-900/50 rounded-2xl p-0 border border-zinc-200 dark:border-zinc-800 items-center mb-4">
              <div className="flex flex-row items-center justify-center w-full h-full gap-6 px-4">
                <div className="flex flex-col items-center justify-center hidden sm:block">
                  <img src={qrSvg} alt="Lightning QR code" className="w-[190px] h-[190px] bg-white border border-zinc-100 dark:border-zinc-800" />
                </div>
                <div className="flex flex-col justify-center flex-1 h-full">
                  <div className="flex items-center w-full font-mono">
                    <span className="flex-1 break-all pr-2 leading-tight select-all text-[13px]">{invoice}</span>
                    <button onClick={copyInvoice} className="p-1.5 rounded-xl hover:bg-indigo-100 dark:hover:bg-indigo-900 transition flex items-center" aria-label="Copy invoice">
                      <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" className="text-indigo-700 dark:text-indigo-600">
                        <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
                        <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
                      </svg>
                    </button>
                  </div>
                </div>
              </div>
            </div>}

          {showDownloadUI ? <div className="w-full flex flex-col gap-2 mt-4">
              <button onClick={downloadDocument} className="w-full px-4 py-3 h-12 rounded-2xl font-medium
                  bg-[#3F51B5] dark:bg-[#283593]
                  text-white shadow-md hover:shadow-lg
                  transition-all duration-200 ease-in-out
                  hover:bg-[#304FFE] dark:hover:bg-[#304FFE]
                  active:scale-[0.98] active:shadow-sm active:bg-[#283593] dark:active:bg-[#1A237E]
                  disabled:bg-zinc-200 dark:disabled:bg-zinc-800
                  disabled:text-zinc-400 dark:disabled:text-zinc-500
                  disabled:opacity-70 disabled:shadow-none disabled:cursor-not-allowed 
                  flex items-center justify-center gap-2 text-sm
                  group focus:outline-none focus:ring-2 focus:ring-[#3F51B5] focus:ring-offset-2 dark:focus:ring-offset-zinc-900">
                <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0">
                  <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
                  <polyline points="7 10 12 15 17 10" />
                  <line x1="12" y1="15" x2="12" y2="3" />
                </svg>
                Download PDF
              </button>
              <button onClick={() => {
    resetState();
    setMarkdown('');
  }} className="w-full px-4 py-3 h-12 rounded-2xl bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-200 text-sm font-medium hover:bg-zinc-200 dark:hover:bg-zinc-700 transition">
                Convert Another
              </button>
            </div> : <button onClick={isProcessing ? () => resetState(true) : convert} disabled={!markdown} className="w-full px-4 py-3 h-12 rounded-2xl font-medium
                relative overflow-hidden
                bg-[#3F51B5] dark:bg-[#283593]
                text-white shadow-md hover:shadow-lg
                transition-all duration-200 ease-in-out
                hover:bg-[#304FFE] dark:hover:bg-[#304FFE]
                active:scale-[0.98] active:shadow-sm active:bg-[#283593] dark:active:bg-[#1A237E]
                disabled:bg-zinc-200 dark:disabled:bg-zinc-800
                disabled:text-zinc-400 dark:disabled:text-zinc-500
                disabled:opacity-70 disabled:shadow-none disabled:cursor-not-allowed 
                flex items-center justify-center gap-2 text-sm
                group focus:outline-none focus:ring-2 focus:ring-[#3F51B5] focus:ring-offset-2 dark:focus:ring-offset-zinc-900">
              {isProcessing && !error && <div className="absolute inset-0 bg-gradient-to-r from-[#F48FB1] to-[#3F51B5] 
                    dark:from-[#C06C98] dark:to-[#283593] 
                    transition-all duration-300 ease-out rounded-2xl
                    group-hover:opacity-0 pointer-events-none" style={{
    width: `${progress}%`
  }} />}
              <div className="relative z-10 flex items-center justify-center gap-2">
                {isProcessing ? <>
                    <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
                    <span className="group-hover:hidden transition-opacity duration-200">{statusMessage}</span>
                    <span className="hidden group-hover:inline transition-opacity duration-200">Cancel</span>
                  </> : <>
                    <span className="group-hover:translate-x-0.5 transition-transform duration-200">Convert to PDF</span>
                    <svg className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-all duration-200 transform group-hover:translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
                    </svg>
                  </>}
              </div>
            </button>}
      </div>
    </div>;
};

<img src="https://mintcdn.com/serendipityai/MPEqWBFcxH09rjQB/images/markdown2pdf-hero.png?fit=max&auto=format&n=MPEqWBFcxH09rjQB&q=85&s=9a94d3b79453dec71f02f8d9da4e8ac8" alt="Markdown to PDF logo" width="1024" height="500" data-path="images/markdown2pdf-hero.png" />

### Agents speak Markdown. Humans prefer PDF. We bridge the gap for the final stage of your agentic workflow. No sign-ups, no credit cards — just sats (Lightning / L402) or USDC (Solana / X402) for bytes.

>

## What is this?

<CardGroup cols={2}>
  <Card title="Agents love Markdown — humans don’t" icon="robot">
    Markdown is easy for AI agents to work with, but not so great for humans. We help agents transform their output into beautiful PDFs for human consumption.
  </Card>

  <Card title="Built for agents, not end-users" icon="code">
    L402, X402 and MCP native API - pay per call with sats on Lightning or USDC on Solana, no OAuth, API keys or rate-limits. Send markdown, get back a PDF. No subscriptions or hidden tiers.
  </Card>
</CardGroup>

## Example output

Here's the output of a markdown file converted to PDF format, showing cover page, table of contents and table support. Our engine is powered by LaTeX rather than HTML to PDF conversion as many other libraries and services use, which results in a much higher quality, print-ready output.

<img src="https://mintcdn.com/serendipityai/MPEqWBFcxH09rjQB/images/examples.png?fit=max&auto=format&n=MPEqWBFcxH09rjQB&q=85&s=c5f59219aeffff81d848558980331c9b" alt="Markdown to PDF examples" width="3000" height="2000" data-path="images/examples.png" />

Below are a few downloadable examples you can try out:

<Columns cols={3}>
  <Card title="Markdown syntax guide" img="https://mintcdn.com/serendipityai/MPEqWBFcxH09rjQB/images/example-markdown.png?fit=max&auto=format&n=MPEqWBFcxH09rjQB&q=85&s=8dc5a2ad6338471ae3d54c5134ca1920" href="https://assets.markdown2pdf.ai/public/markdown.pdf" width="2224" height="3166" data-path="images/example-markdown.png">
    A document that shows the markdown supported.
  </Card>

  <Card title="Research report" img="https://mintcdn.com/serendipityai/MPEqWBFcxH09rjQB/images/example-research.png?fit=max&auto=format&n=MPEqWBFcxH09rjQB&q=85&s=b035202167c09f42bd6b0b275ad5cdac" href="https://assets.markdown2pdf.ai/public/report.pdf" width="2224" height="3166" data-path="images/example-research.png">
    An example research report with comparison tables.
  </Card>

  <Card title="Book" img="https://mintcdn.com/serendipityai/MPEqWBFcxH09rjQB/images/example-book.png?fit=max&auto=format&n=MPEqWBFcxH09rjQB&q=85&s=8f4d585d7dd32760d81f4d979a179ef0" href="https://assets.markdown2pdf.ai/public/book.pdf" width="2224" height="3166" data-path="images/example-book.png">
    A book with a cover page, table of contents and chapters.
  </Card>
</Columns>

<MarkdownToPDFPlayground />

## Pricing

<CardGroup cols={2}>
  <Card title="5 sats per PDF" icon="bitcoin">
    Pay in Bitcoin on the Lightning Network via [L402](/l402). Roughly \$0.01 per PDF — check [here](https://www.kraken.com/learn/satoshi-to-usd-converter) for the latest exchange rate.
  </Card>

  <Card title="$0.01 USDC per PDF" icon="coins">
    Pay in USDC on Solana via [X402](/x402). Dollar-denominated, on-chain settlement in under a second.
  </Card>
</CardGroup>

## Quick start

<Tabs>
  <Tab title="Python">
    ```bash theme={null}
    pip install markdown2pdf-python
    ```

    ```python theme={null}
    from markdown2pdf import MarkdownPDF

    def pay(offer):
        print("⚡ Lightning payment required")
        print(f"Amount: {offer['amount']} {offer['currency']}")
        print(f"Description: {offer['description']}")
        print(f"Invoice: {offer['payment_request']}")
        input("Press Enter once paid...")

    client = MarkdownPDF(on_payment_request=pay)
    path = client.convert(markdown="# Hello from Python", title="My document title", download_path="output.pdf")
    print("Saved PDF to:", path)
    ```
  </Tab>

  <Tab title="Typescript">
    ```bash theme={null}
    npm install @serendipityai/markdown2pdf-typescript
    ```

    ```typescript theme={null}
    import { convertMarkdownToPdf } from "@serendipityai/markdown2pdf-typescript";
    import type { OfferDetails } from "@serendipityai/markdown2pdf-typescript";

    async function pay(offer: OfferDetails) {
      console.log("⚡ Lightning payment required");
      console.log(`Amount: ${offer.amount} ${offer.currency}`);
      console.log(`Description: ${offer.description}`);
      console.log(`Invoice: ${offer.payment_request}`);
      await new Promise<void>(resolve => { process.stdin.once("data", () => { resolve(); }); });
    }

    async function main() {
      const result = await convertMarkdownToPdf("# Hello from Typescript", {
        title: "My document title",
        downloadPath: "output.pdf",
        onPaymentRequest: pay
      });
      console.log("Saved PDF to:", result);
    }

    main().catch(console.error); 
    ```
  </Tab>

  <Tab title="MCP">
    You can drop the below configuration in to your Claude MCP config to add PDF conversion to your chat workflow.

    ```bash theme={null}
    "mcpServers": {
      "markdown2pdf": {
        "command":"npx",
        "args": ["@serendipityai/markdown2pdf-mcp"],
        "cwd": "~"
      }
    }
    ```
  </Tab>

  <Tab title="X402 (Python)">
    ```bash theme={null}
    pip install "x402[httpx]" solders
    ```

    ```python theme={null}
    import os, asyncio
    from solders.keypair import Keypair
    from x402 import x402Client, prefer_network
    from x402.http.clients.httpx import x402HttpxClient
    from x402.mechanisms.svm import KeypairSigner
    from x402.mechanisms.svm.exact import ExactSvmScheme

    async def main():
        keypair = Keypair.from_base58_string(os.environ["SOLANA_SIGNER_KEY"])
        signer = KeypairSigner(keypair)
        NETWORK = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"
        x402 = x402Client()
        x402.register(NETWORK, ExactSvmScheme(signer=signer))
        x402.register_policy(prefer_network(NETWORK))
        async with x402HttpxClient(x402, timeout=120.0) as client:
            resp = await client.post(
                "https://api.markdown2pdf.ai/markdown",
                json={
                    "data": {"text_body": "# Hello from X402"},
                    "options": {"document_name": "hello.pdf"},
                },
            )
            resp.raise_for_status()
            print("Paid tx proof:", resp.headers.get("payment-response"))
            print("Poll:", resp.json()["path"])

    asyncio.run(main())
    ```

    The `x402HttpxClient` handles the 402 → sign → retry loop for you. See the
    [X402 page](/x402) for details and manual-flow variants.
  </Tab>
</Tabs>

If your agent needs help making payments, try [Albyhub](https://albyhub.com), [lnbits](https://lnbits.com) or [fewsats.com](https://fewsats.com) for Lightning; for X402, use any Solana wallet that holds USDC. We provide examples of how to use these services to automatically pay in our SDKs.
