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.
- Per-plan screenshot allowance. Every plan caps screenshot reads per month
(see Rate limits). Past it the endpoint returns 429 with
error.code = "image_cap_reached"and aRetry-Afterheader. - Service-wide backstop: 2,000 OCR calls per rolling 24 hours across all
keys. Reaching it returns 429 with
error.code = "ocr_daily_cap_reached"and aRetry-Afterheader. It sits far above any single plan, so it bites on runaway usage, not on a busy day. The landing-page demo runs on its own separate budget of 200 calls per day and never eats into this one.
What happens to the screenshot
The image you send is stored for 90 days, then deleted automatically. It is kept so the read is auditable: you can see the exact image behind any call on your dashboard, and we can tell why a screenshot was misread rather than guessing. It lives in a private bucket, is never served publicly, and is readable only by you and Odit staff.
If you are passing along screenshots that belong to your own users, this is
worth reflecting in your own privacy notice. Send a receipt link or reference
to /api/verify instead if you would rather nothing was kept.
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 | n/a | 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 links.et. Drop a telebirr screenshot and you'll see the detection row, then the green "Verified at the source" card when the upstream confirms.