# links.et documentation > Verify Ethiopian payment receipts at the source. POST a receipt URL, reference or screenshot and links.et fetches it from the bank over an Ethiopia-egress link and returns the parsed receipt as JSON. 17 banks and wallets, one response envelope. --- Source: https://links.et/docs # Overview > Verify Ethiopian receipt URLs from one API endpoint hosted in Ethiopia. `links.et` fetches receipts from the official provider over an Ethiopia-egress link and returns the parsed receipt as JSON. Built for backends that can't reach the provider directly from their region. ## How it works 1. **Sign up** at [/signup](/signup) and mint an API key from the dashboard. 2. **POST** the receipt URL to `/api/verify`. 3. We fetch the receipt from the official provider, parse it, and return JSON. Prefer clicking over curl? The [playground](/dashboard/playground) runs the same calls from your dashboard, with your API key and ready-made example requests for every endpoint. ## Supported providers **17 banks and wallets**, plus HuluBeje point-of-sale receipts. telebirr, CBE, CBE Birr, Bank of Abyssinia, Zemen, Awash, Dashen, M-PESA, COOPay, Kaafi, Amhara, Abay, Berhan, Oromia, Ahadu, Siinqee and ZamZam. [The providers page](/docs/providers) has the host for each one, the `source` tag it stamps on the receipt, and the shape it returns. Switch on `source` rather than on the host: several banks serve more than one host, and two of them share one. ## Quick example References shown here are placeholders; substitute one from a receipt you control. ```bash curl -sX POST https://links.et/api/verify \ -H "x-api-key: vk_live_..." \ -H "content-type: application/json" \ -d '{"url":"https://transactioninfo.ethiotelecom.et/receipt/ABCD1234EF"}' | jq . ``` ```json { "ok": true, "providerKey": "telebirr", "resolvedUrl": "https://transactioninfo.ethiotelecom.et/receipt/ABCD1234EF", "httpStatus": 200, "fetchedAt": "2026-01-01T00:00:00.000Z", "receipt": { "source": "telebirr-html", "payerName": "", "transactionStatus": "Completed", "receiptNo": "ABCD1234EF", "totalPaidAmount": "100 Birr", "paymentDate": "01-01-2026 00:00:00" }, "rawHtmlLength": 25928, "error": null } ``` CBE responses have a different shape; see [POST /api/verify](/docs/verify). --- Source: https://links.et/docs/authentication # Authentication > API keys are minted from the dashboard and passed via the x-api-key header. ## Get a key 1. [Sign up](/signup) for an account. 2. Open the [dashboard](/dashboard). 3. Mint a key with a memorable label (e.g. `pattern-admin`). 4. **Copy the plaintext now.** We only store a SHA-256 hash; the plaintext is shown once and never again. ## Send it Pass the key in the `x-api-key` request header: ```bash curl https://links.et/api/verify \ -H "x-api-key: vk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \ -H "content-type: application/json" \ -d '{"reference":"DF33KS5FB9"}' ``` ## Revoke Hit the **Revoke** button next to the key in the dashboard. Calls using a revoked key return `401 revoked_key` immediately. ## Key prefix The first 14 chars of each key (e.g. `vk_live_a3f9c2`) are stored in plaintext as the `prefix`. The dashboard shows this; we also return it on successful responses as the `x-api-key-prefix` header for log correlation. --- Source: https://links.et/docs/verify # POST /api/verify > Fetch + parse a single receipt URL across all supported providers. > No receipt URL? See [`POST /api/verify-image`](/docs/verify-image); it extracts a > receipt from a screenshot via AI OCR and chains back here for the upstream > confirmation when the screenshot is from a supported provider. ## Request ```http POST /api/verify HTTP/1.1 Host: links.et x-api-key: vk_live_... content-type: application/json { "url": "" } ``` | Field | Type | Description | |-------------|----------|--------------------------------------------------------------------------| | `url` | string | Full receipt URL. Works for every supported provider. | | `reference` | string | Shorthand, telebirr-only. Equivalent to `transactioninfo.ethiotelecom.et/receipt/`. | Exactly one of the two is required. URLs and references shown below are placeholders; substitute one from a receipt you control. ## Response envelope Every success returns the same envelope. The shape of `receipt` varies by provider; use the `source` field (or its absence) to dispatch. ```json { "ok": true, "providerKey": "telebirr" | "cbe", "resolvedUrl": "", "httpStatus": 200, "fetchedAt": "2026-01-01T00:00:00.000Z", "rawHtmlLength": 25928, "error": null, "receipt": { /* provider-specific, see below */ } } ``` ## Telebirr `receipt.source === "telebirr-html"`. Scraped from the bilingual HTML. ```json { "source": "telebirr-html", "payerName": "", "payerTelebirrNo": "251********", "payerAccountType": "Individual Customer", "creditedPartyName": "", "creditedPartyAccountNo": "251********", "transactionStatus": "Completed", "receiptNo": "ABCD1234EF", "paymentDate": "01-01-2026 00:00:00", "settledAmount": "100 Birr", "serviceFee": "1.74 Birr", "serviceFeeVAT": "0.26 Birr", "totalPaidAmount": "102 Birr", "paymentReason": "Send Money to Registered Customer", "paymentMode": "telebirr", "paymentChannel": "API/App" } ``` ## CBE PDF (`apps.cbe.com.et` and dashed `mbreciept.cbe.com.et`) `receipt.source === "cbe-pdf"`. Amounts are numbers, not strings. ```json { "source": "cbe-pdf", "payerName": "", "payerAccount": "1****0000", "receiverName": "", "receiverAccount": "1****0000", "paymentDate": "1/1/2026, 00:00:00 AM", "reference": "FT00000000", "paymentReason": "Account Transfer", "transferredAmount": 100, "serviceCharge": 1.74, "vat": 0.26, "totalAmount": 102, "currency": "ETB", "amountInWords": "", "customerName": "", "branch": "", "vatReceiptNo": "ABC0000", "vatRegistrationNo": "0000", "vatRegistrationDate": "1/1/2020" } ``` ## Zemen Bank (`share.zemenbank.com`) `receipt.source === "zemen-pdf"`. Amounts are numbers; currency is always `ETB` on these receipts. The URL has the shape `share.zemenbank.com/rt//pdf` where `` is 24 alphanumeric characters (an 8-char prefix followed by the 16-char reference). ```json { "source": "zemen-pdf", "invoiceNo": "000000000", "date": "1-Jan-2026", "payerName": "", "payerAccount": "173****0000(923141XXXXXX0000)", "payerTin": "", "payerVat": "", "recipientName": "", "recipientAccount": "00****00", "paymentOrderNumber": "", "reference": "108ATWR0000000QA", "transactionStatus": "COMPLETED", "transactionDetail": "Remot On Us - ATM CASH WITHDRAWAL", "settledAmount": 100, "serviceCharge": 1, "vat": 0.15, "disasterRiskCharge": 0.05, "totalAmountPaid": 101.20, "currency": "ETB", "totalAmountInWord": "ONE HUNDRED ONE BIRR AND TWENTY CENT(S)", "paymentReason": "" } ``` ## Bank of Abyssinia (`cs.bankofabyssinia.com`) `receipt.source === "boa-json"`. The public `/slip/` page is a React SPA; we bypass it and hit the same `/api/onlineSlip/getDetails/?id=` endpoint the bundle does, then normalize the response. Amounts are numbers. ```json { "source": "boa-json", "transactionReference": "FT00000000", "paymentReference": "", "transactionDate": "01/01/26 00:00", "transactionType": "Account Transfer", "receiverName": "", "receiverAccount": "1******00", "transferredAmount": 10, "serviceCharge": 0, "vat": 0, "totalAmount": 10, "currency": "ETB", "transferredAmountInWord": "TEN BIRR", "narrative": "Transfer", "upstreamStatus": "Success" } ``` ## Awash Bank (`awashpay.awashbank.com:8225`) `receipt.source === "awash-html"`. Scraped from a server-rendered static HTML receipt page. The static company-info block (TIN, VAT Reg, etc.) is dropped (it's identical on every receipt) and we surface `customer` + `transaction` sub-objects. Amounts are strings with a ` ETB` suffix (Awash ships them that way; we keep verbatim). Awash issues at least nine receipt layouts depending on `transactionType` (IPS Bank Transfer, Telebirr Transfer, Send Money, Bill Payment, Ethiotelecom Postpaid Bill, Bulk Payment, WITHDRAW, DEPOSIT, School Fees). The parser maps each variant's synonym labels (`Sender Name` / `Customer Name`, `Beneficiary Account` / `Phone Number` / `CA Number`, etc.) into the same typed fields below. Labels we don't have first-class names for (`Branch`, `CBS Trans ID`, `Student Code`, `Invoice Number`, etc.) appear inside `transaction.extra`. See [Awash Bank provider page](/docs/providers/awash) for the full variant matrix. ```json { "source": "awash-html", "customer": { "customerName": "", "accountNo": "XXXXX******XXXX/BANK", "city": "Addis Ababa", "vatRegNo": "-", "vatRegDate": "-", "tinTaxId": "-", "branch": "" }, "transaction": { "transactionTime": "2026-01-01 00:00:00 AM", "transactionType": "IPS Bank Transfer", "amount": "100 ETB", "vat": "0.09 ETB", "charge": "1 ETB", "senderName": "", "senderAccount": "XXXXX*******XXX", "beneficiaryName": "", "beneficiaryAccount": "1000000000000", "beneficiaryBank": "Commercial Bank of Ethiopia", "reason": "Transfer", "transactionId": "000000000000000", "extra": { } } } ``` ## CBE JSON (`mb.cbe.com.et` and plain `mbreciept.cbe.com.et`) `receipt.source === "mb-json"`. Richer than the PDF: includes commissions, taxes, and processing dates as an array. ```json { "source": "mb-json", "payerName": "", "payerAccount": "1********0000", "receiverName": "", "receiverAccount": "1********0000", "paymentDate": "2026-01-01T00:00:00Z", "reference": "FT00000000", "paymentReason": "Mobile Banking Transfer", "transferredAmount": 260, "serviceCharge": 0.61, "vat": 0.08, "totalAmount": 260.61, "currency": "ETB", "vatReceiptNo": "FT00000000", "transactionType": "ACNX", "processingDate": "20260101", "chargeCode": "WAIVE", "commissions": [{ "type": "COMFTMB", "amount": 0.5, "currency": "ETB" }], "taxes": [{ "type": "15", "amount": 0.08, "currency": "ETB" }], "paymentDetails": ["Mobile Banking Transfer"], "creditAmount": 260 } ``` ## 502: parsed but invalid The upstream answered with 200 but the body didn't carry enough to validate as a real receipt (no `reference`, or telebirr lacked `receiptNo`/`payerName`/`transactionStatus`). The partial receipt is still returned so you can inspect what was extracted. ```json { "ok": false, "error": "parsed PDF but didn't look like a CBE receipt", "receipt": { "...": "..." } } ``` ## GET /api/verify Returns `{ ok: true, ts }`. No auth; used by uptime monitors. --- Source: https://links.et/docs/verify-image # 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": "" }] } ``` | 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/`). 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. --- Source: https://links.et/docs/integration-flows # Integration flows > Three ways to consume /api/verify (short wait, polling, and SSE) plus idempotency replay. Pick whichever matches how your app handles latency. `POST /api/verify` defaults to **synchronous**: the call blocks until the upstream finishes, then returns the receipt. For most callers (a single on-demand verification triggered by a user click) that's fine. When you can't or don't want to hold the request open (mobile network, server with strict request budgets, queue-of-many architecture), use one of the three async-aware flows below. All three share the same submit endpoint and result envelope; what differs is **how the client waits**. ## Short Wait Submit with `waitMs` to ask the server to block up to *waitMs* milliseconds. If the verification finishes before the deadline, you get the same `200`/`502` response a sync call would return. If it doesn't, you get `202` + a request id and the URLs you'd use to poll or stream. ```bash curl -sX POST https://links.et/api/verify \ -H "x-api-key: vk_live_..." \ -H "content-type: application/json" \ -d '{ "url": "https://transactioninfo.ethiotelecom.et/receipt/ABCD1234EF", "waitMs": 3000 }' ``` ### Completed inside the wait window: `200` ```json { "ok": true, "processingStatus": "completed", "requestId": "550e8400-e29b-41d4-a716-446655440000", "providerKey": "telebirr", "resolvedUrl": "...", "receipt": { "...": "..." }, "cached": false, "httpStatus": 200, "error": null } ``` ### Still running at the deadline: `202` ```json { "processingStatus": "queued", "requestId": "550e8400-e29b-41d4-a716-446655440000", "statusUrl": "/api/verify/550e8400-e29b-41d4-a716-446655440000", "eventsUrl": "/api/verify/550e8400-e29b-41d4-a716-446655440000/events" } ``` The verification keeps running on the server. Follow up via polling or SSE; both work against the same `requestId`. `waitMs` is capped at **30 000 ms** server-side; anything higher is silently clamped. ## Polling Use when your environment can't keep a long-lived connection open but you can call back every few seconds. ```bash while true; do RESP=$(curl -s -H "x-api-key: vk_live_..." \ "https://links.et/api/verify/$REQUEST_ID") STATUS=$(jq -r '.processingStatus' <<< "$RESP") if [ "$STATUS" = "completed" ] || [ "$STATUS" = "failed" ]; then echo "$RESP" | jq . break fi sleep 2 done ``` ### While the request is still queued: `202` ```json { "processingStatus": "queued", "requestId": "550e8400-e29b-41d4-a716-446655440000", "statusUrl": "/api/verify/...", "eventsUrl": "/api/verify/.../events" } ``` ### Once the row resolves: `200` / `502` Same envelope as the sync flow, with `processingStatus` and `requestId` added: ```json { "ok": true, "processingStatus": "completed", "requestId": "550e8400-e29b-41d4-a716-446655440000", "providerKey": "telebirr", "receipt": { "...": "..." }, "cached": false, "httpStatus": 200, "error": null } ``` **Cadence suggestion:** 1.5 to 3 s between polls. Faster than one per second buys nothing, because the verification runs at its own pace regardless. A poll reads an existing row, so it counts against your **per-minute request rate** but never against your plan's verification cap. Only the original submit can spend an uncached verification, and only if the receipt was not already cached. See [rate limits](/docs/rate-limits) for the difference. ## Server-Sent Events (SSE) Use when the client can keep a connection open and wants the result as soon as it's ready (instead of polling on a timer). ```bash curl -N \ -H "Accept: text/event-stream" \ "https://links.et/api/verify/$REQUEST_ID/events?x-api-key=vk_live_..." ``` `EventSource` from the browser: ```js const es = new EventSource( `/api/verify/${requestId}/events?x-api-key=${apiKey}`, ); es.addEventListener('status', (e) => { const d = JSON.parse(e.data); console.log('status:', d.status); }); es.addEventListener('completed', (e) => { const d = JSON.parse(e.data); console.log('done', d.receipt); es.close(); }); es.addEventListener('failed', (e) => { const d = JSON.parse(e.data); console.error('failed', d.error); es.close(); }); es.addEventListener('timeout', () => { // The server caps the stream at 5 min. Reconnect to pick up where you // left off; pending state is replayed on connect. es.close(); }); ``` ### Event types | Event | When | Payload | |---|---|---| | `status` | When the row is created (`pending`) and on any intermediate transition | `{ type: 'status', status: 'pending' \| 'processing', ts }` | | `completed` | Verification finished successfully. Stream closes after. | `{ type: 'completed', ok: true, cached, receipt, httpStatus, ts }` | | `failed` | Verification finished with an error. Stream closes after. | `{ type: 'failed', error, httpStatus, ts }` | | `timeout` | The server is closing the stream at the 5-minute lifetime cap. | `{ type: 'timeout', message }` | The stream also sends an unnamed comment (`: ping`) every 15 s to keep proxies from idling the connection. ### Replay-on-reconnect If the verification resolves between your submit call and the moment you subscribe to the SSE stream, you don't miss the terminal event; the server replays the last-seen event on connect for ~5 minutes. After that, the in-memory replay is evicted (the row stays in Postgres; the GET polling endpoint still returns the resolution forever). ## Idempotency Add an `Idempotency-Key` header to `POST /api/verify` to make retries safe. The first call with a given key (per API key) creates a request and returns its id. Any subsequent call with the **same** key replays the current state of that same request; even if the URL differs in the retry, you get the original. ```bash curl -X POST https://links.et/api/verify \ -H "x-api-key: vk_live_..." \ -H "Idempotency-Key: 7e1a4c34-..." \ -H "content-type: application/json" \ -d '{"url":"...","waitMs":2000}' ``` A `200` / `202` / `502` will look identical to the first response, including the same `requestId`, for as long as the row exists. Use a UUID (or any stable string up to 256 chars) generated by the client per logical operation. Idempotency rows are kept indefinitely; there's no TTL today. ## When to use which | Flow | Best for | Trade-off | |---|---|---| | **Sync** *(default)* | One-off verifications triggered by user interaction; cached receipts | Holds the HTTP request open. Instant on a cache hit, but see below for the worst case | | **Short wait** | Cached-most-of-the-time receipts where you accept queue fallback when it isn't cached | Up to 30 s of held connection in the worst case | | **Polling** | Background jobs, queue workers, mobile networks | Slightly stale; uses more HTTP requests | | **SSE** | Browser UIs that want the result instantly | Needs a long-lived connection; harder to put behind some proxies | You can mix and match. A common pattern is: submit with `waitMs: 2000`. If `202`, open SSE for the result. If your SSE connection drops, fall back to polling once a second. ## What a busy bank does to a sync call A cached receipt returns immediately. An uncached one has to reach the bank, and we are a polite client: each upstream host gets a token bucket per egress identity, so when a bank is saturated your call **waits for a slot** before the fetch even starts. That makes the sync worst case much longer than one fetch timeout: | Stage | Bound | |---|---| | Waiting for an upstream slot | 30 s, longer on a deliberately slow host, hard ceiling 120 s | | The fetch itself | 20 s per attempt | | One retry, on a different egress identity | 20 s | So a sync call against a struggling bank can hold the connection for well over a minute. There is one retry, never more, and none at all for a metered link like Siinqee. If the queue is full or the wait runs out, the call gives up rather than hanging, and the error reads like `gave up waiting 34s for a slot on transactioninfo.ethiotelecom.et`. Today that returns **HTTP 400**, because the response never got an upstream status code to report. It is a "come back later", not a complaint about your request, so retry it with backoff the way you would a 502. This is the reason `waitMs` exists. Submit with a deadline you are happy to hold, take the `202`, and pick the result up by polling or SSE instead of holding a socket open through a bank outage. --- Source: https://links.et/docs/agents # Coding agents > A drop-in agent file, llms.txt, and a markdown twin of every docs page. Most integrations now get written with a coding agent in the loop. These four URLs exist so that agent can read the API without you pasting docs into a chat window. ## The agent file [`https://links.et/agents.md`](/agents.md) is a single file covering the base URL, auth, both endpoints, the response envelope, every error code with its retry rule, the limits, and the handful of things that are easy to get wrong. Drop it into your repo under whatever filename your tool reads: ```bash curl -sL https://links.et/agents.md -o AGENTS.md # or append it to a file you already keep curl -sL https://links.et/agents.md >> CLAUDE.md ``` `https://links.et/AGENTS.md` serves the same file, for when that spelling is what you reach for. Or skip the download and hand your agent the URL. Anything that can fetch a page can read it. ## llms.txt [`/llms.txt`](/llms.txt) is the index: what links.et is, then one line per docs page with a link to its markdown. It follows the [llmstxt.org](https://llmstxt.org) convention, so tools that look for that file will find it. [`/llms-full.txt`](/llms-full.txt) is every page concatenated into one file. Use it when you would rather spend one fetch than ten. ## Markdown twin of any page Add `.md` to a docs URL and you get the raw markdown instead of the rendered page: | Page | Markdown | |---|---| | `/docs` | [`/docs.md`](/docs.md) | | `/docs/verify` | [`/docs/verify.md`](/docs/verify.md) | | `/docs/providers/cbe` | [`/docs/providers/cbe.md`](/docs/providers/cbe.md) | The markdown is the source of the page with the frontmatter turned into a heading and the inline HTML flattened, so a table of bank logos reads as a table of bank names. ## What not to point an agent at The demo endpoints on the landing page (`/api/demo-verify` and `/api/demo-verify-image`) take no API key and are metered per IP. They are there so a person can try the service. Anything built against them stops working, so keep them out of the code your agent writes. Point it at [`POST /api/verify`](/docs/verify) with a real key. --- Source: https://links.et/docs/providers # Providers overview > Every supported receipt host, the source tag each one stamps on its receipt, and the shape it returns. `links.et` resolves an inbound URL or reference into an upstream call against one of these hosts, parses the response, and returns a provider-tagged receipt object. The `source` field on the receipt tells you which schema you are looking at, so switch on that rather than on the host. **17 banks and wallets**, plus one point-of-sale format. | Provider | Source tag | Hosts | Return shape | |---|---|---|---| | Telebirr | `telebirr-html` | `transactioninfo.ethiotelecom.et` | HTML scraped | | CBE, PDF | `cbe-pdf` | `apps.cbe.com.et` | PDF parsed | | CBE, JSON | `mb-json` | `mb.cbe.com.et` · `mbreciept.cbe.com.et` | JSON re-mapped | | CBE Birr | `cbebirr-pdf` | `cbepay1.cbe.com.et` · `cbepay2.cbe.com.et` | PDF parsed | | Bank of Abyssinia | `boa-json` | `cs.bankofabyssinia.com` | JSON envelope flattened | | Zemen Bank | `zemen-pdf` | `share.zemenbank.com` | PDF parsed | | Awash Bank | `awash-html` | `awashpay.awashbank.com:8225` | HTML scraped | | Dashen Bank | `dashen-pdf` | `receipts.dashenbanksc.com` | PDF parsed | | Dashen Super App | `dashen-html` | `receipt.dashensuperapp.com` · `api.dashensuperapp.com` | HTML scraped | | M-PESA | `mpesa-pdf` | `m-pesabusiness.safaricom.et` | PDF parsed | | COOPay Ebirr | `ebirr-html` | `receipt.ebirr.com/coopay/…` | HTML scraped | | Kaafi Ebirr | `ebirr-html` | `receipt.ebirr.com/kaafimf/…` | HTML scraped | | Amhara Bank | `amhara-json` | `receipt.amharabank.com.et` · `transaction.amharabank.com.et` | JSON re-mapped | | Abay Bank | `abay-html` | `abaymobile.com.et` | HTML scraped | | Berhan Bank | `berhan-pdf` | `transactioninfo.berhanonline.et` | PDF parsed | | Oromia Bank | `oromia-pdf` | `obinfo.oromiabank.com` | PDF parsed | | Ahadu Bank | `ahadu-pdf` | `receipt.ahadubank.com` | PDF parsed | | Siinqee Bank | `siinqee-pdf` | `receipts.siinqeebank.com:871` | PDF parsed | | ZamZam Bank | `zamzam-json` | `receipt.zamzambank.com.et` | JSON re-mapped | | HuluBeje (point of sale) | `hulubeje-dxxrdv` | `et.hulubeje.com` | Export parsed | Both Ebirr brands share one host and one parser, and are told apart by the first path segment. `coopay` and `kaafi` are separate providers on the receipt. Two things to remember: - Reference numbers shown in these docs are **placeholders** (`ABCD1234EF`, `FT00000000`, and so on). Substitute your own when testing. - Receipts are **immutable once issued**, so we cache them forever. The second hit on the same URL returns from cache and carries `x-cache: HIT`. Cache hits never count against your plan's verification cap. ## Screenshots, and the one provider that is different `POST /api/verify-image` reads a screenshot, finds the reference or the QR, and then verifies it upstream the normal way, so the receipt you get back is the bank's copy rather than anything read off the image. Bank of Abyssinia is the exception. Its slip page encrypts the receipt fields into the QR instead of linking to a page we could fetch, and the 12-character reference it prints will not resolve on its own. There the QR payload is the answer, and the `upstream` object says so plainly: ```json { "attempted": false, "reason": "qr_receipt", "provider": "boa", "receipt": { "source": "boa-qr", "transactionReference": "FT00000000" } } ``` `attempted: false` means nothing was fetched from the bank. Those fields came out of the image. Treat them as weaker evidence than any other receipt here: a QR is tamper-evident, not signed, so editing the amount in a screenshot leaves the QR disagreeing with it, but anyone can mint a matching code. Account numbers arrive already masked by the bank, so the QR reveals nothing the screenshot did not already show. ## Siinqee has a five-view meter `receipts.siinqeebank.com` allows about **five requests per receipt reference, globally rather than per caller**, and counts failed attempts too. Once a link is spent, nobody can open it again, including the customer. We hold to that budget: a Siinqee link is fetched once per verification with no egress retry, and a failure is cached for 24 hours rather than the usual 30 minutes, so retries spread over days instead of burning the budget in an afternoon. Your side of the bargain is to **not** poll a Siinqee reference. Ask once, then read the cached answer as often as you like, since cache hits never touch the bank. ## Availability Provider hosts go down on their own schedule, and a receipt that will not verify is usually the bank rather than us. `apps.cbe.com.et` (the CBE PDF host) has been unreachable since July 2026, so CBE receipts resolve through the JSON host instead. [The status page](/status) probes every host on this list every few minutes and shows the current state. --- Source: https://links.et/docs/providers/telebirr # Telebirr > Detail page: URL format, reference shape, return type, and quirks for telebirr receipts. Telebirr's official receipt endpoint at `transactioninfo.ethiotelecom.et` returns a bilingual (Amharic + English) HTML page; we scrape it with regex and return a structured object. ## URL format ``` https://transactioninfo.ethiotelecom.et/receipt/ ``` | Part | Pattern | Example | |---|---|---| | host | literal | `transactioninfo.ethiotelecom.et` | | `` | 8–14 chars `[A-Z0-9]` | `ABCD1234EF` | You can also pass just the reference via `{ "reference": "ABCD1234EF" }`; telebirr is the only provider where the bare-reference shortcut works directly through `/api/verify`. ## Return type `receipt.source === "telebirr-html"`. All fields are optional strings, present when the upstream HTML included them. ```ts interface TelebirrReceipt { source: 'telebirr-html'; // Counterparties payerName?: string; payerTelebirrNo?: string; // masked: "251********" payerAccountType?: string; // e.g. "Individual Customer" creditedPartyName?: string; creditedPartyAccountNo?: string; // masked // Transaction header transactionStatus?: string; // "Completed" | … receiptNo?: string; // matches the URL reference paymentDate?: string; // "DD-MM-YYYY HH:MM:SS" // Amounts (strings; they include the " Birr" suffix as telebirr renders it) settledAmount?: string; serviceFee?: string; serviceFeeVAT?: string; totalPaidAmount?: string; // Payment metadata paymentReason?: string; paymentMode?: string; paymentChannel?: string; } ``` ### Sample (placeholder values) ```json { "source": "telebirr-html", "payerName": "", "payerTelebirrNo": "251********", "payerAccountType": "Individual Customer", "creditedPartyName": "", "creditedPartyAccountNo": "251********", "transactionStatus": "Completed", "receiptNo": "ABCD1234EF", "paymentDate": "01-01-2026 00:00:00", "settledAmount": "100 Birr", "serviceFee": "1.74 Birr", "serviceFeeVAT": "0.26 Birr", "totalPaidAmount": "102 Birr", "paymentReason": "Send Money to Registered Customer", "paymentMode": "telebirr", "paymentChannel": "API/App" } ``` ## Quirks - **Bilingual labels**: the upstream label format is `/` (e.g. `የክፍያው ሁኔታ/transaction status`). We anchor on the English tail only to sidestep Unicode NFC/NFD drift on the Amharic prefix. - **Amount strings carry the unit**: `"100 Birr"`, not a number. If you need a numeric, strip the trailing ` Birr` client-side. - **One row in the HTML is malformed**: the `transaction status` `` is missing its closing tag in the upstream output. Our parser tolerates this; it's why we don't reject receipts that look incomplete in raw HTML. - **Rate limit**: telebirr's edge rate-limits aggressively. links.et's outbound queue throttles to ~1 request every 1.5 s for this host, with adaptive backoff on `429`. You won't usually hit this from `/api/verify` because cache hits don't touch upstream. --- Source: https://links.et/docs/providers/cbe # CBE > Detail page: four URL shapes (two short-link variants and two direct endpoints), two response schemas (PDF and JSON), and how the resolver routes between them. CBE has the busiest URL surface of any provider: four supported shapes that resolve to one of two upstream backends. Pass any of them; the resolver figures out the right call. ## URL formats ### 1. Short-link, **dashed** id → PDF backend ``` https://mbreciept.cbe.com.et/ # id contains a "-" ``` | Part | Pattern | Example | |---|---|---| | host | literal | `mbreciept.cbe.com.et` | | `` | `FT[A-Z0-9]+-\d+` | `FT00000000-12345678` | **Resolution:** dash is stripped → call `apps.cbe.com.et:100/?id=` which returns a PDF. We parse with `pdf-parse`. ### 2. Short-link, **plain** token → JSON backend ``` https://mbreciept.cbe.com.et/ # no dash, mixed case ``` | Part | Pattern | Example | |---|---|---| | `` | 16+ `[A-Za-z0-9]`, at least one lowercase | `fHCxz62uM8157lCfet` | **Resolution:** passed through to `mb.cbe.com.et/api/v1/transactions/public/transaction-detail/` which returns JSON. ### 3. Direct PDF ``` https://apps.cbe.com.et:100/?id= https://apps.cbe.com.et:100/BranchReceipt/ ``` Pass-through. Returns a PDF. ### 4. Direct JSON ``` https://mb.cbe.com.et/api/v1/transactions/public/transaction-detail/ ``` Pass-through. Returns JSON. ## Return types CBE has **two** response shapes, depending on which backend the URL resolves to. The `source` field discriminates: present (`"mb-json"`) for the JSON backend, **absent** for the PDF backend (the legacy parser). ### PDF: `source` is absent ```ts interface CbePdfReceipt { // No `source` field; discriminate by its absence + providerKey === 'cbe' // Counterparties (mostly strings: names + masked accounts) payerName?: string; payerAccount?: string; // masked: "1****0000" receiverName?: string; receiverAccount?: string; // masked // Transaction paymentDate?: string; // free text, e.g. "1/1/2026, 00:00:00 AM" reference?: string; // FT-prefixed id paymentReason?: string; // free text // Amounts (numeric, already parsed) transferredAmount?: number; serviceCharge?: number; vat?: number; totalAmount?: number; currency?: string; // "ETB" | "USD" | … amountInWords?: string; // Customer block (receipt holder profile) customerName?: string; branch?: string; // Company-info block (CBE's own VAT registration) vatReceiptNo?: string; vatRegistrationNo?: string; vatRegistrationDate?: string; } ``` ### JSON: `source: "mb-json"` Same base fields as PDF, plus everything `mb.cbe.com.et` returns that the PDF doesn't expose. ```ts interface CbeMbJsonReceipt { source: 'mb-json'; // Shared with PDF payerName?: string; payerAccount?: string; receiverName?: string; receiverAccount?: string; paymentDate?: string; // ISO 8601, e.g. "2026-01-01T00:00:00Z" reference?: string; paymentReason?: string; transferredAmount?: number; serviceCharge?: number; vat?: number; totalAmount?: number; currency?: string; vatReceiptNo?: string; // JSON-only enrichments transactionType?: string; // e.g. "ACNX" processingDate?: string; // "YYYYMMDD" debitValueDate?: string; creditValueDate?: string; chargeCode?: string; // e.g. "WAIVE" debitTheirRef?: string; creditTheirRef?: string; commissions?: Array<{ type: string; amount: number; currency: string }>; taxes?: Array<{ type: string; amount: number; currency: string }>; paymentDetails?: string[]; // free-text notes creditAmount?: number; // usually equal to debitAmount minus rounding } ``` ### Sample: PDF backend (placeholder values) ```json { "payerName": "", "payerAccount": "1****0000", "receiverName": "", "receiverAccount": "1****0000", "paymentDate": "1/1/2026, 00:00:00 AM", "reference": "FT00000000", "paymentReason": "Account Transfer", "transferredAmount": 100, "serviceCharge": 1.74, "vat": 0.26, "totalAmount": 102, "currency": "ETB", "amountInWords": "", "customerName": "", "branch": "", "vatReceiptNo": "ABC0000", "vatRegistrationNo": "0000", "vatRegistrationDate": "1/1/2020" } ``` ### Sample: JSON backend (placeholder values) ```json { "source": "mb-json", "payerName": "", "payerAccount": "1********0000", "receiverName": "", "receiverAccount": "1********0000", "paymentDate": "2026-01-01T00:00:00Z", "reference": "FT00000000", "paymentReason": "Mobile Banking Transfer", "transferredAmount": 260, "serviceCharge": 0.61, "vat": 0.08, "totalAmount": 260.61, "currency": "ETB", "vatReceiptNo": "FT00000000", "transactionType": "ACNX", "processingDate": "20260101", "chargeCode": "WAIVE", "commissions": [{ "type": "COMFTMB", "amount": 0.5, "currency": "ETB" }], "taxes": [{ "type": "15", "amount": 0.08, "currency": "ETB" }], "paymentDetails": ["Mobile Banking Transfer"], "creditAmount": 260 } ``` ## Quirks - **`mb.cbe.com.et` is gated**: without the right `x-app-id`, `x-app-version`, `Referer`, `Origin`, and `User-Agent`, CBE returns `500 Security Alert: Invalid or tampered legacy token!`. links.et sends the full browser-equivalent header set on every JSON request. - **PDF body has positional-only fields**: in the company-info block, labels are stacked first and values stacked second. Empty cells are omitted from values, which makes positional pairing fragile. We extract only the last three values (`vatReceiptNo` / `vatRegistrationNo` / `vatRegistrationDate`) which are stable across receipts. - **Two amount string formats**: `mb.cbe.com.et` sometimes prefixes amounts with the currency (`"ETB0.50"`), sometimes returns bare decimals (`"260.00"`). Our parser handles both. - **PDF endpoint runs on port `100`**: non-standard, but is the official URL. Direct port 443 doesn't work. --- Source: https://links.et/docs/providers/zemen # Zemen Bank > Detail page: URL token structure (24-char prefix + reference composite), PDF parsing quirks, and full return type. Zemen's `share.zemenbank.com` serves a PDF directly (with a download attachment header). We grab the bytes, parse with `pdf-parse`, and extract structured fields. ## URL format ``` https://share.zemenbank.com///pdf ``` | Part | Pattern | Example | |---|---|---| | host | literal | `share.zemenbank.com` | | `` | 2 letters | `rt` (retail / ATM) · `ft` (funds transfer) · others observed in the wild | | `` | 24 chars `[A-Za-z0-9]` | `00000000ABCDEFGHIJKLMNOP` | ### Token anatomy The 24-char token is `<8-char prefix><16-char reference>`. The trailing 16 chars are the actual **reference number** that appears in the receipt body under "Reference No". The leading 8 chars look like a branch / timestamp code that's only used at the URL level. ``` 00000000ABCDEFGHIJKLMNOP └──────┬─┘└──────┬─────┘ 8-char 16-char URL only reference (echoed in receipt) ``` The resolver accepts **any 2-letter prefix**, so when Zemen mints a new code (`cr`, `dr`, etc.) you don't need a redeploy on our side. ## Return type `receipt.source === "zemen-pdf"`. Amounts are numbers; currency is always `"ETB"` on these receipts. ```ts interface ZemenReceipt { source: 'zemen-pdf'; // Header invoiceNo?: string; date?: string; // e.g. "3-Jun-2026" // Counterparties payerName?: string; payerAccount?: string; // e.g. "173****0000(923141XXXXXX0000)" payerTin?: string; // empty unless payer is VAT-registered payerVat?: string; recipientName?: string; recipientAccount?: string; // masked // Transaction paymentOrderNumber?: string; reference?: string; // last 16 chars of the URL token transactionStatus?: string; // "COMPLETED" | … transactionDetail?: string; // e.g. "Remot On Us - ATM CASH WITHDRAWAL" // Amounts (numeric) settledAmount?: number; serviceCharge?: number; vat?: number; // 15% disasterRiskCharge?: number; // 5% totalAmountPaid?: number; currency?: 'ETB'; // always ETB on these receipts // Misc totalAmountInWord?: string; // e.g. "ONE HUNDRED ONE BIRR AND TWENTY CENT(S)" paymentReason?: string; // free text, often empty } ``` ### Sample (placeholder values) ```json { "source": "zemen-pdf", "invoiceNo": "000000000", "date": "1-Jan-2026", "payerName": "", "payerAccount": "173****0000(923141XXXXXX0000)", "payerTin": "", "payerVat": "", "recipientName": "", "recipientAccount": "00****00", "paymentOrderNumber": "", "reference": "ABCDEFGHIJKLMNOP", "transactionStatus": "COMPLETED", "transactionDetail": "Remot On Us - ATM CASH WITHDRAWAL", "settledAmount": 100, "serviceCharge": 1, "vat": 0.15, "disasterRiskCharge": 0.05, "totalAmountPaid": 101.20, "currency": "ETB", "totalAmountInWord": "ONE HUNDRED ONE BIRR AND TWENTY CENT(S)", "paymentReason": "" } ``` ## Quirks - **`Date` field uses the Amharic colon**: the top-of-document date label ends in `፡` (U+1361), not ASCII `:`. Our regex accepts both. - **No whitespace between table cells**: pdf-parse strips spacing such that `Service ChargeETB 1.00` arrives as one token. Amount-row regex uses `[ \t]*` between the label and `ETB`, not `\s+`. - **Empty-value rows collapse onto the next label**: when a field like "Payer TIN" is unset, pdf-parse emits the label followed immediately by a newline. Our label regex uses `[ \t]*` for the trailing whitespace so we don't slurp the next label's value into the current field. - **Some receipts return `HTTP 404 application/json`** when the reference is no longer available upstream (Zemen cycles them out of cache). We detect non-PDF bodies and surface a friendly `"receipt not found upstream"` error instead of a misleading `"pdf-parse failed"`. - **Real fetches are slow**: Zemen's PDF backend takes 5–15 s per call even when healthy. links.et's outbound queue serialises calls (one in flight at a time) for this host and cooldowns aggressively on `429`. --- Source: https://links.et/docs/providers/boa # Bank of Abyssinia > Detail page: public SPA URL, underlying API endpoint, token format, and return shape. Bank of Abyssinia's `cs.bankofabyssinia.com/slip/` is a React SPA that fetches its data from a same-origin JSON endpoint. We bypass the SPA entirely and hit the underlying API directly. ## URL format ### Public slip URL (what users share) ``` https://cs.bankofabyssinia.com/slip/?trx= ``` | Part | Pattern | Example | |---|---|---| | host | literal | `cs.bankofabyssinia.com` | | `` | `FT[A-Z0-9]{15}` (17 chars total) | `FT00000000000000A` | ### What we actually hit ``` GET https://cs.bankofabyssinia.com/api/onlineSlip/getDetails/?id= Accept: application/json Referer: https://cs.bankofabyssinia.com/slip/?trx= ``` ### Token anatomy The 17-char URL token is approximately `<12-char reference><5-char suffix>`. The reference echoed inside the receipt body (`transactionReference`) is just the leading 12 chars; the trailing 5 look like a channel/branch suffix. The API wants the **full 17**, not the inner reference. ## Return type `receipt.source === "boa-json"`. Amounts are numbers. ```ts interface BoaReceipt { source: 'boa-json'; // Identity transactionReference?: string; // 12-char inner reference paymentReference?: string; transactionDate?: string; // "DD/MM/YY HH:MM" transactionType?: string; // "Account Transfer" | … // Counterparties receiverName?: string; receiverAccount?: string; // masked: "1******00" // Amounts (numeric) transferredAmount?: number; serviceCharge?: number; vat?: number; totalAmount?: number; currency?: string; // "ETB" // Free-text transferredAmountInWord?: string; narrative?: string; // e.g. "Transfer" // Upstream envelope status (from header.status) upstreamStatus?: string; // typically "success" } ``` ### Sample (placeholder values) ```json { "source": "boa-json", "transactionReference": "FT00000000", "paymentReference": "", "transactionDate": "01/01/26 00:00", "transactionType": "Account Transfer", "receiverName": "", "receiverAccount": "1******00", "transferredAmount": 10, "serviceCharge": 0, "vat": 0, "totalAmount": 10, "currency": "ETB", "transferredAmountInWord": "TEN BIRR", "narrative": "Transfer", "upstreamStatus": "success" } ``` ## Quirks - **The slip page is a 456-byte React shell**: `curl` against the public URL returns the empty SPA; the receipt data only lands after the JS bundle fetches the API. We avoid that round trip entirely by going to the API directly. - **No payer fields are exposed**: unlike telebirr or CBE, BoA's public slip never includes the sender's name or account. Only the receiver side is visible. - **Envelope has a `header` wrapper**: the raw upstream returns `{ header: { status, audit, … }, body: [ { ...receipt } ] }`. We unwrap to a single object and lift `header.status` to `upstreamStatus`. - **Amount strings include the currency**: some upstream fields like `amountDebitedWithCurrency` arrive as `"ETB10.50"`. Our parser strips the optional 3-letter prefix. --- Source: https://links.et/docs/providers/awash # Awash Bank > Detail page: public receipt URL, page structure, captured fields, and return shape. Awash Bank's `awashpay.awashbank.com:8225` is a server-rendered static HTML "VAT Invoice / Customer Receipt" page. We fetch it directly and scrape the structured tables into JSON. ## URL format ``` https://awashpay.awashbank.com:8225/- ``` | Part | Pattern | Example | |---|---|---| | host | literal | `awashpay.awashbank.com` | | port | literal | `:8225` | | `` | leading dash + alphanumerics-and-dashes | `-ABCD1234EF-1A2B3C` | The leading dash on the path **is part of the URL**; every Awash receipt link starts with one. The token interior carries one or more additional dashes. ## What we actually hit The URL is passed through unchanged: no SPA bypass, no API endpoint substitution. A direct `GET` returns the receipt HTML in ~250 ms. ## Return type `receipt.source === "awash-html"`. The static company-info block (TIN `0000030100`, VAT Reg `17264`, etc.) is dropped; it's identical on every Awash receipt. We capture two sub-objects: `customer` (the receipt holder) and `transaction` (the payment itself). ```ts interface AwashReceipt { source: 'awash-html'; customer: { customerName?: string; // e.g. "" accountNo?: string; // masked: "XXXXX******XXXX/BANK" city?: string; // e.g. "Addis Ababa" vatRegNo?: string; // "-" when the customer isn't VAT-registered vatRegDate?: string; tinTaxId?: string; // labelled "TIN (TAX ID)" on the receipt branch?: string; // e.g. "" }; transaction: { transactionTime?: string; // "YYYY-MM-DD HH:MM:SS AM|PM" transactionType?: string; // see "Supported transaction types" below amount?: string; // string with currency: " ETB" vat?: string; // string with currency: " ETB" charge?: string; // service charge; present on wallet/bill receipts senderName?: string; senderAccount?: string; // masked: "XXXXX*******XXX" beneficiaryName?: string; beneficiaryAccount?: string; beneficiaryBank?: string; // e.g. "Commercial Bank of Ethiopia" reason?: string; // e.g. "Transfer" transactionId?: string; // 15-digit numeric (placeholder: "000000000000000") // Any label found in the transaction table that didn't map to one of the // typed fields above, e.g. "Branch", "CBS Trans ID" on WITHDRAW/DEPOSIT; // "Student Code", "Student Class", "School" on School Fees. Absent when // every label resolved to a typed field (IPS Bank Transfer, etc.). extra?: Record; }; } ``` Not every field is set on every receipt; Awash issues at least nine distinct layouts depending on `transactionType`, each carrying a different subset. ## Supported transaction types Awash uses synonym labels across receipt types (`Sender Name` / `Customer Name` / `Name`, `Beneficiary Account` / `Phone Number` / `CA Number`, etc.). The parser maps each synonym into the same typed field below. Type-specific labels we don't have first-class names for surface inside `transaction.extra`. | transactionType | `senderName` source | `beneficiaryName` / `…Account` source | Lands in `extra` | |---|---|---|---| | `IPS Bank Transfer` | Sender Name | Beneficiary Name + Account + Bank | n/a | | `Telebirr Transfer` | Customer Name | n/a / Phone Number | n/a | | `Send Money` | _customer fallback_ | Recipient | n/a | | `Bill Payment` | Customer Name | n/a / CA Number | Invoice Number, Invoice Date | | `Ethiotelecom Postpaid Bill` | Customer Name | n/a / Service Number | n/a | | `Bulk Payment` | Sender | Recipient | n/a | | `WITHDRAW` | _customer fallback_ | n/a | Branch, CBS Trans ID | | `DEPOSIT` | _customer fallback_ | n/a | Branch, CBS Trans ID | | `School Fees` | _customer fallback_ | Student Name | Student Code, Student Class, School, Payment Period | ### `senderName` fallback For receipt types where Awash doesn't include a separate sender field (`WITHDRAW`, `DEPOSIT`, `Send Money`, `School Fees`), the receipt-holder is implicitly the originating party, so `transaction.senderName` falls back to `customer.customerName`. This guarantees the validation rule below holds across all nine types; you can detect whether the fallback fired by comparing `transaction.senderName === customer.customerName`. ## Samples ### IPS Bank Transfer ```json { "source": "awash-html", "customer": { "customerName": "", "accountNo": "XXXXX******XXXX/BANK", "city": "Addis Ababa", "vatRegNo": "-", "vatRegDate": "-", "tinTaxId": "-", "branch": "" }, "transaction": { "transactionTime": "2026-01-01 00:00:00 AM", "transactionType": "IPS Bank Transfer", "amount": "100 ETB", "vat": "0.09 ETB", "senderName": "", "senderAccount": "XXXXX*******XXX", "beneficiaryName": "", "beneficiaryAccount": "1000000000000", "beneficiaryBank": "Commercial Bank of Ethiopia", "reason": "Transfer", "transactionId": "000000000000000" } } ``` ### Telebirr Transfer ```json { "source": "awash-html", "customer": { "customerName": "", "accountNo": "XXXXX******XXXX/BANK", "city": "Addis Ababa", "branch": "" }, "transaction": { "transactionTime": "2026-01-01 00:00:00 AM", "transactionType": "Telebirr Transfer", "amount": "100 ETB", "vat": "0.09 ETB", "charge": "1 ETB", "senderName": "", "senderAccount": "XXXXX*******XXX", "beneficiaryAccount": "251********", "reason": "Transfer", "transactionId": "000000000000000" } } ``` ### School Fees (with `extra`) ```json { "source": "awash-html", "customer": { "customerName": "", "branch": "" }, "transaction": { "transactionTime": "2026-01-01 00:00:00 AM", "transactionType": "School Fees", "amount": "100 ETB", "senderName": "", "beneficiaryName": "", "transactionId": "000000000000000", "extra": { "Student Code": "", "Student Class": "", "School": "", "Payment Period": "" } } } ``` ### WITHDRAW (with `extra`) ```json { "source": "awash-html", "customer": { "customerName": "", "branch": "" }, "transaction": { "transactionTime": "2026-01-01 00:00:00 AM", "transactionType": "WITHDRAW", "amount": "100 ETB", "senderName": "", "senderAccount": "XXXXX*******XXX", "transactionId": "000000000000000", "extra": { "Branch": "", "CBS Trans ID": "" } } } ``` ## Quirks - **The receipt page is a static HTML render**: no SPA hydration needed, `curl` returns the full page. The embedded QR code re-encodes the same URL the page was served from. - **Amounts are strings with currency suffix**: `"1800 ETB"`, not `1800`. Awash ships them this way; we preserve verbatim so the caller can decide whether to strip ` ETB` or keep it. - **Duplicate field labels**: "VAT Reg No" and "VAT Reg Date" appear in both the company-info section (dropped) and the customer-info section (captured). The parser slices by the second `` to avoid the company's static `17264` shadowing the customer's `-`. - **Multi-payee beneficiary names**: Awash receipts for batch transfers contain `&`-separated names (e.g. `" & &"`). HTML entities are decoded before return. - **Two different masks for the same account**: the receipt-holder's account on `customer.accountNo` carries one mask shape (e.g. `XXXXX******XXXX/BANK` with the `/BANK` suffix), while `transaction.senderAccount` carries a slightly different one (e.g. `XXXXX*******XXX`, no suffix). Awash masks them at the source; we don't unmask or re-mask. - **`transaction.extra` is type-specific**: same parser, different layout per `transactionType`. If you only consume the typed fields, `extra` can be ignored; if you need to render the full receipt faithfully, iterate over `extra` and render its `(key, value)` pairs alongside. - **Validation rule**: receipt is considered valid only when `transaction.transactionId`, `transaction.senderName`, AND `transaction.amount` are all present. With the customer-fallback for `senderName`, this passes for all nine receipt types whenever the upstream HTML is complete. --- Source: https://links.et/docs/health # Health / Status API > Two open endpoints for monitors and dashboards: a single-purpose health probe and a structured per-component status snapshot. Both endpoints are **open** (no API key required), **cached** for ~15 s at the edge plus 30 s in-process so a flood of probes never burns through to the underlying checks, and **safe to hit from any host** (no rate-limit attribution). ## `GET /api/verify`: minimal health Use when all you need is "is links.et alive". Always returns 200 with a tiny JSON body. ```bash curl https://links.et/api/verify ``` ```json { "ok": true, "ts": "2026-06-11T19:23:11.482Z" } ``` Don't use this to check whether an **upstream** (telebirr, CBE, …) is healthy; this endpoint only reports on `links.et` itself. ## `GET /api/status`: structured per-component status Use when you want to know which upstream is degraded or what response times look like right now. ```bash curl https://links.et/api/status ``` ### Response | HTTP | When | |------|------| | `200` | Overall status is `operational` or `slow` | | `503` | Overall status is `down` (at least one component returned a 5xx or timed out) | ```json { "ok": true, "status": "operational", "checkedAt": "2026-06-11T19:23:11.482Z", "cached": true, "components": [ { "name": "verify-web (this service)", "host": "links.et", "group": "internal", "status": "operational", "responseMs": 0, "httpStatus": 200, "error": null }, { "name": "Postgres (auth + API keys)", "host": "127.0.0.1:5432", "group": "internal", "status": "operational", "responseMs": 35, "httpStatus": null, "error": null }, { "name": "Telebirr — transactioninfo.ethiotelecom.et", "host": "transactioninfo.ethiotelecom.et", "group": "upstream", "status": "operational", "responseMs": 522, "httpStatus": 200, "error": null } ] } ``` ### Field reference | Field | Type | Notes | |---|---|---| | `ok` | boolean | `true` unless overall status is `down` | | `status` | enum | `operational` \| `slow` \| `down`; worst across all components | | `checkedAt` | ISO timestamp | When the underlying probe round was last taken | | `cached` | boolean | `true` if the snapshot came from the in-process 30 s cache | | `components[].name` | string | Human label (e.g. `"Telebirr — transactioninfo.ethiotelecom.et"`) | | `components[].host` | string | Upstream host or internal address | | `components[].group` | enum | `internal` (the links.et service itself) or `upstream` | | `components[].status` | enum | Per-component `operational` / `slow` / `down` | | `components[].responseMs` | number \| null | Probe round-trip time | | `components[].httpStatus` | number \| null | HTTP status the upstream returned (or `null` for non-HTTP probes like Postgres) | | `components[].error` | string \| null | Network or timeout error message when the probe failed | ### Classification rules (same as the human-readable /status page) - `down`: probe failed or upstream returned HTTP `>= 500` - `slow`: probe succeeded but took longer than the per-component slow threshold (typically 1.5 s) - `operational`: probe succeeded under threshold ## `HEAD /api/status`: uptime-monitor friendly Same status code semantics as `GET /api/status`, no body. Cheapest possible probe; recommended for monitors that only need to react to `200` vs `503`. ```bash curl -I https://links.et/api/status # HTTP/2 200 # cache-control: public, max-age=15, s-maxage=15 ``` ## Polling cadence The in-process snapshot refreshes every 30 s and the public response is cache-controlled for 15 s. A polling interval **between 30 s and 5 minutes** is plenty; anything faster gets you the same cached snapshot, just with extra bandwidth. --- Source: https://links.et/docs/errors # Errors > Error envelope and codes. ## Envelope All error responses share the same JSON shape: ```json { "ok": false, "error": { "code": "rate_limited", "message": "Rate limit exceeded. Maximum 60 requests per minute." } } ``` ## Codes | HTTP | Code | When | |---|---|---| | 400 | `invalid_json` | Request body was not valid JSON. | | 400 | `invalid_request` | JSON parsed but failed schema validation. Carries `issues`. | | 400 | (none) | No upstream status to report: the URL did not resolve to a supported provider, or the call never reached the bank. Read `error`. | | 401 | `missing_key` | No `x-api-key` header. | | 401 | `invalid_key` | Key not recognised. | | 401 | `revoked_key` | Key was revoked. | | 429 | `rate_limited` | Hit the per-minute request rate. Includes `Retry-After`. | | 429 | `quota_exceeded` | Hit the plan's uncached verification cap. Carries `used` and `cap`, plus `Retry-After` to the window rollover. | | 429 | `image_cap_reached` | Hit the plan's monthly screenshot read allowance. `POST /api/verify-image` only. | | 429 | `ocr_daily_cap_reached` | The service-wide daily screenshot budget is spent. Rare, and not about your account. | | 503 | `provider_down` | The bank is failing every health check we run, so we did not queue a lookup that had no answer in it. Includes `Retry-After`. Never counts against your cap. | | 502 | (none) | The bank answered but the receipt failed validation. See `error` and the partial `receipt`. | There is **no 504**. A call that times out or gives up waiting for a busy bank has no upstream status code to report, so it comes back as a 400 with the reason in `error`. See [what a busy bank does](/docs/integration-flows#what-a-busy-bank-does-to-a-sync-call). `POST /api/verify-image` can also return `ai_not_configured` (503, the extractor is not set up on this deployment) and `detection_failed` (502, the extractor ran but could not read a receipt reference out of the image). ## When a bank is down We probe every upstream about every five minutes, from each of our egress addresses. When the most recent round has a bank failing on **all** of them, a verification for that bank returns `503 provider_down` straight away instead of queueing a call that would spend two 20 s attempts to reach the same conclusion. ```json { "ok": false, "error": { "code": "provider_down", "message": "Telebirr is not answering us right now (every check has failed for 25 minutes). We did not spend a lookup on it. Try again in a few minutes." } } ``` `Retry-After` holds 300 s, the probe interval — there is no point asking again before we have looked again. A single address failing is not enough to trigger this, because that looks like our IP being blocked rather than the bank being down. [`GET /api/status`](/docs/health) shows the same picture per component. ## Retry guidance - **`rate_limited`** wait the `Retry-After` interval, then try again. - **`provider_down`** wait the `Retry-After` interval. Retrying sooner cannot work: nothing is asked of the bank until our next probe round says it is answering again. It costs you nothing, so a queued job can simply try later. - **`quota_exceeded`** and **`image_cap_reached`** do not retry on a timer. `Retry-After` can be days away, because the window is your plan's, not a minute. Upgrade, enable overage, or wait for the rollover. - **502** and the busy-bank **400** are usually the bank rather than you. Retry after 1 to 2 s with jitter and treat three consecutive failures as a real outage. Do not retry a Siinqee reference at all: those links allow about five views in total, so a retry loop destroys the receipt. See [providers](/docs/providers). - **401** never retry. Mint a new key from the dashboard. - **400 `invalid_request`** never retry. The body is wrong. --- Source: https://links.et/docs/rate-limits # Rate limits > Two different ceilings, the request rate and the plan's verification cap, plus the screenshot read allowance. Two separate ceilings apply to every call, and they fail with different codes: - **Request rate**, per key, per minute. Protects the service from a hot loop. - **Verification cap**, per plan, per window. Counts only receipts we fetched from a bank and delivered to you. This is the one tied to what you pay. A third, the **screenshot read allowance**, applies to `POST /api/verify-image` only. ## Request rate Every key has a per-minute budget, **60 requests/minute** by default. Each request increments a bucket keyed on the current minute. Go over it and the rest of that minute returns `429 rate_limited` with a `Retry-After` header holding the seconds until the next minute boundary. The limit is stored per key, so it can be raised on one key without touching the others. Ask if 60 is not enough. ## Verification cap Only **uncached, successful** verifications count. Two things are always free: - **A receipt we have already fetched.** It is served from cache, costs us nothing, and never counts however many times you ask for it. - **A verification that failed.** A timeout, a bank that is down, a receipt we could not parse — none of it touches your cap. You are charged for receipts delivered, not for attempts, and never for our own bad days. That is why these numbers can be stated plainly rather than hidden behind a fair-use asterisk. | Plan | Uncached receipts | Window | Over the cap | |---|---|---|---| | Free | 200 | calendar month | Hard stop | | Shop | 2,000 | day | Hard stop | | Developer | 30,000 | calendar month | 0.05 ETB each, opt-in | | Scale | 150,000 | calendar month | 0.03 ETB each, opt-in | | Enterprise | Per contract | calendar month | 0.03 ETB each, opt-in | Overage is **off by default on every plan that offers it**. Until you turn it on from the dashboard, passing the cap stops the call rather than billing you for it. Free and Shop have no overage path at all. Passing the cap returns `429 quota_exceeded`, not `rate_limited`. The body carries `used` and `cap`, and `Retry-After` holds the seconds until the window rolls over: ```json { "ok": false, "error": { "code": "quota_exceeded", "message": "Plan 'Free' allows 200 new (uncached) verifications per month. Cache hits and failed lookups are always free. Upgrade or enable overage to continue.", "used": 200, "cap": 200 } } ``` Waiting out a `rate_limited` takes seconds. Waiting out a `quota_exceeded` can take until the end of the month, so treat them differently in your retry code. ## Screenshot reads `POST /api/verify-image` runs the image through an AI extractor before it can verify anything, and that call costs us money whether or not the receipt turns out to be cached. It gets its own allowance, counted **per calendar month on every plan**, whatever window that plan's verification cap uses: | Plan | Screenshot reads / month | |---|---| | Free | 200 | | Shop | 1,000 | | Developer | 2,000 | | Scale | 6,000 | | Enterprise | Per contract | There is no overage path here: reaching the allowance returns `429 image_cap_reached` until the month rolls over. Reading a screenshot is independent of verifying the receipt it contains, so a read that resolves to an already-cached receipt still spends one read and no verification. ## Priority When a bank is saturated and calls have to queue, the plan decides who is served first: Scale and Enterprise, then Developer, then Shop and Free. Priority changes your position in the queue, never your cap. ## Anonymous demo The landing page demo takes no API key and is metered per IP, not per account: **10 verifications and 3 screenshot reads per hour**. It exists so you can try the thing before signing up. Anything built against it will stop working. ## Raising a limit Email [robelmezemir@gmail.com](mailto:robelmezemir@gmail.com) with your key prefix and what you are building. Request rate and Enterprise caps are set per key; plan caps move by changing plan.