Verify Ethiopian CBE and Telebirr payment receipts from a pasted SMS — no bank API keys, no manual review.
When a customer pays, they get an SMS containing a receipt link. This library takes that raw SMS, extracts the link, fetches the authoritative receipt page, parses it into a normalized transaction, and runs deterministic checks.
import { parseReceipt, verifyTransaction } from 'paste-to-verify';
const transaction = await parseReceipt(pastedSms);
const result = verifyTransaction(transaction, {
amount: 990,
receiverName: 'Dawit',
maxAgeMinutes: 30,
});
result.verified; // boolean
result.failures; // [{ field, expected, actual, reason }]The SMS is not trusted as the source of truth — it is easy to fabricate. It serves two purposes only: it supplies the receipt URL, and it provides a fallback for fields the receipt page doesn't expose. The receipt page is authoritative, so on any overlap the page value wins.
| Provider | Host | Receipt page | Fetch strategy |
|---|---|---|---|
| Telebirr | transactioninfo.ethiotelecom.et |
Server-rendered HTML | fetch + node-html-parser (edge-friendly) |
| CBE | cbe.com.et |
JavaScript SPA (spinner-only HTML) | Headless browser render, then scrape the DOM |
The CBE receipt is a Nuxt SPA whose served HTML contains only a loading spinner,
so it is rendered with Playwright (by default) and the loaded DOM is scraped.
You can inject your own renderer for edge runtimes — see
apps/docs → Providers → CBE.
CBE therefore needs a headless browser. Telebirr alone does not.
packages/core/ # the published npm package: paste-to-verify
apps/docs/ # documentation site + landing page (Next.js + Fumadocs)
apps/examples/ # interactive demo UIs — paste an SMS (or use a built-in
express/ # sample), watch the parsed receipt + live verdict
nextjs/ # express :3001 · nextjs :3003
# each also exposes POST /verify and POST /check
The layout follows a deliberate convention so it can grow without breaking changes:
packages/*holds publishable libraries (npm). New JS/TS packages (e.g. a framework adapterpackages/nextjs) slot in besidecoreand are picked up by the pnpm workspace automatically.apps/*holds deployables and consumers — the docs site and runnable examples. Nothing inapps/is published.- Other language ports (e.g. a PHP/Composer package) get their own
top-level directory (
php/), since each ecosystem's tooling expects its own manifest at its package root. The JS workspace config (pnpm-workspace.yaml,turbo.json) never needs to know about them, andpackages/corekeeps its npm identity untouched.
Tooling: pnpm workspaces + Turborepo, TypeScript (strict), tsup (ESM + CJS + d.ts), Vitest, ESLint + Prettier.
Contributing — human or AI — starts with AGENTS.md: the commands, the correctness invariants that must not be broken, and the testing and release conventions.
pnpm install # also installs Playwright's package
pnpm build # turbo: build core, docs, and examples
pnpm test # vitest with coverage (>90% on packages/core/src)
pnpm lint
pnpm typecheckRun a piece in isolation:
pnpm --filter paste-to-verify test # core unit tests
pnpm --filter @paste-to-verify/docs dev # docs at http://localhost:3000
pnpm --filter @paste-to-verify/example-express devTo actually parse CBE receipts (locally or in the examples), install the browser:
pnpm --filter paste-to-verify exec playwright install chromiumTests mock fetch/the renderer with saved fixtures in
packages/core/tests/fixtures, so CI never hits
the network. The committed fixtures and docs use synthetic, anonymized data —
real receipts contain personal information (names, account numbers, a unique
link) and are never committed.
To check against a real receipt, supply your own URL/SMS via a gitignored .env
(see .env.example) and run the live scripts:
cp .env.example .env # then fill in TELEBIRR_SMS / CBE_SMS (or *_RECEIPT_URL)
node --env-file=.env packages/core/scripts/smoke-live.mjs # parse a live receipt end-to-end
node --env-file=.env packages/core/scripts/save-fixtures.mjs # re-save fixtures from a live pageRegenerating fixtures overwrites them with the live page's (personal) data — don't commit that.
parseReceipt(sms, options?)— main entry point (async).parseReceiptUrl(url, options?)/parseReceiptId(provider, id, options?)— verify from just the receipt link or a transaction id (no SMS needed).verifyTransaction(tx, options?)— pure, synchronous, never throws.extractUrl(sms),detectProvider(url),normalizeUrl(url),urlMatchesHost(url, host)— utilities.createPlaywrightRenderer()— the default renderer (inject your own for edge).- Typed errors (
UrlNotFoundError,ProviderNotRecognizedError,ReceiptFetchError,ReceiptParseError,BrowserUnavailableError), each with a stablecode.parseReceiptnever throws an untyped error.
Full reference and guides live in the docs site (apps/docs).
| Decision | Choice |
|---|---|
| HTML parser | node-html-parser (Telebirr) |
| SPA rendering | Playwright (CBE), injectable + optional peer dependency |
| HTTP client | native fetch |
| Test runner | Vitest |
| Build | tsup (ESM + CJS + .d.ts) |
| Package manager / tasks | pnpm + Turborepo |
| Node minimum | 18 |
MIT © Biruk Worku — see LICENSE.