How to Extract Text from Brazilian CVM PDFs for AI Agents

LLMs and RAG systems need text, not binary PDFs. CVM filings — DFP, ITR, FRE — are often scanned tables, multi-column layouts, and Portuguese financial notes that break naive pdftotext pipelines. If you want to extract text from CVM filings reliably for AI agents, you need page-level markdown with structure preserved.

apicvm enqueues async extraction jobs and delivers one callback per page with markdown content. This guide covers the full flow: list a document, start extraction, handle callbacks, and wire it into an agent or RAG pipeline.

The problem

Building on Brazilian regulatory PDFs hits predictable walls:

  • Layout complexity — financial tables span pages; simple text extraction loses structure.
  • File size — DFP and FRE documents can exceed hundreds of pages; loading entire PDFs into LLM context is impractical.
  • Pipeline fragility — scraping CVM portals and running local OCR breaks when formats change.

You need a service that returns page-by-page markdown with a stable API contract.

How apicvm extraction works

Extraction is asynchronous — there is no synchronous "give me all text" endpoint.

1. GET /v1/documents          → find document.id
2. POST /v1/document-text-extractions  → enqueue job (202)
3. Your callback URL          → receives one POST per page with markdown

Each successful callback includes:

{
  "status": "success",
  "document_id": "550e8400-e29b-41d4-a716-446655440000",
  "job_id": "42",
  "page": {
    "number": 1,
    "markdown": "# Demonstrações Financeiras\n\n..."
  },
  "current_page": 1,
  "total_pages": 120,
  "is_last_page": false
}

Progress is only available through callbacks — no HTTP job status endpoint exists in v1.

Step 1: Get a document ID

export APICVM_KEY='apicvm_...'
export APICVM_URL='https://apicvm.dev'

curl -H "Authorization: Bearer $APICVM_KEY" \
  "$APICVM_URL/v1/documents?ticker=PETR4&type=DFP&year=2024&perPage=5"

Copy the id UUID from the document you want to extract.

Step 2: Enqueue extraction

Your callback URL must be HTTPS in production (private IPs and localhost are blocked).

curl -X POST -H "Authorization: Bearer $APICVM_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "callback_url": "https://your-app.example.com/callbacks/apicvm",
    "document": {
      "id": "550e8400-e29b-41d4-a716-446655440000"
    }
  }' \
  "$APICVM_URL/v1/document-text-extractions"

Response (202):

{
  "id": "42",
  "status": "queued",
  "document_id": "550e8400-e29b-41d4-a716-446655440000"
}

If the document was previously extracted with all pages done, the worker may reuse cached OCR text.

Step 3: Handle callbacks

Your endpoint must be idempotent — the worker retries failed callbacks up to 3 times.

Minimal FastAPI handler:

from fastapi import FastAPI, Request
from collections import defaultdict

app = FastAPI()
pages: dict[str, dict[int, str]] = defaultdict(dict)


@app.post("/callbacks/apicvm")
async def apicvm_callback(request: Request):
    payload = await request.json()
    status = payload.get("status")

    if status == "error":
        print(f"Extraction error: {payload.get('error_message')}")
        return {"ok": True}

    if status == "success":
        doc_id = payload["document_id"]
        page_num = payload["page"]["number"]
        markdown = payload["page"]["markdown"]
        pages[doc_id][page_num] = markdown

        print(
            f"Doc {doc_id}: page {payload['current_page']}/{payload['total_pages']}"
        )

        if payload.get("is_last_page"):
            full_text = "\n\n".join(
                pages[doc_id][n] for n in sorted(pages[doc_id])
            )
            # Feed into vector store, agent memory, etc.
            print(f"Extraction complete — {len(pages[doc_id])} pages")
            del pages[doc_id]

    return {"ok": True}

Python: enqueue from a script

import os
import requests

BASE = os.environ["APICVM_URL"]
H = {
    "Authorization": f"Bearer {os.environ['APICVM_KEY']}",
    "Content-Type": "application/json",
}


def enqueue_extraction(document_id: str, callback_url: str) -> dict:
    r = requests.post(
        f"{BASE}/v1/document-text-extractions",
        headers=H,
        json={"callback_url": callback_url, "document": {"id": document_id}},
    )
    r.raise_for_status()
    return r.json()


# List → extract workflow
docs = requests.get(
    f"{BASE}/v1/documents",
    headers={"Authorization": H["Authorization"]},
    params={"ticker": "PETR4", "type": "DFP", "year": 2024, "perPage": 1},
).json()

doc_id = docs["data"][0]["id"]
job = enqueue_extraction(doc_id, "https://your-app.example.com/callbacks/apicvm")
print(f"Job {job['id']} queued for document {job['document_id']}")

Building a RAG pipeline

Typical architecture for Brazil filings AI agents:

apicvm list documents
       ↓
POST document-text-extractions
       ↓
callback handler receives pages
       ↓
chunk markdown (by heading or token limit)
       ↓
embed → vector store (Pinecone, pgvector, etc.)
       ↓
agent retrieves chunks + cites page number

Chunk by markdown headings (#, ##) to keep financial tables intact where possible. Store metadata: ticker, type, year, page.number, document_id.

For LLM discovery of apicvm capabilities, see /llms.txt.

Error callbacks

On failure:

{
  "status": "error",
  "document_id": "...",
  "job_id": "42",
  "error_message": "Description of the error"
}

Log and alert on these; re-enqueue if transient.

Current limitations

  • Async only — no synchronous extraction or polling endpoint in v1.
  • Callback required — you must expose a reachable HTTPS URL in production.
  • One document per job — specify document.id explicitly; ambiguous filters are rejected.
  • Portuguese source text — markdown reflects the original filing language.
  • Corpus coverage — extraction works on documents in the apicvm database with files in the bucket.
  • Rate limit — 1 document per API key per UTC day for POST /v1/document-text-extractions (each request counts, including cache hits). Other endpoints use the default per-minute limit.

Next steps

Ready to integrate?

Get an API key and start querying Brazilian CVM filings programmatically.