POST /api/verify-image
Lift the reference token off a receipt screenshot, then cross-check it against the upstream verifier.
For when you don't have a receipt URL — just a screenshot. Useful for telebirr app success screens, downloaded PDF invoices, or any photo a user shares as proof of payment.
The endpoint runs a Gemini-based detector over the image, identifies which
provider it came from, lifts the upstream-lookup reference, and — when the
provider is supported — chains into POST /api/verify so the
same response also includes the authoritative bank-side receipt.
The detector's output is not a receipt. It only carries provider +
reference + confidence. The real receipt fields (payer, amount, date, status)
live in upstream.result.receipt, fetched directly from the bank. If you
need verified data, that's the field to read.
Request
| Field | Type | Description |
|---|---|---|
images | Array<{ imageBase64 }> | 1–5 images of the same transaction. Each imageBase64 is the raw base64 string (no data: prefix needed; both forms work). |
imageBase64 | string | Single-image shorthand. Equivalent to images: [{ imageBase64 }]. |
Limits
- 5 images per request, 5 MB base64 per image (≈ 3.75 MB raw).
- One billed call per request regardless of image count.
- Shared daily cap: 200 OCR calls / 24h across all keys + the landing-page demo. When the cap is reached the endpoint returns 429 with
error.code = "ocr_daily_cap_reached"and aRetry-Afterheader. This is a global budget guard against the underlying Gemini bill — a single noisy caller can't burn the day's allowance away from everyone else by minting more keys.
Multi-image semantics
Multiple images of the same transaction (front + back of a PDF, pages of an invoice) all work — the detector picks the clearest reference it can see. Sending multiple images of different transactions is unsupported; you'll get whichever reference looks most prominent.
Response
detected — what the OCR pulled off the screenshot
| Field | Type | Notes |
|---|---|---|
provider | "telebirr" | "cbe" | "boa" | "zemen" | "awashbank" | "none" | Which known provider issued the screenshot. "none" when nothing matched. |
reference | string | null | The upstream-lookup token (telebirr "Transaction Number" / "Invoice No.", CBE "Reference", etc.). |
url | string | null | Full receipt URL if printed in plain text on screen (not decoded from QR). |
confidence | number (0–1) | Detector's confidence that reference is the exact upstream-lookup token. |
upstream — the cross-check against the bank
Always present. Tells you what happened with the chained verification.
attempted | reason | When |
|---|---|---|
true | — | The detector identified a supported provider and the chained verifyWithCache returned. Inspect upstream.result.ok to know whether the bank confirmed it. |
false | unsupported_provider | Detector returned provider: "none" (random photo, unrecognised bank). |
false | no_reference | Provider identified, but no reference token was readable. |
false | provider_needs_full_url | CBE/BOA/Zemen/Awash without a printed URL — we can't construct the lookup URL from a reference alone. |
false | verifier_threw | The upstream verifier itself errored. upstream.error carries the message; upstream.url shows what URL was attempted. |
When attempted: true the inner result has the same shape as
POST /api/verify plus a cached flag — cache hits return in
under 50 ms because the shared receipts_cache table is the same one the URL
verifier writes to.
Currently auto-verifiable providers
Only telebirr is auto-verified end-to-end from a screenshot, because the
reference token alone is enough to reconstruct the upstream URL
(transactioninfo.ethiotelecom.et/receipt/<ref>). For CBE / BOA / Zemen / Awash
we need the full receipt URL — either the screenshot prints it as text
(rare), or you fall back to calling POST /api/verify with
the URL yourself.
Errors
| Status | error.code | Meaning |
|---|---|---|
| 400 | invalid_json | Body wasn't valid JSON. |
| 400 | invalid_request | Neither images nor imageBase64 was supplied. |
| 400 | image_too_large | At least one image is over the 5 MB base64 cap. |
| 401 | missing_key / invalid_key / revoked_key | Same auth gate as /api/verify. See Authentication. |
| 429 | rate_limited | Per-key minute bucket. Retry-After header carries seconds. |
| 429 | ocr_daily_cap_reached | Shared 200/day OCR budget has been spent across all keys + the demo. Retry-After carries seconds. |
| 503 | ai_not_configured | AI_GATEWAY_API_KEY is missing from the server env. |
| 502 | detection_failed | The AI gateway itself errored. The chained upstream verify, if any, is not attempted. |
Examples
Single screenshot (curl)
Use only the verified upstream receipt
Try it
The landing page has a drag-and-drop / paste-with-⌘V demo of the same endpoint at v.odit.et. Drop a telebirr screenshot — you'll see the detection row, then the green "Verified at the source" card when the upstream confirms.