Extract Text From PDFs and Images With Amazon Textract and SDK v3

A stack of printed paper documents on a desk next to a laptop

Photo by ron dyar on Unsplash

To extract text with Textract in AWS SDK v3, send DetectDocumentTextCommand from @aws-sdk/client-textract for an image or single-page PDF up to 10 MB. For multi-page PDFs in S3, call StartDocumentTextDetectionCommand, wait for the job, then page through GetDocumentTextDetectionCommand with NextToken. Build lines from the LINE blocks each PAGE block lists.

This guide is for Node.js and TypeScript developers who need the text from scanned documents, receipts, contracts or screenshots, and don’t want to run OCR themselves. You’ll get a typed module that handles both the synchronous and asynchronous APIs, a parser that turns Textract’s block graph into pages of lines, the IAM policy, current per-page prices and the mistakes that cost the most time.

The samples were type-checked with strict tsc against @aws-sdk/client-textract 3.1142.0 in September 2026. The parser was run on the sample GetDocumentTextDetection response from the Textract developer guide, and the async flow on mocked responses with aws-sdk-client-mock: one IN_PROGRESS poll, then results split across two pages with a NextToken.

DetectDocumentText or StartDocumentTextDetection: which API do you need?

Textract has one synchronous and one asynchronous operation for plain text detection. The limits decide for you:

DetectDocumentText (sync) StartDocumentTextDetection + GetDocumentTextDetection (async)
Input Bytes in the request, or an S3 object S3 object only
Formats JPEG, PNG, PDF, TIFF JPEG, PNG, PDF, TIFF
Size 10 MB 10 MB for JPEG and PNG; 500 MB for PDF and TIFF
Pages 1 (PDF and TIFF limited to one page) Up to 3,000 pages
Result Blocks in the response A JobId; results kept for 7 days, read with NextToken paging (up to 1,000 blocks per call)

Password-protected PDFs and XFA-based PDFs aren’t supported, and the minimum text height is 15 pixels (about 8-point text at 150 DPI). Text detection covers English, French, German, Italian, Portuguese and Spanish; handwriting is supported in English only.

Prerequisites

How to extract text with Textract and AWS SDK v3, step by step

  1. Pick sync or asyncOne page and 10 MB or less: DetectDocumentText. Anything else: the async pair.
  2. Send the documentPass Bytes (a Uint8Array; the SDK handles base64) or an S3Object with Bucket and Name.
  3. For async, waitSubscribe to the SNS completion notification, or poll GetDocumentTextDetection with backoff until JobStatus leaves IN_PROGRESS.
  4. Read all result pagesLoop on NextToken and collect every block before parsing.
  5. Walk the blocksEach PAGE block has CHILD relationships pointing at LINE blocks, and each LINE points at its WORD blocks.

Example: a Textract module for images and PDFs

textract-text.ts

// textract-text.ts: extract text with Amazon Textract and AWS SDK v3.
// detectText / detectTextInS3: synchronous, single-page images or single-page PDFs up to 10 MB.
// extractTextFromS3: asynchronous, multi-page PDFs and TIFFs in S3 (up to 3,000 pages).
import { setTimeout as sleep } from "node:timers/promises";
import {
  TextractClient,
  DetectDocumentTextCommand,
  StartDocumentTextDetectionCommand,
  GetDocumentTextDetectionCommand,
  type Block,
} from "@aws-sdk/client-textract";

const textract = new TextractClient({ region: process.env.AWS_REGION ?? "us-east-1" });

export interface PageText {
  page: number;
  lines: { text: string; confidence: number }[];
}

/** Turn Textract blocks into pages of lines, in the order each PAGE block lists its children. */
export function blocksToPages(blocks: Block[]): PageText[] {
  const byId = new Map(blocks.filter((b) => b.Id).map((b) => [b.Id as string, b]));
  const pages: PageText[] = [];
  for (const page of blocks.filter((b) => b.BlockType === "PAGE")) {
    const childIds = (page.Relationships ?? []).filter((r) => r.Type === "CHILD").flatMap((r) => r.Ids ?? []);
    const lines = childIds
      .map((id) => byId.get(id))
      .filter((b): b is Block => b?.BlockType === "LINE")
      .map((b) => ({ text: b.Text ?? "", confidence: Math.round((b.Confidence ?? 0) * 10) / 10 }));
    pages.push({ page: page.Page ?? pages.length + 1, lines });
  }
  return pages.sort((a, b) => a.page - b.page);
}

