---
title: Build a Receipt Scanner App with Ionic and Capacitor
description: Build a receipt scanner app with Ionic and Capacitor — scan receipts with the native scanner UI, tune the JPEG quality, read the total with OCR and list stored scans.
date:
  created: 2026-11-05
  updated: 2026-11-05
authors:
  - robingenz
categories:
  - Capacitor
  - Guides
  - Ionic Framework
  - SDKs
links:
  - Capacitor Document Scanner: sdks/capacitor/document-scanner.md
  - Capacitor ML Kit Text Recognition: sdks/capacitor/mlkit/text-recognition.md
faq: true
---

# Build a Receipt Scanner App with Ionic and Capacitor

A receipt scanner app needs three things: a camera flow that finds the edges of a crumpled receipt and flattens it, a place to keep the scans, and a way to read the total off the image. This guide builds an Ionic receipt scanner app as one Ionic Angular page on top of the [Capacitor Document Scanner plugin](../../sdks/capacitor/document-scanner.md), which opens the native scanner of Android and iOS. Storage uses `@capacitor/filesystem`, and the total comes from the free [Capacitor ML Kit Text Recognition plugin](../../sdks/capacitor/mlkit/text-recognition.md). You get a receipts list with thumbnails, dates and amounts, a scan button, and the `imageQuality` setting that decides how big each scan gets. The scanner plugin is part of [Capawesome Insiders](../../insiders/index.md).

<!-- more -->

<div class="capawesome-z29o10a">
  <a href="https://capawesome.io/" target="_blank">
    <img alt="Ship a fix in one command with Capawesome Cloud Live Updates, no store review" src="https://capawesome.io/assets/banners/cloud-ship-a-fix-in-one-command.png" />
  </a>
</div>

**Key takeaways:**

- The Capacitor Document Scanner plugin opens Google's ML Kit scanner on Android and VisionKit on iOS; both detect the edges, correct the perspective and return JPEG pages in the app's cache directory.
- `imageQuality` sets the JPEG quality of the returned pages from `0` to `100` (default `100`) and does not touch the PDF; receipts are text on paper, so test OCR at the value you pick.
- The plugin deletes its cache files when it loads on the next app launch, so copy each scan to `Directory.Data` in the same function that scanned it.
- `processImage(...)` of the ML Kit Text Recognition plugin returns the recognized lines; a regex over the lines that mention a total finds the amount, with the largest amount as fallback.
- `Filesystem.readdir(...)` lists the stored scans with `mtime` and `uri`, and `Capacitor.convertFileSrc(...)` turns each URI into an `<img>` source.

## What you will build

The receipts page lists every scanned receipt as an `ion-item` with a thumbnail, the scan date and the total, and a floating camera button starts a new scan. All plugin calls live in a `ReceiptService` provided in root, and the page is a standalone Angular component that renders the service's signals. The plugin calls are plain TypeScript, so the same service works in an Ionic React or Vue app with a different wrapper around it.

Each receipt is one JPEG in a `receipts` folder of the app's data directory plus one number, the total, kept in `@capacitor/preferences` under the file name:

```typescript
export interface Receipt {
  fileName: string;
  imageSrc: string;
  scannedAt: Date;
  total: number | null;
}
```

