# links.et for coding agents

> Served at https://links.et/agents.md. Copy it into your own repo as
> AGENTS.md, CLAUDE.md or a Cursor rule so your agent can integrate links.et
> without reading the whole docs site.

links.et verifies Ethiopian payment receipts at the source. You send a receipt
URL, a reference or a screenshot. links.et fetches the receipt from the bank
over an Ethiopia-egress link, parses it, and returns JSON. 17 banks and
wallets, one response envelope.

Use it when your backend cannot reach an Ethiopian bank from its region, or
when you need a receipt confirmed by the bank rather than typed in by a user.

## Base URL and auth

Base URL: `https://links.et`

Pass your key in the `x-api-key` header. Keys start with `vk_live_` and are
minted at https://links.et/dashboard/keys. The plaintext is shown once, so
store it in your secret manager on first sight.

```bash
curl -X POST https://links.et/api/verify \
  -H "x-api-key: $LINKS_ET_API_KEY" \
  -H "content-type: application/json" \
  -d '{"url":"https://transactioninfo.ethiotelecom.et/receipt/ABCD1234EF"}'
```

Never hardcode the key, and never send it from a browser. It is a server-side
credential.

## POST /api/verify

Verifies one receipt. Body takes exactly one of:

| Field | Type | Notes |
|---|---|---|
| `url` | string | Full receipt URL. Works for every supported provider. |
| `reference` | string | telebirr only. Same as `transactioninfo.ethiotelecom.et/receipt/<ref>`. |

Optional:

| Field | Type | Notes |
|---|---|---|
| `waitMs` | number | Block up to this many ms, then return `202` with a `requestId`. Clamped to 30000. |

Optional `Idempotency-Key` header makes retries safe. The same key replays the
first call's response, including its `requestId`, even if the body differs.

Default behaviour is synchronous: the call holds open until the bank answers.
A cache hit is instant. An uncached receipt against a busy bank can hold the
connection for over a minute, because links.et waits for an upstream slot
(up to 30s, hard ceiling 120s), then fetches (20s), then retries once on a
different egress address (20s). If you cannot hold a request that long, send
`waitMs` and follow up.

### Success

```json
{
  "ok": true,
  "providerKey": "telebirr",
  "resolvedUrl": "<the upstream URL actually fetched>",
  "httpStatus": 200,
  "fetchedAt": "2026-01-01T00:00:00.000Z",
  "rawHtmlLength": 25928,
  "error": null,
  "receipt": { "source": "telebirr-html" }
}
```

`receipt` is provider specific. Switch on `receipt.source`, never on the host:
several banks serve more than one host, and two of them share one.

| `source` | Provider |
|---|---|
| `telebirr-html` | telebirr |
| `cbe-pdf` | CBE, PDF receipt |
| `mb-json` | CBE, mobile banking JSON |
| `cbebirr-pdf` | CBE Birr |
| `boa-json` | Bank of Abyssinia |
| `zemen-pdf` | Zemen Bank |
| `awash-html` | Awash Bank |
| `dashen-pdf` | Dashen Bank |
| `dashen-html` | Dashen Super App |
| `mpesa-pdf` | M-PESA |
| `ebirr-html` | COOPay Ebirr, Kaafi Ebirr |
| `amhara-json` | Amhara Bank |
| `abay-html` | Abay Bank |
| `berhan-pdf` | Berhan Bank |
| `oromia-pdf` | Oromia Bank |
| `ahadu-pdf` | Ahadu Bank |
| `siinqee-pdf` | Siinqee Bank |
| `zamzam-json` | ZamZam Bank |
| `hulubeje-dxxrdv` | HuluBeje point of sale |

Amount types differ by provider. CBE, Zemen, BoA and the JSON sources return
numbers. telebirr returns strings like `"100 Birr"`. Awash returns strings
like `"100 ETB"`. Parse per source; do not assume a number.

Field names are per source too. Read the page for the provider you support:
https://links.et/docs/verify.md

### Async follow-up

A `202` from `waitMs` looks like this:

```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"
}
```

Then either poll `GET {statusUrl}` (same envelope once it resolves, plus
`processingStatus` and `requestId`), or subscribe to
`GET {eventsUrl}` for server-sent events. The events stream replays the last
event for about 5 minutes, so you do not miss a result that landed before you
connected. Both take the same `x-api-key` header; the SSE endpoint also
accepts the key as a query parameter.

