Siinqee Bank unavailable (9 days). Known, ongoing.Status →
links.et

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: links.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.
  • 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 a Retry-After header.
  • 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 a Retry-After header. 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

{
	"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
truen/aThe 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://links.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://links.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 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.

On this page