Initial release: @qbirr/sdk v1.0.0
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
# @qbirr/sdk
|
||||
|
||||
Verify Ethiopian bank transfer receipts in real time — CBE, Telebirr, Awash, Dashen, M-Pesa, Bank of Abyssinia, and eBirr.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @qbirr/sdk
|
||||
```
|
||||
|
||||
Requires Node.js 18+ (uses built-in `fetch`). Zero dependencies.
|
||||
|
||||
## Quick start
|
||||
|
||||
```js
|
||||
import { Qbirr } from '@qbirr/sdk';
|
||||
|
||||
const qb = new Qbirr({ apiKey: process.env.QBIRR_API_KEY });
|
||||
|
||||
const r = await qb.verify({
|
||||
provider: 'cbe',
|
||||
ref: 'FT23001234ABC',
|
||||
amount: 500,
|
||||
receiverName: 'YOUR FULL NAME',
|
||||
receiverAccount: '1000017692643',
|
||||
});
|
||||
|
||||
if (r.verified) {
|
||||
console.log(`✓ Paid by ${r.payer}, amount: ${r.amount}`);
|
||||
} else {
|
||||
console.log(`✗ ${r.error}`);
|
||||
}
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `new Qbirr(options)`
|
||||
|
||||
| Option | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `apiKey` | ✓ | — | Your key from the qbirr dashboard |
|
||||
| `baseUrl` | | `https://verify.qbirr.com` | Override (e.g. for self-hosted) |
|
||||
| `timeoutMs` | | `30000` | Per-request timeout |
|
||||
|
||||
### `qb.verify(req)`
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `provider` | string | ✓ | `cbe` · `telebirr` · `abyssinia` · `dashen` · `mpesa` · `awash` · `ebirr` |
|
||||
| `ref` | string | ✓ | Transaction reference. For **Awash**, paste the SMS URL or its token. For **eBirr**, paste the URL or `"tenant/token"`. |
|
||||
| `amount` | number | ✓ | Expected payment amount in ETB |
|
||||
| `receiverName` | string | ✓ | Exact name on your receiving account |
|
||||
| `receiverAccount` | string | for CBE & Abyssinia | Your account number |
|
||||
|
||||
Returns `{ verified, payer, amount, error }`.
|
||||
|
||||
### `qb.usage()`
|
||||
|
||||
Returns the current month's `{ used, limit, remaining, plan, period_start, period_end }`.
|
||||
|
||||
## Errors
|
||||
|
||||
`QbirrError` is thrown for network failures, HTTP 4xx/5xx, or non-JSON responses. **Verification failures** (wrong amount, wrong receiver, expired receipt) are NOT thrown — they're returned as `{ verified: false, error: '...' }`.
|
||||
|
||||
```js
|
||||
import { Qbirr, QbirrError } from '@qbirr/sdk';
|
||||
try {
|
||||
const r = await qb.verify({ ... });
|
||||
} catch (e) {
|
||||
if (e instanceof QbirrError) {
|
||||
console.error(`API error ${e.status}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@qbirr/sdk",
|
||||
"version": "1.0.0",
|
||||
"description": "qbirr.com — Ethiopian bank verification SDK for Node.js (CBE, Telebirr, Awash, Dashen, M-Pesa, Abyssinia, eBirr)",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": ["dist", "README.md"],
|
||||
"engines": { "node": ">=18" },
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.3",
|
||||
"@types/node": "^22.7.5"
|
||||
},
|
||||
"keywords": ["qbirr", "ethiopia", "cbe", "telebirr", "awash", "dashen", "mpesa", "abyssinia", "ebirr", "payment", "verification"]
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* @qbirr/sdk — Node.js client for the qbirr payment verification API.
|
||||
*
|
||||
* import { Qbirr } from '@qbirr/sdk';
|
||||
* const qb = new Qbirr({ apiKey: process.env.QBIRR_API_KEY! });
|
||||
* const r = await qb.verify({
|
||||
* provider: 'cbe',
|
||||
* ref: 'FT23001234ABC',
|
||||
* amount: 500,
|
||||
* receiverName: 'YOUR NAME',
|
||||
* receiverAccount: '1000017692643',
|
||||
* });
|
||||
* console.log(r.verified, r.payer);
|
||||
*
|
||||
* Works on Node 18+ (uses the built-in fetch). No dependencies.
|
||||
*/
|
||||
|
||||
export type Provider =
|
||||
| 'cbe' // Commercial Bank of Ethiopia (needs receiverAccount for legacy FT refs)
|
||||
| 'telebirr' // Telebirr
|
||||
| 'abyssinia' // Bank of Abyssinia (needs receiverAccount)
|
||||
| 'dashen' // Dashen Bank
|
||||
| 'mpesa' // Safaricom M-Pesa Ethiopia
|
||||
| 'awash' // Awash Bank (ref = SMS URL or token)
|
||||
| 'ebirr' // eBirr (COOPay/KAAFI/Nib/Wegagen/Ahadu — ref = "tenant/token" or full URL)
|
||||
| 'zamzam'; // ZamZam Bank (ref = SMS URL or bare id)
|
||||
|
||||
export interface VerifyRequest {
|
||||
provider: Provider;
|
||||
/** Transaction reference (or, for Awash/eBirr, the SMS URL / URL token). */
|
||||
ref: string;
|
||||
/** Expected payment amount in ETB. */
|
||||
amount: number;
|
||||
/** Exact receiver name (case-insensitive, whitespace-normalised match). */
|
||||
receiverName: string;
|
||||
/** Required for CBE and Bank of Abyssinia. Ignored for other providers. */
|
||||
receiverAccount?: string;
|
||||
}
|
||||
|
||||
export interface VerifyResult {
|
||||
/** True if the receipt is real, the amount matches, and the receiver matches. */
|
||||
verified: boolean;
|
||||
/** Payer name as extracted from the receipt. */
|
||||
payer: string;
|
||||
/** Amount that was actually paid (per the receipt). */
|
||||
amount: number;
|
||||
/** Human-readable failure reason, empty on success. */
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface UsageStatus {
|
||||
used: number;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
plan: string;
|
||||
/** ISO date — first instant of the current monthly window. */
|
||||
period_start: string;
|
||||
/** ISO date — first instant of the next monthly window. */
|
||||
period_end: string;
|
||||
}
|
||||
|
||||
export interface QbirrOptions {
|
||||
/** Your API key from the qbirr dashboard. */
|
||||
apiKey: string;
|
||||
/** Override base URL. Defaults to https://verify.qbirr.com */
|
||||
baseUrl?: string;
|
||||
/** Per-request timeout (ms). Defaults to 30 000. */
|
||||
timeoutMs?: number;
|
||||
/** Optional fetch override (for tests). */
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
/** Thrown for network failures, HTTP errors, or non-JSON responses. */
|
||||
export class QbirrError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status?: number,
|
||||
public readonly body?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'QbirrError';
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://verify.qbirr.com';
|
||||
|
||||
export class Qbirr {
|
||||
private readonly apiKey: string;
|
||||
private readonly baseUrl: string;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly fetchFn: typeof fetch;
|
||||
|
||||
constructor(opts: QbirrOptions) {
|
||||
if (!opts.apiKey) throw new QbirrError('apiKey is required');
|
||||
this.apiKey = opts.apiKey;
|
||||
this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
||||
this.timeoutMs = opts.timeoutMs ?? 30_000;
|
||||
this.fetchFn = opts.fetch ?? globalThis.fetch;
|
||||
if (!this.fetchFn) throw new QbirrError('fetch is not available — pass options.fetch on Node < 18');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a single transaction. Returns `{ verified: false, error: '...' }`
|
||||
* on a real verification failure (wrong amount, wrong receiver, expired
|
||||
* receipt). Throws QbirrError for network/auth problems.
|
||||
*/
|
||||
async verify(req: VerifyRequest): Promise<VerifyResult> {
|
||||
const body: Record<string, unknown> = {
|
||||
provider: req.provider,
|
||||
ref: req.ref,
|
||||
amount: req.amount,
|
||||
receiver_name: req.receiverName,
|
||||
};
|
||||
if (req.receiverAccount) body.receiver_account = req.receiverAccount;
|
||||
|
||||
const data = await this.request<VerifyResult>('POST', '/api/v1/verify', body);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify by uploading the receipt itself — PDF or an image (PNG/JPEG).
|
||||
* Images with a QR code (most bank receipts have one) are decoded and
|
||||
* routed through the URL-based flow automatically.
|
||||
*
|
||||
* const buf = await fs.readFile('receipt.pdf');
|
||||
* await qb.verifyUpload({
|
||||
* provider: 'cbe',
|
||||
* amount: 999,
|
||||
* receiverName: 'YOUR NAME',
|
||||
* receiverAccount: '1000017692643',
|
||||
* file: buf,
|
||||
* filename: 'receipt.pdf',
|
||||
* contentType: 'application/pdf',
|
||||
* });
|
||||
*/
|
||||
async verifyUpload(opts: {
|
||||
provider: Provider;
|
||||
amount: number;
|
||||
receiverName: string;
|
||||
receiverAccount?: string;
|
||||
file: Buffer | Uint8Array | Blob;
|
||||
filename?: string;
|
||||
contentType?: string;
|
||||
}): Promise<VerifyResult> {
|
||||
const form = new FormData();
|
||||
form.append('provider', opts.provider);
|
||||
form.append('amount', String(opts.amount));
|
||||
form.append('receiver_name', opts.receiverName);
|
||||
if (opts.receiverAccount) form.append('receiver_account', opts.receiverAccount);
|
||||
const blob = opts.file instanceof Blob
|
||||
? opts.file
|
||||
: new Blob([opts.file], { type: opts.contentType || 'application/octet-stream' });
|
||||
form.append('file', blob, opts.filename || 'upload.pdf');
|
||||
|
||||
return this.requestForm<VerifyResult>('POST', '/api/v1/verify/upload', form);
|
||||
}
|
||||
|
||||
/** Returns the current month's quota usage for this API key. */
|
||||
async usage(): Promise<UsageStatus> {
|
||||
return this.request<UsageStatus>('GET', '/api/v1/usage');
|
||||
}
|
||||
|
||||
private async requestForm<T>(method: 'POST', path: string, form: FormData): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
|
||||
try {
|
||||
const resp = await this.fetchFn(url, {
|
||||
method,
|
||||
headers: { 'X-API-Key': this.apiKey, 'User-Agent': '@qbirr/sdk-node/1.0.0' },
|
||||
body: form,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
const text = await resp.text();
|
||||
let parsed: any;
|
||||
try { parsed = text ? JSON.parse(text) : {}; }
|
||||
catch { throw new QbirrError(`Non-JSON response from qbirr (HTTP ${resp.status})`, resp.status, text); }
|
||||
if (!resp.ok) throw new QbirrError((parsed as any)?.error || `HTTP ${resp.status}`, resp.status, parsed);
|
||||
return parsed as T;
|
||||
} catch (e: any) {
|
||||
if (e instanceof QbirrError) throw e;
|
||||
if (e?.name === 'AbortError') throw new QbirrError(`Upload timed out after ${this.timeoutMs}ms`);
|
||||
throw new QbirrError(`Network error: ${e.message}`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
|
||||
try {
|
||||
const resp = await this.fetchFn(url, {
|
||||
method,
|
||||
headers: {
|
||||
'X-API-Key': this.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'User-Agent': '@qbirr/sdk-node/1.0.0',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
|
||||
let parsed: unknown;
|
||||
const text = await resp.text();
|
||||
try { parsed = text ? JSON.parse(text) : {}; }
|
||||
catch { throw new QbirrError(`Non-JSON response from qbirr (HTTP ${resp.status})`, resp.status, text); }
|
||||
|
||||
if (!resp.ok) {
|
||||
const msg = (parsed as any)?.error || `HTTP ${resp.status}`;
|
||||
throw new QbirrError(msg, resp.status, parsed);
|
||||
}
|
||||
return parsed as T;
|
||||
} catch (e: any) {
|
||||
if (e instanceof QbirrError) throw e;
|
||||
if (e?.name === 'AbortError') throw new QbirrError(`Request timed out after ${this.timeoutMs}ms`);
|
||||
throw new QbirrError(`Network error: ${e.message}`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Qbirr;
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user