# 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`](/docs/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

```http
POST /api/verify-image HTTP/1.1
Host: links.et
x-api-key: vk_live_...
content-type: application/json

{
  "images": [{ "imageBase64": "<base64 JPEG/PNG>" }]
}
```

| 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](/docs/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](/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`](/docs/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

```json
{
	"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

| 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`](/docs/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`](/docs/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](/docs/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)

```bash
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

```js
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.