/** Synchronous: bytes of a JPEG, PNG, single-page PDF or TIFF (10 MB maximum). */
export async function detectText(bytes: Uint8Array): Promise<PageText[]> {
  const res = await textract.send(new DetectDocumentTextCommand({ Document: { Bytes: bytes } }));
  return blocksToPages(res.Blocks ?? []);
}

/** Synchronous, from S3. The bucket must be in the same Region as the Textract client. */
export async function detectTextInS3(bucket: string, key: string): Promise<PageText[]> {
  const res = await textract.send(new DetectDocumentTextCommand({ Document: { S3Object: { Bucket: bucket, Name: key } } }));
  return blocksToPages(res.Blocks ?? []);
}

/** Asynchronous: start a job, wait for it, then read every page of results with NextToken. */
export async function extractTextFromS3(
  bucket: string,
  key: string,
  opts: { requestToken?: string; timeoutMs?: number } = {},
): Promise<{ pages: PageText[]; warnings: string[] }> {
  const { JobId } = await textract.send(
    new StartDocumentTextDetectionCommand({
      DocumentLocation: { S3Object: { Bucket: bucket, Name: key } },
      ClientRequestToken: opts.requestToken, // same token + same input returns the same JobId
    }),
  );
  if (!JobId) throw new Error("Textract returned no JobId");

  // Poll with backoff. For many documents, use the SNS NotificationChannel instead (see below).
  const deadline = Date.now() + (opts.timeoutMs ?? 10 * 60_000);
  let delay = 2_000;
  let first = await textract.send(new GetDocumentTextDetectionCommand({ JobId, MaxResults: 1000 }));
  while (first.JobStatus === "IN_PROGRESS") {
    if (Date.now() > deadline) throw new Error(`Textract job ${JobId} still running after timeout`);
    await sleep(delay);
    delay = Math.min(delay * 2, 30_000);
    first = await textract.send(new GetDocumentTextDetectionCommand({ JobId, MaxResults: 1000 }));
  }
  if (first.JobStatus === "FAILED") throw new Error(`Textract job ${JobId} failed: ${first.StatusMessage ?? "no message"}`);

  // SUCCEEDED or PARTIAL_SUCCESS: collect every block across result pages.
  const blocks: Block[] = [...(first.Blocks ?? [])];
  const warnings = (first.Warnings ?? []).map((w) => `${w.ErrorCode} on pages ${(w.Pages ?? []).join(", ")}`);
  let nextToken = first.NextToken;
  while (nextToken) {
    const page = await textract.send(new GetDocumentTextDetectionCommand({ JobId, MaxResults: 1000, NextToken: nextToken }));
    blocks.push(...(page.Blocks ?? []));
    nextToken = page.NextToken;
  }
  return { pages: blocksToPages(blocks), warnings };
}

The polling loop uses the promise version of setTimeout from node:timers/promises, described in the Node.js timers documentation. It starts at 2 seconds and doubles up to 30. ClientRequestToken makes the start call idempotent: the same token with the same input returns the same JobId for 7 days instead of starting (and billing) a second job.

A small command-line wrapper shows both paths:

extract.ts

// extract.ts: print the text of a local image or single-page PDF, or of a multi-page PDF in S3.
// Usage: npx tsx extract.ts ./receipt.png
//        npx tsx extract.ts s3://amzn-s3-demo-bucket/contracts/lease.pdf
import { readFile } from "node:fs/promises";
import { detectText, extractTextFromS3, type PageText } from "./textract-text.js";

const target = process.argv[2];
if (!target) {
  console.error("usage: npx tsx extract.ts <file | s3://bucket/key>");
  process.exit(1);
}

let pages: PageText[];
if (target.startsWith("s3://")) {
  const [bucket, ...rest] = target.slice(5).split("/");
  const result = await extractTextFromS3(bucket, rest.join("/"));
  for (const w of result.warnings) console.warn(`warning: ${w}`);
  pages = result.pages;
} else {
  pages = await detectText(await readFile(target));
}

for (const p of pages) {
  console.log(`--- page ${p.page} (${p.lines.length} lines)`);
  for (const line of p.lines) console.log(`${line.confidence < 80 ? "?" : " "} ${line.text}`);
}
Output

