How to Extract Text from a Scanned PDF Using PDF.co in C#

Scanned PDF documents usually contain page images rather than selectable text. This tutorial shows how to upload a scanned PDF to PDF.co, process it with OCR, and save the extracted text using C#.

The workflow runs asynchronously so it can accommodate documents that require additional OCR processing time.

Prerequisites

You will need:

  • .NET 6 or later
  • A PDF.co account and API key
  • A scanned PDF document
  • The Newtonsoft.Json NuGet package

You can use this PDF.co scanned PDF sample. Download it and save it in the project folder as ScannedPDF.pdf.

Step 1: Create the Project

Create a new console application:

dotnet new console -n ExtractTextFromScannedPdf
cd ExtractTextFromScannedPdf

Install Newtonsoft.Json:

dotnet add package Newtonsoft.Json

Place ScannedPDF.pdf in the project directory.

Step 2: Configure Your PDF.co API Key

Copy your API key from the PDF.co dashboard and store it in the PDFCO_API_KEY environment variable.

On Windows PowerShell:

$env:PDFCO_API_KEY="YOUR_PDFCO_API_KEY"

On macOS or Linux:

export PDFCO_API_KEY="YOUR_PDFCO_API_KEY"

The application will read the key from this environment variable and send it in the x-api-key request header.

Step 3: Request a Presigned Upload URL

A local file must first be uploaded to PDF.co storage. The application requests a presigned URL from:

GET /v1/file/upload/get-presigned-url

Encode the filename separately with Uri.EscapeDataString() before adding it to the query string:

var fileName = Path.GetFileName(ScannedPdfLocalPath);
var encodedFileName = Uri.EscapeDataString(fileName);

var requestUri =
    $"/v1/file/upload/get-presigned-url" +
    $"?contenttype=application/octet-stream" +
    $"&name={encodedFileName}";

PDF.co returns:

  • presignedUrl: The URL used to upload the file.
  • url: The PDF.co URL representing the uploaded source file.

Step 4: Upload the Scanned PDF

Upload the local file to the returned presigned URL with an HTTP PUT request:

private static async Task UploadFileAsync(string presignedUrl)
{
    using var uploadClient = new HttpClient();
    await using var fileStream = File.OpenRead(ScannedPdfLocalPath);
    using var content = new StreamContent(fileStream);

    content.Headers.ContentType =
        new MediaTypeHeaderValue("application/octet-stream");

    using var response = await uploadClient.PutAsync(presignedUrl, content);
    response.EnsureSuccessStatusCode();
}

A separate HTTP client is used for the upload because the presigned URL already contains the authorization information required by the storage service.

Step 5: Start the PDF-to-Text Job

Send the uploaded file URL to the PDF.co PDF-to-Text endpoint:

POST /v1/pdf/convert/to/text

Configure the request to run asynchronously and enable OCR:

var parameters = new Dictionary<string, object>
{
    ["name"] = Path.GetFileName(ExtractedTextLocalPath),
    ["url"] = uploadedFileUrl,
    ["lang"] = "eng",
    ["async"] = true,
    ["profiles"] =
        "{ 'OCRMode': 'TextFromImagesAndVectorsAndRepairedFonts' }"
};

The lang parameter identifies the OCR language. Use another supported language code or a combination such as eng+deu when appropriate.

The selected OCR mode extracts text from scanned images while also handling text represented through fonts and vector elements. Additional OCR modes are listed in the PDF.co Profiles documentation.

The complete endpoint configuration is documented on the PDF-to-Text API page.

Step 6: Check the Background Job

The conversion response includes a jobId. Send it to the Job Check endpoint:

POST /v1/job/check

The job can return one of these statuses:

  • working: Processing is still underway.
  • success: Processing has completed.
  • failed: The job encountered an error.
  • aborted: Processing was stopped.

While the status is working, wait briefly and check again. Use Task.Delay() so the application does not block the executing thread.