A common pattern: submit with `waitMs: 2000`, and on `202` open SSE, falling
back to polling once a second if the connection drops.

## POST /api/verify-image

For when you only have a screenshot. Runs an AI detector over the image, reads
the provider and reference off it, and chains into `/api/verify` for the
bank-side receipt.

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

`imageBase64` alone works as a single-image shorthand. Up to 5 images of the
same transaction per request, 5 MB of base64 each. One billed call whatever
the image count.

**The detector's output is not a receipt.** It carries provider, reference and
confidence only. The verified receipt is at `upstream.result.receipt`. If you
need trustworthy data, read that field and nothing else.

Screenshots are stored for 90 days so the read is auditable, in a private
bucket. If you are passing along your users' screenshots, say so in your own
privacy notice.

## Health

Both are open, no key needed:

- `GET /api/verify` returns `{ "ok": true, "ts": "..." }`. Is links.et alive.
- `GET /api/status` returns per-component status. Use this to see which bank is
  degraded. Returns `503` when overall status is down.

## Errors

Every error has the same shape:

```json
{ "ok": false, "error": { "code": "rate_limited", "message": "..." } }
```

| HTTP | Code | Retry? |
|---|---|---|
| 400 | `invalid_json`, `invalid_request` | No. Fix the body. |
| 400 | none | Yes, with backoff. The call never got an upstream status: bad URL, or gave up waiting for a busy bank. |
| 401 | `missing_key`, `invalid_key`, `revoked_key` | No. Mint a new key. |
| 429 | `rate_limited` | Yes, after `Retry-After`. |
| 429 | `quota_exceeded` | No timer. `Retry-After` can be days out. Carries `used` and `cap`. |
| 429 | `image_cap_reached` | No timer. Monthly screenshot allowance is spent. |
| 429 | `ocr_daily_cap_reached` | Yes, later. Service-wide budget, not your account. |
| 502 | none | Yes, 1 to 2s with jitter. Bank answered but the receipt failed validation. Partial `receipt` is included. |
| 503 | `provider_down` | Yes, after `Retry-After` (300s). Bank is failing every health check. Never counts against your cap. |
| 503 | `ai_not_configured` | No. Image extractor is not set up on this deployment. |

There is no 504. A timeout has no upstream status to report, so it comes back
as a 400 with the reason in `error`.

Treat three consecutive 502s or busy-bank 400s as a real outage rather than
retrying forever.

## Limits

- Request rate: 60 requests per minute per key by default. Over it returns
  `429 rate_limited` with `Retry-After`.
- Verification cap: per plan, per window. Only uncached successful
  verifications count. Cache hits are free however often you ask. Failed
  lookups are free. Over the cap returns `429 quota_exceeded`.
- Screenshot reads: counted per calendar month on every plan, separate from
  the verification cap.

Caps and prices per plan: https://links.et/docs/rate-limits.md

## Rules that are easy to get wrong

1. Switch on `receipt.source`, not on the host or `providerKey`.
2. Do not retry a Siinqee link. Those receipts allow about five views in
   total, so a retry loop destroys the receipt for everyone.
3. `quota_exceeded` is not `rate_limited`. One waits seconds, the other can
   wait until the month rolls over. Handle them separately.
4. A receipt URL is a lookup key for somebody's whole transaction at their
   bank. Treat it like a credential: do not log it, do not put it in an error
   message you ship, do not paste it into a bug report.
5. The landing page demo endpoints (`/api/demo-verify`,
   `/api/demo-verify-image`) take no key and are metered per IP. They exist so
   a human can try the service. Anything built on them will stop working.
6. Amount fields are numbers on some providers and strings on others. Never
   feed them straight into arithmetic.

## Full docs

Every page is served as markdown by adding `.md` to its URL.

- Index for agents: https://links.et/llms.txt
- Everything in one file: https://links.et/llms-full.txt
- Overview: https://links.et/docs.md
- Authentication: https://links.et/docs/authentication.md
- POST /api/verify: https://links.et/docs/verify.md
- POST /api/verify-image: https://links.et/docs/verify-image.md
- Integration flows: https://links.et/docs/integration-flows.md
- Providers: https://links.et/docs/providers.md
- Errors: https://links.et/docs/errors.md
- Rate limits: https://links.et/docs/rate-limits.md
- Health: https://links.et/docs/health.md