--- page 1 (3 lines)
  LEASE AGREEMENT
  Tenant: Jordan Lee
  Monthly rent: $2,150.00
--- page 2 (2 lines)
  Signed on 14 September 2026
? J. Lee

That run used mocked Textract responses for a two-page lease stored at s3://amzn-s3-demo-bucket/contracts/lease.pdf. The first poll returned IN_PROGRESS, the second returned page 1’s blocks with a NextToken, and the third returned page 2’s lines. Note that the PAGE block for page 2 arrived in the first result page while its lines arrived in the second, which is why the module collects everything before parsing. The ? marks a line under 80% confidence, here a signature.

How are Textract results structured?

Textract returns a flat array of Block objects linked by IDs. For text detection there are three types that matter:

  • PAGE: one per page, with a CHILD relationship listing the page’s lines.
  • LINE: a line of text with Text, Confidence (0 to 100), Geometry and a CHILD relationship to its words.
  • WORD: one word, with its own confidence and geometry. TextType tells printed from handwritten text.

Here’s blocksToPages run on the sample GetDocumentTextDetection response from the Textract developer guide, a photo of the words “Hello, world”:

parse-sample.ts

// parse-sample.ts: run blocksToPages on a saved GetDocumentTextDetection response. No AWS call is made.
import { readFile } from "node:fs/promises";
import type { Block } from "@aws-sdk/client-textract";
import { blocksToPages } from "./textract-text.js";

const response = JSON.parse(await readFile("sample-response.json", "utf8")) as { Blocks: Block[] };
const words = response.Blocks.filter((b) => b.BlockType === "WORD").length;
console.log(`${response.Blocks.length} blocks, ${words} WORD blocks`);
for (const page of blocksToPages(response.Blocks)) {
  console.log(`page ${page.page}:`);
  console.table(page.lines);
}
Output

10 blocks, 5 WORD blocks
page 1:
┌─────────┬──────────────┬────────────┐
│ (index) │ text         │ confidence │
├─────────┼──────────────┼────────────┤
│ 0       │ 'ellooworio' │ 53.3       │
│ 1       │ 'He llo,'    │ 89.2       │
│ 2       │ 'worlo'      │ 82.4       │
│ 3       │ 'world'      │ 88.5       │
└─────────┴──────────────┴────────────┘

Two lessons from a real response. First, OCR output needs checking: the sample reads “He llo,” at 89% and “ellooworio” at 53% for the same image. Use Confidence to route low-scoring lines to review. Second, reading order is the order the PAGE block lists its lines, which follows how Textract grouped the text, not necessarily how a human reads a multi-column page. For columns, sort lines by Geometry.BoundingBox (Left, then Top) within column bands, or use layout analysis in AnalyzeDocument.

Waiting for async jobs: polling or SNS notifications?

Polling is fine for a script or a handful of documents. At volume, the Textract guide recommends against it: Get operations are throttled if called too often, and one queue is easier to watch than many jobs. The alternative is NotificationChannel on the start call, with an SNS topic ARN and an IAM role Textract can use to publish to it. The topic must be in the same Region as the Textract endpoint. Textract publishes a JSON message with JobId, Status, API, JobTag and DocumentLocation; subscribe an SQS queue or a Lambda function, and call GetDocumentTextDetection when Status is SUCCEEDED. The guides to publishing SNS messages with SDK v3 and sending and receiving SQS messages in TypeScript cover the plumbing.

Handle PARTIAL_SUCCESS as well as SUCCEEDED: some pages were processed and Warnings lists the pages that weren’t. If you start many jobs at once, the start call raises LimitExceededException until running jobs drop below your quota; queue the start requests rather than retrying in a tight loop. The SDK’s standard retry mode already backs off on throttling errors, and configuring retries and timeouts in AWS SDK v3 shows how to tune it.

What does Textract text detection cost?

As of September 2026, the AWS Price List for Amazon Textract (published 11 September 2026) gives these us-east-1 prices. Sync and async text detection cost the same:

Feature First 1 million pages per month Over 1 million pages
Detect Document Text $1.50 per 1,000 pages $0.60 per 1,000 pages
AnalyzeDocument Tables $15 per 1,000 pages Tiered; see the price list
AnalyzeDocument Forms $50 per 1,000 pages Tiered; see the price list
AnalyzeDocument Queries $15 per 1,000 pages Tiered; see the price list

