Guides

Vision API: Read a Mobile Money Receipt with an LLM (JSON)

TchaviBy Gael — Tchavi founder

Published · Updated · 11 min read

Back to blog

Table of contents

To read a mobile money receipt or a shop invoice with an LLM, you send the image in the content array of a user message, next to your instruction, and ask for JSON back. One POST /v1/chat/completions call, the same one as for text.

I tested three vision models on a receipt, a photo of that receipt and an invoice, all synthetic. The receipts were fictional West African mobile money receipts, written in French. An M-Pesa SMS or receipt works the same way, but I did not measure an M-Pesa test. The result that surprised me most: the cost per image varies by a factor of twenty between models, even though their per-token prices are of the same order.

In short

  • Tchavi, an OpenAI-compatible AI API gateway you pay for with mobile money, accepts images in chat/completions: an { type: "image_url", image_url: { url } } block in the content array, with the URL or a base64 data: string.
  • Measured accuracy: on a clean receipt, a degraded photo and an invoice, gemini-3.1-flash-lite and claude-haiku-5-5 got everything right (10/10, 10/10 and 18/18). gpt-4o-mini made two errors on the photo and had one stray character on the clean receipt.
  • Measured cost for one receipt: 0.438 credits with claude-haiku-5-5, 1.139 with gemini-3.1-flash-lite, 8.607 with gpt-4o-mini. At October 2026 rates (about 13,204 credits per KES 1,000), that is about KES 0.03, KES 0.09 and KES 0.65.
  • Base64 or URL: the same number of tokens on the same image, so the same bill. The difference is practical, not financial.
  • Never accept a photo of a receipt as proof of payment. An image can be forged: credit is given on the operator's confirmation, not on a screenshot.

The format: an array of blocks

In messages, the content of a user message can be a string or an array of blocks. For an image:

{
  "model": "claude-haiku-5-5",
  "messages": [
    {
      "role": "user",
      "content": [
        { "type": "text", "text": "Read this receipt and answer in JSON." },
        { "type": "image_url", "image_url": { "url": "https://example.com/receipt.png" } }
      ]
    }
  ]
}

This is the format described in the chat/completions documentation, with the text, image_url and document_url (for PDFs) blocks. Not every model reads images: check the capability on the model page or in GET /v1/models. I used gpt-4o-mini, gemini-3.1-flash-lite and claude-haiku-5-5.

Base64 or URL

You have two ways to give the image:

  • A public URL: handy if the image is already hosted (your storage, your CDN). The provider must be able to download it: a local address, a private one or one behind a login will not work.
  • Base64 in a data: string: this is what you need for a receipt your customer just sent on WhatsApp or from your app. Nothing to host, but the request grows by about a third of the file size.

I compared them on the same image, a small public logo of 192 x 75 pixels, with two models:

ModelTransportInput tokens
gpt-4o-miniURL8,515
gpt-4o-minibase648,515
claude-haiku-5-5URL44
claude-haiku-5-5base6444

The tokens are the same, so the cost of the image is too. The credit gaps between my two calls come from the length of the answer and, for gpt-4o-mini, from part of the input being served from cache (2,944 tokens the second time). The timings were close (1.8 s against 1.1 s, and 2.5 s against 1.6 s: too few runs to conclude anything). I did not host my synthetic receipts: the URL test is on the logo, and the receipts went as base64.

The code, in Node.js

The measured instruction is in French, as the receipts were, so I kept it as it was; only the code identifiers are in English.

import { readFileSync } from 'node:fs';

const BASE = 'https://tchavi.com/api/v1';

const PROMPT =
  'Lis ce reçu de mobile money. Réponds uniquement avec un objet JSON : ' +
  '{"operateur": string, "montant": number, "devise": string, "frais": number, ' +
  '"destinataire": string, "numero": string, "reference": string, ' +
  '"date": "AAAA-MM-JJ", "heure": "HH:MM", "solde": number}. ' +
  'Les montants sont des nombres sans espaces. Si un champ est illisible, mets null.';