The guide starts after installation. To install the Capacitor Document Scanner plugin, please refer to the [Installation](../../sdks/capacitor/document-scanner.md/#installation) section in the plugin documentation. The [Text Recognition plugin](../../sdks/capacitor/mlkit/text-recognition.md#installation) has its own installation section. [Announcing the Capacitor Document Scanner Plugin](./announcing-the-capacitor-document-scanner-plugin.md) covers the scanner's full API, so this guide only explains the options a receipt flow uses.

## Platform setup

The scanner needs one key on iOS and nothing in the manifest on Android. Add `NSCameraUsageDescription` to `ios/App/App/Info.plist`, because VisionKit uses the camera and the plugin rejects `scanDocument(...)` with an explicit error when the key is missing:

```xml
<key>NSCameraUsageDescription</key>
<string>The app needs access to the camera to scan your receipts.</string>
```

On Android, Google's scanner activity requests the camera permission itself. The scanner is an on-demand Google Play services module that is downloaded the first time `scanDocument(...)` runs, so the first scan on a fresh device takes a moment longer and needs a network connection, and devices without Play services cannot scan at all. That is what [`isAvailable()`](../../sdks/capacitor/document-scanner.md#isavailable) checks on Android; on iOS it checks for a camera and a supported iOS version.

The Text Recognition plugin bundles one model per script with the app, and its documentation notes that each model adds several megabytes. Receipts in Latin-script countries need only the default `Script.Latin`, so leave the other scripts out.

## Scan a receipt

Scanning a receipt in Ionic is one call to [`scanDocument(...)`](../../sdks/capacitor/document-scanner.md#scandocument), which opens the native scanner and resolves with the paths of the scanned pages once the user confirms. The service's `scan()` method checks availability first and treats a canceled scan as a normal outcome:

```typescript
import { Injectable } from '@angular/core';
import { DocumentScanner, ScannerMode } from '@capawesome-team/capacitor-document-scanner';

@Injectable({ providedIn: 'root' })
export class ReceiptService {
  async scan(): Promise<string | null> {
    const { available } = await DocumentScanner.isAvailable();
    if (!available) {
      return null;
    }
    try {
      const { scannedImages } = await DocumentScanner.scanDocument({
        pageLimit: 1,
        imageQuality: 75,
        androidScannerMode: ScannerMode.Full,
        androidGalleryImportAllowed: true,
      });
      return scannedImages[0] ?? null;
    } catch (error) {
      if ((error as { code?: string }).code === 'SCAN_CANCELED') {
        return null;
      }
      throw error;
    }
  }
}
```

Three options shape the receipt flow. `pageLimit: 1` makes a receipt one page; on Android the scanner stops after that page, on iOS VisionKit has no page limit and the plugin truncates the result to one page after scanning. `androidScannerMode: ScannerMode.Full` is the default and adds ML-based cleaning that removes stains, fingers and shadows on top of crop, rotate and filters, which matters for a receipt pulled out of a wallet; iOS applies its cleaning automatically. `androidGalleryImportAllowed: true` lets the user pick a receipt that already exists as a photo or screenshot, for example a forwarded invoice. The option is Android only, since VisionKit offers no import.

The scanner UI itself belongs to Google and Apple. The plugin draws nothing and cannot change labels, buttons or the flow, so the two platforms look different and both show the edge detection, the review step and the confirm button their users already know from Google Drive and the Files app.

## Tune the image quality

`imageQuality` is the JPEG quality of the pages the scanner returns, from `0` to `100`, and it defaults to `100`. For a receipt app the setting decides two things at once: how much storage a year of receipts takes, and how well the text survives for OCR. The example uses `75`. Receipts are dark text on light paper, and JPEG compression artifacts appear first around sharp edges, which is where the digits are, so a value far below that risks misread amounts, while a value near `100` stores every receipt at camera resolution for no visible gain.

Do not pick the number from a blog post, including this one. Scan the ten worst receipts you can find at three or four values, run the OCR step from the section below on each, and keep the lowest value where every total is still read correctly. `Filesystem.stat(...)` returns the `size` of each file, so the comparison takes one afternoon.

The option changes only the JPEG pages. If you ever set `generatePdf: true` for multi-page documents, the PDF keeps its own encoding, which [How to Scan a Document to PDF in a Capacitor App](./how-to-scan-a-document-to-pdf-in-a-capacitor-app.md) covers.

## Store the scan

The scanned image lands in the app's cache directory, and the Capacitor Document Scanner plugin deletes stale files there when it loads on the next launch. A receipt app therefore copies the scan to a permanent place before anything else happens. `Filesystem.copy(...)` of the free `@capacitor/filesystem` plugin moves it into a `receipts` folder of `Directory.Data`, which is the app's files directory on Android and the Documents directory on iOS:

```typescript
import { Injectable } from '@angular/core';
import { Directory, Filesystem } from '@capacitor/filesystem';

@Injectable({ providedIn: 'root' })
export class ReceiptService {
  private readonly folder = 'receipts';

  async store(scannedImage: string): Promise<string> {
    await this.ensureFolder();
    const fileName = `${crypto.randomUUID()}.jpg`;
    const { uri } = await Filesystem.copy({
      from: scannedImage,
      to: `${this.folder}/${fileName}`,
      toDirectory: Directory.Data,
    });
    return uri;
  }

  private async ensureFolder(): Promise<void> {
    try {
      await Filesystem.mkdir({ path: this.folder, directory: Directory.Data, recursive: true });
    } catch {
      // The folder exists already.
    }
  }
}
```

Without a `directory` option, `copy(...)` reads `from` as the full `file://` path the scanner returned, and `toDirectory` resolves the target inside the data directory. The call resolves with the `uri` of the copy, which is the path the OCR step and the list use from here on. A random UUID as the file name avoids collisions when the user scans two receipts in the same second.

## Read the total

The scanner returns an image, not text. To put a total next to each receipt, the service runs the stored JPEG through [`processImage(...)`](../../sdks/capacitor/mlkit/text-recognition.md#processimage) of the Capacitor ML Kit Text Recognition plugin, which recognizes Latin text on the device and returns the full `text` plus `blocks` of `lines`. The total is then a matter of finding the right line:

```typescript
import { Injectable } from '@angular/core';
import { TextRecognition } from '@capacitor-mlkit/text-recognition';

const AMOUNT = /\d{1,3}(?:[.,]\d{3})*[.,]\d{2}/g;
const TOTAL_WORDS = /\b(total|summe|gesamt\w*|amount due|balance)\b/i;

const toNumber = (amount: string): number => {
  const integerPart = amount.slice(0, -3).replace(/[.,]/g, '');
  return Number(`${integerPart}.${amount.slice(-2)}`);
};

@Injectable({ providedIn: 'root' })
export class ReceiptService {
  async readTotal(uri: string): Promise<number | null> {
    const { blocks } = await TextRecognition.processImage({ path: uri });
    const lines = blocks.flatMap(block => block.lines.map(line => line.text));
    return this.extractTotal(lines);
  }

  private extractTotal(lines: string[]): number | null {
    const amountsOf = (line: string) => (line.match(AMOUNT) ?? []).map(toNumber);
    const totalLines = lines.filter(line => TOTAL_WORDS.test(line) && amountsOf(line).length);
    const totalLine = totalLines[totalLines.length - 1];
    if (totalLine) {
      const amounts = amountsOf(totalLine);
      return amounts[amounts.length - 1];
    }
    const allAmounts = lines.flatMap(amountsOf);
    return allAmounts.length ? Math.max(...allAmounts) : null;
  }
}
```

The heuristic has two stages. A line that contains a word like "Total" and an amount wins; the word boundaries in `TOTAL_WORDS` keep "Subtotal" and "Zwischensumme" out, the last such line is taken because the grand total prints after the subtotals, and the last amount on that line is taken because receipts print the label on the left and the figure on the right. `AMOUNT` matches a grouped amount such as `1,234.56` or `1.234,56` as a whole, and `toNumber(...)` reads the last separator as the decimal point. If no total line exists, the largest amount on the receipt is the best guess, since the total is rarely smaller than any single item. Both stages fail on receipts that print the total in words, split the amount across lines or belong to a shop whose receipts say "Due" where the list above says "Total", so extend `TOTAL_WORDS` for your market and let the user tap the amount to correct it. Store the corrected value, not the OCR result.

`processImage(...)` reads the image from the `path` you pass, so it takes the stored URI from `store(...)` directly. The method runs on Android and iOS only; the scanner itself has no web implementation either, so the receipt page is a native-only feature.

## List stored scans

The receipts list is built from the folder itself rather than from a database, so a receipt is never listed without its image or stored without being listed. `Filesystem.readdir(...)` returns the entries with their `name`, `mtime`, `size` and `uri`, and `Capacitor.convertFileSrc(...)` turns the native URI into a URL the WebView can load in an `<img>` tag:

```typescript
import { Injectable, signal } from '@angular/core';
import { Capacitor } from '@capacitor/core';
import { Directory, Filesystem } from '@capacitor/filesystem';
import { Preferences } from '@capacitor/preferences';

@Injectable({ providedIn: 'root' })
export class ReceiptService {
  readonly receipts = signal<Receipt[]>([]);

  async load(): Promise<void> {
    await this.ensureFolder();
    const { files } = await Filesystem.readdir({ path: this.folder, directory: Directory.Data });
    const totals = await this.loadTotals();
    const receipts = files
      .filter(file => file.type === 'file')
      .map(file => ({
        fileName: file.name,
        imageSrc: Capacitor.convertFileSrc(file.uri),
        scannedAt: new Date(file.mtime),
        total: totals[file.name] ?? null,
      }))
      .sort((a, b) => b.scannedAt.getTime() - a.scannedAt.getTime());
    this.receipts.set(receipts);
  }

  async remove(receipt: Receipt): Promise<void> {
    await Filesystem.deleteFile({ path: `${this.folder}/${receipt.fileName}`, directory: Directory.Data });
    const totals = await this.loadTotals();
    delete totals[receipt.fileName];
    await Preferences.set({ key: 'receipt-totals', value: JSON.stringify(totals) });
    await this.load();
  }

  private async loadTotals(): Promise<Record<string, number>> {
    const { value } = await Preferences.get({ key: 'receipt-totals' });
    return value ? JSON.parse(value) : {};
  }
}
```

The totals live in `@capacitor/preferences` as one JSON object keyed by file name. That is enough for a few hundred receipts; an app that also stores merchants, categories and line items moves to a database such as the [Capacitor SQLite plugin](../../sdks/capacitor/sqlite.md) and keeps only the images in the folder. `mtime` is the file's modification time in milliseconds, which for a copied scan is the moment of the scan.

A `scanAndStore()` method ties the pieces together and is what the page calls:

```typescript
import { Injectable } from '@angular/core';
import { Preferences } from '@capacitor/preferences';

@Injectable({ providedIn: 'root' })
export class ReceiptService {
  async scanAndStore(): Promise<void> {
    const scannedImage = await this.scan();
    if (!scannedImage) {
      return;
    }
    const uri = await this.store(scannedImage);
    const total = await this.readTotal(uri);
    if (total !== null) {
      const totals = await this.loadTotals();
      totals[uri.split('/').pop() ?? uri] = total;
      await Preferences.set({ key: 'receipt-totals', value: JSON.stringify(totals) });
    }
    await this.load();
  }
}
```

## The receipts page

The page is a standalone component that imports each Ionic component from `@ionic/angular/standalone`, renders the `receipts` signal and starts a scan from a floating action button:

```typescript
import { Component, OnInit, inject } from '@angular/core';
import { DatePipe, CurrencyPipe } from '@angular/common';
import {
  IonContent,
  IonFab,
  IonFabButton,
  IonHeader,
  IonIcon,
  IonItem,
  IonItemOption,
  IonItemOptions,
  IonItemSliding,
  IonLabel,
  IonList,
  IonNote,
  IonThumbnail,
  IonTitle,
  IonToolbar,
} from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { camera } from 'ionicons/icons';
import { Receipt, ReceiptService } from './receipt.service';

@Component({
  selector: 'app-receipts',
  imports: [
    CurrencyPipe,
    DatePipe,
    IonContent,
    IonFab,
    IonFabButton,
    IonHeader,
    IonIcon,
    IonItem,
    IonItemOption,
    IonItemOptions,
    IonItemSliding,
    IonLabel,
    IonList,
    IonNote,
    IonThumbnail,
    IonTitle,
    IonToolbar,
  ],
  template: `
    <ion-header>
      <ion-toolbar>
        <ion-title>Receipts</ion-title>
      </ion-toolbar>
    </ion-header>
    <ion-content>
      <ion-list>
        @for (receipt of receiptService.receipts(); track receipt.fileName) {
          <ion-item-sliding>
            <ion-item>
              <ion-thumbnail slot="start">
                <img [src]="receipt.imageSrc" alt="Scanned receipt" />
              </ion-thumbnail>
              <ion-label>{{ receipt.scannedAt | date: 'mediumDate' }}</ion-label>
              <ion-note slot="end">
                {{ receipt.total !== null ? (receipt.total | currency: 'EUR') : 'No total' }}
              </ion-note>
            </ion-item>
            <ion-item-options side="end">
              <ion-item-option color="danger" (click)="remove(receipt)">Delete</ion-item-option>
            </ion-item-options>
          </ion-item-sliding>
        }
      </ion-list>
      <ion-fab slot="fixed" vertical="bottom" horizontal="end">
        <ion-fab-button (click)="scan()">
          <ion-icon name="camera"></ion-icon>
        </ion-fab-button>
      </ion-fab>
    </ion-content>
  `,
})
export class ReceiptsPage implements OnInit {
  readonly receiptService = inject(ReceiptService);

  constructor() {
    addIcons({ camera });
  }

  async ngOnInit(): Promise<void> {
    await this.receiptService.load();
  }

  async scan(): Promise<void> {
    await this.receiptService.scanAndStore();
  }

  async remove(receipt: Receipt): Promise<void> {
    await this.receiptService.remove(receipt);
  }
}
```

The thumbnail loads the full scan, which is fine for a list of a few dozen receipts and slow for thousands; at that point, generate a small preview once at scan time, for example with the [Capacitor Photo Manipulator plugin](../../sdks/capacitor/photo-manipulator.md), and store it next to the original. The currency is hard-coded to show the pipe; a real app reads it from the receipt or the user's locale.

## FAQ

### How do I let users scan receipts in an Ionic app?

Call `scanDocument(...)` of the [Capacitor Document Scanner plugin](../../sdks/capacitor/document-scanner.md) with `pageLimit: 1`. The plugin opens Google's ML Kit scanner on Android and VisionKit on iOS, both of which detect the receipt's edges, correct the perspective and return the page as a JPEG in the cache directory. Copy the file to `Directory.Data` with `@capacitor/filesystem` before the next launch, and run the free Capacitor ML Kit Text Recognition plugin over it to read the total.

### Can users pick a receipt photo from the gallery instead of the camera?

On Android, yes. Set `androidGalleryImportAllowed: true` and the scanner offers an import button, and the picked image goes through the same edge detection and cleaning. VisionKit on iOS has no import, so offer the [Capacitor File Picker plugin](../../sdks/capacitor/file-picker.md) as a separate path there and run text recognition on the picked image without perspective correction.

### Does the scanner read the amounts on the receipt?

No. The Capacitor Document Scanner plugin returns images and an optional PDF and runs no OCR. The Capacitor ML Kit Text Recognition plugin reads the text, and the heuristic in this guide picks the total from the recognized lines. Expect misreads on faded thermal paper and let the user correct the amount.

### Does the receipt scanner work in the browser?

No. `scanDocument(...)` and `processImage(...)` reject with an unimplemented error on the web, since neither platform scanner nor ML Kit exists in a browser.

### Does it work with Ionic React or Vue?

Yes. The plugins are framework-agnostic. Only the service and page wrappers in this guide are Angular-specific; the plugin calls are the same in React, Vue or plain JavaScript.

### Is the Capacitor Document Scanner plugin free?

No. It is part of the [Capawesome Insiders](../../insiders/index.md) subscription and installs from the Capawesome npm registry with your license key. The Capacitor ML Kit Text Recognition plugin, `@capacitor/filesystem` and `@capacitor/preferences` are free and open source.

## Related posts

- [Announcing the Capacitor Document Scanner Plugin](./announcing-the-capacitor-document-scanner-plugin.md)
- [How to Scan a Document to PDF in a Capacitor App](./how-to-scan-a-document-to-pdf-in-a-capacitor-app.md)
- [An Alternative to cordova-plugin-document-scanner](./alternative-to-cordova-plugin-document-scanner.md)
- [Capacitor File Handling: The Complete Guide](./capacitor-file-handling-guide.md)

## Try Capawesome Cloud

The scanner and the text recognizer are native plugins, and the camera usage string lives in `Info.plist`, so every change in this guide ships in a native build. Capawesome Cloud builds and signs your Ionic app for Android and iOS without a Mac, and [How to Sign & Build Capacitor Apps in the Cloud](./how-to-sign-and-build-your-capacitor-app-in-the-cloud.md) shows the setup.

[Try Capawesome Cloud Free](https://capawesome.io){ .md-button .md-button--primary }

## Conclusion

Ship the scan-and-store loop first and the OCR second: a receipt that is captured and kept is already worth something, while a wrong total in the list costs trust. Pick `imageQuality` with your own receipts and your own OCR check, copy the scan out of the cache in the same function that scanned it, and build the list from the folder so that the files stay the source of truth. When users ask for multi-page invoices as one document, switch on `generatePdf` and follow [How to Scan a Document to PDF in a Capacitor App](./how-to-scan-a-document-to-pdf-in-a-capacitor-app.md).

If you have any questions, join us on the [Capawesome Discord server](https://discord.gg/VCXxSVjefW){:target="_blank"}. To stay updated on the latest news, subscribe to the [Capawesome newsletter](https://capawesome.io/newsletter/){:target="_blank"}.