Worked example: 20,000 contracts a month averaging 12 pages is 240,000 pages. With text detection that’s 240,000 ÷ 1,000 × $1.50 = $360. The same pages with Forms would be 240 × $50 = $12,000, so only pay for analysis on the pages that need it. Storage for the input documents is billed separately by S3.

When do you need AnalyzeDocument instead?

DetectDocumentText returns lines and words. If you need structure, AnalyzeDocument (sync) and StartDocumentAnalysis (async) take FeatureTypes: TABLES returns cells with row and column indexes, FORMS returns key-value pairs such as “Tenant: Jordan Lee”, and QUERIES answers questions like “What is the monthly rent?” (up to 15 queries per page sync, 30 async). The block graph is the same idea with more block types, and the async flow is identical, so extractTextFromS3 needs only the command names changed. For invoices and receipts, AnalyzeExpense is purpose-built.

If you want to summarize or classify the extracted text, pass the lines to a model; invoking Amazon Bedrock models with the Converse API is the SDK v3 route for that.

Which IAM permissions does Textract need?

The caller needs the Textract actions and, for S3 input, read access to the object: Textract reads the document with the caller’s permissions, and the bucket must be in the same Region.

textract-text-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DetectText",
      "Effect": "Allow",
      "Action": [
        "textract:DetectDocumentText",
        "textract:StartDocumentTextDetection",
        "textract:GetDocumentTextDetection"
      ],
      "Resource": "*"
    },
    {
      "Sid": "ReadInputDocuments",
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::amzn-s3-demo-bucket/contracts/*"
    }
  ]
}

Add kms:Decrypt on the key if the documents use SSE-KMS. With OutputConfig, Textract writes results to your bucket, which needs s3:PutObject on the output prefix. For SNS notifications, the role in NotificationChannel needs sns:Publish on the topic and a trust policy for Textract. To check a policy against your own code, paste it into the IAM policy generator for TypeScript AWS SDK code, and see finding the IAM actions your SDK code needs for how commands map to actions.

Troubleshooting and common mistakes

  • A multi-page PDF is rejected by DetectDocumentText. The sync API accepts one page. Use the async pair. UnsupportedDocumentException means the format itself isn’t PNG, JPEG, PDF or TIFF.
  • InvalidS3ObjectException. Textract couldn’t read the object: wrong key, a bucket in another Region, missing s3:GetObject, or a KMS key the caller can’t use. Check the key exists with GetObject in AWS SDK v3 using the same role.
  • DocumentTooLargeException or BadDocumentException. Over the size limit, password protected, XFA-based, or corrupt. Re-save the PDF, or split it.
  • InvalidJobIdException. The JobId is wrong or older than 7 days. Results aren’t kept longer unless you set OutputConfig.
  • Lines missing from the last pages. The loop stopped before NextToken was empty.
  • Access denied. Work through troubleshooting AWS IAM access denied errors with the policy above.
  • Tests hitting AWS. Mock TextractClient; the guide to mocking AWS SDK v3 clients shows the setup used here.

Limits: what Textract text detection can’t do

  • No vertical text, and only six languages for text detection.
  • No reading-order guarantee for complex layouts.
  • No password-protected PDFs, and text must be at least 15 pixels tall.
  • Sync calls handle one page; async results expire after 7 days by default.
  • It returns text, not meaning. Validation of numbers, names and dates is your job.

Once text is extracted, many pipelines store it next to the source; tagging S3 objects with AWS SDK v3 is a simple way to mark documents as processed. Migrating a v2 Textract script? The free AWS SDK v2 to v3 converter drafts the change from new AWS.Textract() calls to commands.

Frequently asked questions

How do I extract text from a PDF with Textract in Node.js?

For a one-page PDF, send DetectDocumentTextCommand with the file bytes. For more pages, upload it to S3, call StartDocumentTextDetectionCommand, wait for the job, then read GetDocumentTextDetectionCommand results with NextToken.

What is the Textract page limit?

One page for synchronous operations. Asynchronous jobs accept PDF and TIFF files up to 500 MB and 3,000 pages.

How much does Amazon Textract cost per page?

As of September 2026, text detection in us-east-1 is $1.50 per 1,000 pages for the first million pages a month, then $0.60 per 1,000. Table, form and query analysis cost more.

Can Textract read handwriting?

Yes, in English. WORD blocks carry a TextType of printed or handwriting.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud