Odit Verify

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

POST /api/verify-image HTTP/1.1
Host: v.odit.et
x-api-key: vk_live_...
content-type: application/json
 
{
  "images": [{ "imageBase64": "<base64 JPEG/PNG>" }]
}
FieldTypeDescription
imagesArray<{ imageBase64 }>1–5 images of the same transaction. Each imageBase64 is the raw base64 string (no data: prefix needed; both forms work).
imageBase64stringSingle-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 a Retry-After header. 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

{
  "ok": true,
  "source": "image",
  "fetchedAt": "2026-01-01T00:00:00.000Z",
  "detected": {
    "provider": "telebirr",
    "reference": "DE73NC383J",
    "url": null,
    "confidence": 0.95
  },
  "upstream": {
    "attempted": true,
    "url": "https://transactioninfo.ethiotelecom.et/receipt/DE73NC383J",
    "result": {
      "ok": true,
      "providerKey": "telebirr",
      "resolvedUrl": "https://transactioninfo.ethiotelecom.et/receipt/DE73NC383J",
      "httpStatus": 200,
      "fetchedAt": "2026-01-01T00:00:00.000Z",
      "cached": true,
      "receipt": { /* same shape as POST /api/verify telebirr response */ },
      "error": null
    }
  },
  "error": null
}

detected — what the OCR pulled off the screenshot

FieldTypeNotes
provider"telebirr" | "cbe" | "boa" | "zemen" | "awashbank" | "none"Which known provider issued the screenshot. "none" when nothing matched.
referencestring | nullThe upstream-lookup token (telebirr "Transaction Number" / "Invoice No.", CBE "Reference", etc.).
urlstring | nullFull receipt URL if printed in plain text on screen (not decoded from QR).
confidencenumber (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.

attemptedreasonWhen
trueThe detector identified a supported provider and the chained verifyWithCache returned. Inspect upstream.result.ok to know whether the bank confirmed it.
falseunsupported_providerDetector returned provider: "none" (random photo, unrecognised bank).
falseno_referenceProvider identified, but no reference token was readable.
falseprovider_needs_full_urlCBE/BOA/Zemen/Awash without a printed URL — we can't construct the lookup URL from a reference alone.
falseverifier_threwThe 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

Statuserror.codeMeaning
400invalid_jsonBody wasn't valid JSON.
400invalid_requestNeither images nor imageBase64 was supplied.
400image_too_largeAt least one image is over the 5 MB base64 cap.
401missing_key / invalid_key / revoked_keySame auth gate as /api/verify. See Authentication.
429rate_limitedPer-key minute bucket. Retry-After header carries seconds.
429ocr_daily_cap_reachedShared 200/day OCR budget has been spent across all keys + the demo. Retry-After carries seconds.
503ai_not_configuredAI_GATEWAY_API_KEY is missing from the server env.
502detection_failedThe AI gateway itself errored. The chained upstream verify, if any, is not attempted.

Examples

Single screenshot (curl)

B64=$(base64 -i receipt.jpg | tr -d '\n')
curl -sS https://v.odit.et/api/verify-image \
  -H "x-api-key: $VERIFY_KEY" \
  -H 'content-type: application/json' \
  -d "$(jq -n --arg b "$B64" '{imageBase64: $b}')"

Use only the verified upstream receipt

const res = await fetch('https://v.odit.et/api/verify-image', {
  method: 'POST',
  headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
  body: JSON.stringify({ imageBase64 }),
}).then(r => r.json());
 
if (res.upstream?.attempted && res.upstream.result.ok) {
  // Bank confirmed — use upstream.result.receipt as the source of truth.
  return res.upstream.result.receipt;
}
// Either not a known provider screenshot, or upstream refused.
// res.detected tells you what the OCR thought; res.upstream.reason explains why no upstream check.

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.

On this page