When the status becomes success, the response contains the URL of the generated text file. See the Background Job Check documentation for the response details.

Step 7: Add the Complete C# Code

Replace the contents of Program.cs with the following:

using Newtonsoft.Json;
using System.Net.Http.Headers;
using System.Text;

namespace ExtractTextFromScannedPdf;

internal class Program
{
    private static readonly string ApiKey =
        Environment.GetEnvironmentVariable("PDFCO_API_KEY")
        ?? throw new InvalidOperationException(
            "Set the PDFCO_API_KEY environment variable before running the application.");

    private const string ScannedPdfLocalPath = "ScannedPDF.pdf";
    private const string ExtractedTextLocalPath = "Extracted.txt";

    private static readonly HttpClient PdfCoClient = new()
    {
        BaseAddress = new Uri("https://api.pdf.co")
    };

    private static async Task Main()
    {
        if (!File.Exists(ScannedPdfLocalPath))
        {
            throw new FileNotFoundException(
                $"The source file '{ScannedPdfLocalPath}' was not found.");
        }

        PdfCoClient.DefaultRequestHeaders.Add("x-api-key", ApiKey);

        Console.WriteLine("Requesting an upload URL...");
        var uploadInfo = await GetPresignedUrlAsync();

        if (string.IsNullOrWhiteSpace(uploadInfo.PresignedUrl) ||
            string.IsNullOrWhiteSpace(uploadInfo.Url))
        {
            throw new InvalidOperationException(
                "PDF.co did not return the required upload URLs.");
        }

        Console.WriteLine("Uploading the scanned PDF...");
        await UploadFileAsync(uploadInfo.PresignedUrl);

        Console.WriteLine("Starting the OCR conversion...");
        var conversionJob = await StartConversionAsync(uploadInfo.Url);

        if (string.IsNullOrWhiteSpace(conversionJob.JobId))
        {
            throw new InvalidOperationException(
                "PDF.co did not return a job ID.");
        }

        Console.WriteLine($"Job ID: {conversionJob.JobId}");
        var completedJob = await WaitForJobAsync(conversionJob.JobId);

        var resultUrl = completedJob.Url ?? conversionJob.Url;

        if (string.IsNullOrWhiteSpace(resultUrl))
        {
            throw new InvalidOperationException(
                "PDF.co did not return the extracted text URL.");
        }

        Console.WriteLine("Downloading the extracted text...");

        using var downloadClient = new HttpClient();
        var extractedText = await downloadClient.GetStringAsync(resultUrl);
        await File.WriteAllTextAsync(ExtractedTextLocalPath, extractedText);

        Console.WriteLine(
            $"Extraction completed. Text saved to '{ExtractedTextLocalPath}'.");
    }

    private static async Task<ApiResponse> GetPresignedUrlAsync()
    {
        var fileName = Path.GetFileName(ScannedPdfLocalPath);
        var encodedFileName = Uri.EscapeDataString(fileName);

        var requestUri =
            $"/v1/file/upload/get-presigned-url" +
            $"?contenttype=application/octet-stream" +
            $"&name={encodedFileName}";

        using var response = await PdfCoClient.GetAsync(requestUri);
        return await ReadApiResponseAsync(response);
    }

    private static async Task UploadFileAsync(string presignedUrl)
    {
        using var uploadClient = new HttpClient();
        await using var fileStream = File.OpenRead(ScannedPdfLocalPath);
        using var content = new StreamContent(fileStream);

        content.Headers.ContentType =
            new MediaTypeHeaderValue("application/octet-stream");

        using var response = await uploadClient.PutAsync(presignedUrl, content);
        response.EnsureSuccessStatusCode();
    }

    private static async Task<ApiResponse> StartConversionAsync(
        string uploadedFileUrl)
    {
        var parameters = new Dictionary<string, object>
        {
            ["name"] = Path.GetFileName(ExtractedTextLocalPath),
            ["url"] = uploadedFileUrl,
            ["lang"] = "eng",
            ["async"] = true,
            ["profiles"] =
                "{ 'OCRMode': 'TextFromImagesAndVectorsAndRepairedFonts' }"
        };

        using var content = CreateJsonContent(parameters);
        using var response = await PdfCoClient.PostAsync(
            "/v1/pdf/convert/to/text",
            content);

        return await ReadApiResponseAsync(response);
    }

    private static async Task<ApiResponse> CheckJobAsync(string jobId)
    {
        var parameters = new Dictionary<string, string>
        {
            ["jobid"] = jobId
        };

        using var content = CreateJsonContent(parameters);
        using var response = await PdfCoClient.PostAsync(
            "/v1/job/check",
            content);

        return await ReadApiResponseAsync(response);
    }

    private static async Task<ApiResponse> WaitForJobAsync(string jobId)
    {
        while (true)
        {
            var job = await CheckJobAsync(jobId);

            switch (job.Status?.ToLowerInvariant())
            {
                case "success":
                    Console.WriteLine("OCR processing completed.");
                    return job;

                case "working":
                    Console.WriteLine("OCR processing is still underway...");
                    await Task.Delay(TimeSpan.FromSeconds(5));
                    break;

                case "failed":
                case "aborted":
                    throw new InvalidOperationException(
                        $"PDF.co job {job.Status}: {job.Message}");

                default:
                    throw new InvalidOperationException(
                        $"Unexpected PDF.co job status: {job.Status ?? "unknown"}");
            }
        }
    }

    private static StringContent CreateJsonContent(object value)
    {
        var json = JsonConvert.SerializeObject(value);
        return new StringContent(json, Encoding.UTF8, "application/json");
    }

    private static async Task<ApiResponse> ReadApiResponseAsync(
        HttpResponseMessage response)
    {
        var json = await response.Content.ReadAsStringAsync();

        if (!response.IsSuccessStatusCode)
        {
            throw new HttpRequestException(
                $"PDF.co returned HTTP {(int)response.StatusCode}: {json}");
        }

        var result = JsonConvert.DeserializeObject<ApiResponse>(json)
            ?? throw new InvalidOperationException(
                "PDF.co returned an empty or invalid response.");

        if (result.Error)
        {
            throw new InvalidOperationException(
                $"PDF.co error: {result.Message ?? "Unknown error"}");
        }

        return result;
    }
}

internal sealed class ApiResponse
{
    [JsonProperty("error")]
    public bool Error { get; set; }

    [JsonProperty("message")]
    public string? Message { get; set; }

    [JsonProperty("presignedUrl")]
    public string? PresignedUrl { get; set; }

    [JsonProperty("url")]
    public string? Url { get; set; }

    [JsonProperty("jobId")]
    public string? JobId { get; set; }

    [JsonProperty("status")]
    public string? Status { get; set; }
}

Step 8: Run the Application

Run the project:

dotnet run

The application will:

  1. Request a presigned upload URL.
  2. Upload ScannedPDF.pdf.
  3. submit the document for OCR processing.
  4. Monitor the background job.
  5. Download the extracted text.
  6. Save it as Extracted.txt.

Open Extracted.txt to review the OCR result.

Troubleshooting

The source file cannot be found

Make sure ScannedPDF.pdf is located in the project directory from which dotnet run is executed.

The API key is missing

Verify that the PDFCO_API_KEY environment variable is set in the same terminal session used to run the project.

The extracted text is inaccurate

Confirm that lang matches the document language. You can also adjust OCR resolution or apply image-preprocessing filters through the profiles parameter.

The job remains in progress

Larger documents and high-resolution scans can take longer to process. The application will continue checking the job every five seconds until PDF.co reports a final status.

Conclusion

You have created a C# application that uploads a scanned PDF, extracts its text with PDF.co OCR, monitors asynchronous processing, and saves the result locally. The same workflow can be extended to process multiple documents, select specific pages, use multiple OCR languages, or send the extracted text to another application.

Related Tutorials

See Related Tutorials