GuidesOctober 28, 20262 min read

Call the PaddleOCR API from Java and Spring Boot

Parse PDFs with hosted PaddleOCR-VL or GLM-OCR from a Spring Boot app: multipart upload with RestClient, typed JSON records, and async jobs for large files.

PaddleOCR ships Python pipelines, so Java teams usually end up running a Python sidecar or a GPU server next to their app. A hosted API avoids both. This guide calls hosted PaddleOCR-VL from Spring Boot over HTTPS and maps the response to Java records. The same code works for GLM-OCR by changing one string.

You need Spring Boot 3.2 or later (for RestClient) and an OpenParser API key from Studio. New accounts get $10 in credits, about 10,000 pages.

Configure the client

Put the key in an environment variable and build one RestClient for the API:

@Configuration
class OpenParserConfig {
 
    @Bean
    RestClient openParser(@Value("${OPENPARSER_API_KEY}") String apiKey) {
        return RestClient.builder()
                .baseUrl("https://api.openparser.dev")
                .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey)
                .build();
    }
}

Map the response

POST /parse returns an openparser@1 document. You rarely need all of it. These records keep the parts most apps use; Spring's default Jackson setup ignores the other fields.

record ParsedDocument(String text, String markdown, List<Page> pages, List<Element> elements) {}
 
record Page(int number, double width, double height) {}
 
record Element(String id, String kind, String role, String text, List<Location> locations) {}
 
record Location(@JsonProperty("page_number") int pageNumber, Box bbox) {}
 
record Box(double left, double top, double right, double bottom) {}

Send a PDF

The endpoint takes multipart/form-data with two parts: request, a JSON object naming the model, and file.

@Service
class OcrService {
 
    private final RestClient openParser;
 
    OcrService(RestClient openParser) {
        this.openParser = openParser;
    }
 
    ParsedDocument parse(Resource pdf, String ocrModel) {
        var body = new MultipartBodyBuilder();
        body.part("request", Map.of("ocr_model", ocrModel, "output_format", "openparser@1"),
                MediaType.APPLICATION_JSON);
        body.part("file", pdf);
 
        return openParser.post()
                .uri("/parse")
                .header("Idempotency-Key", UUID.randomUUID().toString())
                .contentType(MediaType.MULTIPART_FORM_DATA)
                .body(body.build())
                .retrieve()
                .body(ParsedDocument.class);
    }
}

Call it with ocrService.parse(new FileSystemResource("invoice.pdf"), "paddleocr-vl-1.6"). To try GLM-OCR on the same file, pass "glm-ocr". The records stay the same because every model returns the same format.

The API requires an Idempotency-Key on every parse request. If a request times out and you resend it with the same key, the API returns the original job instead of starting and billing a new one, so generate the key once per document and reuse it on retries.

Large PDFs: use async jobs

Synchronous parsing waits up to the server limit. For long documents, queue a job with POST /parse/async and poll GET /jobs/{id} until the status is succeeded:

record JobRef(String id, String status) {}
 
record Job(String id, String status, ParsedDocument result) {}
 
ParsedDocument parseLarge(Resource pdf, String ocrModel) throws InterruptedException {
    var body = new MultipartBodyBuilder();
    body.part("request", Map.of("ocr_model", ocrModel, "output_format", "openparser@1"),
            MediaType.APPLICATION_JSON);
    body.part("file", pdf);
 
    JobRef ref = openParser.post()
            .uri("/parse/async")
            .header("Idempotency-Key", UUID.randomUUID().toString())
            .contentType(MediaType.MULTIPART_FORM_DATA)
            .body(body.build())
            .retrieve()
            .body(JobRef.class);
 
    while (true) {
        Job job = openParser.get().uri("/jobs/{id}", ref.id()).retrieve().body(Job.class);
        switch (job.status()) {
            case "succeeded" -> { return job.result(); }
            case "failed", "partial", "indeterminate" ->
                    throw new IllegalStateException("OCR job ended as " + job.status());
            default -> Thread.sleep(2_000);
        }
    }
}

In a web request handler, run this on a background executor or a queue consumer so a 200-page PDF does not hold an HTTP thread.

Read the result

markdown is the quickest way to feed text into search or a language model. For structured work, walk elements: each has a kind (text, table, formula, figure), a role such as heading or paragraph, and locations with page coordinates you can use to highlight the source in a viewer.

doc.elements().stream()
        .filter(e -> "table".equals(e.kind()))
        .forEach(e -> log.info("table on page {}", e.locations().get(0).pageNumber()));

If you need fields instead of text, such as an invoice total or a contract date, call /extract with a JSON schema; each returned field cites the elements it came from. The API reference lists both endpoints, and the PaddleOCR API page covers pricing.