export async function readReceipt(file, model = 'claude-haiku-5-5') {
  const mime = file.endsWith('.jpg') ? 'image/jpeg' : 'image/png';
  const dataUrl = `data:${mime};base64,${readFileSync(file).toString('base64')}`;

  const res = await fetch(`${BASE}/chat/completions`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.TCHAVI_API_KEY}`,
    },
    body: JSON.stringify({
      model,
      temperature: 0,
      max_tokens: 800,
      messages: [
        {
          role: 'user',
          content: [
            { type: 'text', text: PROMPT },
            { type: 'image_url', image_url: { url: dataUrl } },
          ],
        },
      ],
    }),
  });
  const json = await res.json();
  if (!res.ok) throw new Error(`${json.error}: ${json.message}`);

  // Some models wrap the JSON in ```json … ```: strip it.
  const raw = json.choices[0].message.content.replace(/^```(?:json)?\s*|\s*```$/g, '');
  return {
    data: JSON.parse(raw),
    credits: Number(res.headers.get('x-credits-used')),
  };
}

The same call in Python

import base64
import json
import os
import re

import requests

PROMPT = (
    "Lis ce reçu de mobile money. Réponds uniquement avec un objet JSON : "
    '{"operateur": string, "montant": number, "devise": string, "frais": number, '
    '"destinataire": string, "numero": string, "reference": string, '
    '"date": "AAAA-MM-JJ", "heure": "HH:MM", "solde": number}. '
    "Les montants sont des nombres sans espaces. Si un champ est illisible, mets null."
)


def read_receipt(file, model="claude-haiku-5-5"):
    mime = "image/jpeg" if file.endswith(".jpg") else "image/png"
    b64 = base64.b64encode(open(file, "rb").read()).decode()
    r = requests.post(
        "https://tchavi.com/api/v1/chat/completions",
        headers={"Authorization": f"Bearer {os.environ['TCHAVI_API_KEY']}"},
        json={
            "model": model,
            "temperature": 0,
            "max_tokens": 800,
            "messages": [{
                "role": "user",
                "content": [
                    {"type": "text", "text": PROMPT},
                    {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}},
                ],
            }],
        },
        timeout=120,
    )
    body = r.json()
    if not r.ok:
        raise RuntimeError(f"{body['error']}: {body['message']}")
    text = re.sub(r"^```(?:json)?\s*|\s*```$", "", body["choices"][0]["message"]["content"])
    return json.loads(text), float(r.headers.get("x-credits-used", 0))

One point of caution: I only used temperature: 0 on my three models. If another model, a reasoning model for example, returns an error because of this parameter, remove it.

I did not test the response_format option (json_object or json_schema) in this series, so I don't say it works on every model: support varies, and the model's API tab tells you. In the meantime, the instruction that forces JSON and the code-block cleanup above were enough on my three models.

The test: a fictional receipt, a photo, an invoice

So as not to show anyone's data, I made three images with Python (PIL), all marked "DOCUMENT FICTIF":

  1. a transfer receipt, clean, with ten fields;
  2. the same receipt "photographed": perspective, blur, shadow, noise, compressed JPEG;
  3. a shop invoice, slightly tilted, with three lines, a total and a payment reference (18 values).

The names, numbers and references are invented ("Boutique Awa Démo", "TXN-7Q4K92LM18", 15,000 FCFA). One run per model and per image, with temperature: 0: this is a small sample, not a benchmark.

Accuracy, field by field

Imagegpt-4o-minigemini-3.1-flash-liteclaude-haiku-5-5
Clean receipt (10 fields)9/1010/1010/10
Receipt photo (10 fields)8/1010/1010/10
Invoice (18 values)18/1818/1818/18

The detail of gpt-4o-mini's errors:

  • Clean receipt: the destinataire (recipient) field came back with a Cyrillic о in place of the Latin o in "Démo". To the eye, it is identical. In a database, it is a different text, and an exact search fails. I found it by comparing character codes.
  • Receipt photo: destinataire cut to "Boutique Awa Dém", and numero (phone number) rendered +22901000000, with two zeros missing. A wrong number is worse than an empty field.

The amounts, date, time, reference and operator were right on all three models on all three images. The errors came from long texts and phone numbers, not from amounts. I don't draw a law from that for other receipts: test with yours.

Credits and timings

KES figures are at October 2026 rates (1 credit is about KES 0.0757):

Imagegpt-4o-minigemini-3.1-flash-liteclaude-haiku-5-5
Clean receipt8.607 cr, 2.6 s1.139 cr, 1.9 s0.438 cr, 1.9 s
Receipt photo8.596 cr, 2.9 s1.114 cr, 1.8 s0.678 cr, 4.0 s
Invoice8.723 cr, 3.5 s1.593 cr, 2.3 s0.478 cr, 2.4 s

Why such a gap? Look at the input tokens of the clean receipt (640 x 980 pixels):

ModelInput tokensInput price (credits / 1,000 tokens)
gpt-4o-mini25,6070.33
gemini-3.1-flash-lite1,2050.581
claude-haiku-5-59570.22

gpt-4o-mini has the second-lowest per-token price, but it counts the same image as more than twenty times as many tokens: the bill follows. So the per-token price alone doesn't tell you what an image will cost. Compare on a real image, with x-credits-used.

The costs can be recomputed: for claude-haiku-5-5 on the clean receipt, 957 input tokens at 0.22 and 206 output tokens at 1.1 per 1,000 give 0.437, and I measured 0.438. At 0.438 credits per receipt, a thousand receipts come to 438 credits, about KES 33. With gpt-4o-mini, 8,607 credits, about KES 652. These are public prices; some models are on discount right now, and each model page shows the price billed today.

What I would do in production

  1. Choose the model on your images, with thirty real receipts (removing personal data) and the field-by-field count above.
  2. Validate the JSON in code: the amount is a positive number, the date exists, the reference follows your operator's format, and on an invoice the sum of the lines equals the total. All three of my models passed that last check, but it is the one that will catch the error one day.
  3. Normalise the texts (Unicode, spaces) before you compare or store, because of the Cyrillic character in my test.
  4. Ask for null rather than a guess: the instruction "if a field is unreadable, put null" is in my code for that.
  5. Don't treat the image as proof of payment. To credit a customer, wait for the webhook or the operator's confirmation. The receipt read by the LLM is there to pre-fill a form, not to validate a payment.
  6. Cap the size of the images you accept and shrink them before sending: the number of tokens follows the dimensions.

For text prices in FCFA, there is the price comparison (in French).

FAQ

How do I send an image to an LLM through the API?

In messages, put a user message whose content is an array with a text block and an image_url block. The URL can be a public address or a data:image/png;base64,... string.

Base64 or URL: which one?

Base64 for an image you just received and haven't hosted, URL for an image that is already online and public. On the same image, I measured the same tokens with both, so the same cost.

Which models read images?

The ones whose vision capability is shown on their page or in GET /v1/models. I tested gpt-4o-mini, gemini-3.1-flash-lite and claude-haiku-5-5.

How much does reading a receipt cost?

Between 0.438 and 8.607 credits on my three models for a 640 x 980 pixel receipt, which is about KES 0.03 to KES 0.65 at October 2026 rates. The cost depends mostly on the number of tokens each model assigns to the image.

Can an LLM replace OCR?

For simple receipts and invoices, it did well in my runs, including on a degraded photo. It can also be wrong in a quiet way, like a phone number with digits missing. So keep checks in your code.

Can I read a PDF?

Yes, with a document_url block, on models that have the documents capability (for example the Claude models). I did not test it here.

How do I pay without a bank card?

With mobile money: the minimum top-up is KES 250, and there is no subscription and no free credits at signup. Your first paid top-up adds 150 bonus credits. See countries and operators, including M-Pesa in Kenya.

create your account and top up KES 250

This article is part of the series "Building an AI product from Africa". Tchavi is an OpenAI-compatible AI API gateway: one key gives access to over 100 text, image, audio and video models, and you pay for it with mobile money, no bank card (countries and operators). More guides on the blog. Follow us: LinkedIn and X.

Share

Your first API call, paid with mobile money

No bank card. Top up with mobile money in your local currency; your first top-up adds 150 bonus credits, and credits never expire.