mozosubz-sdk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -0
- package/package.json +28 -0
- package/src/index.d.ts +73 -0
- package/src/index.js +208 -0
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Mozosubz SDK
|
|
2
|
+
|
|
3
|
+
Buy cheap data and airtime in Nigeria programmatically via the [Mozosubz V1 API](https://mozosubz.xyz/doc).
|
|
4
|
+
|
|
5
|
+
Purchases process synchronously in milliseconds — the API response you get back **is** the final transaction status (`success`, `transaction_id`, amount, and your new wallet balance). No polling or webhooks needed.
|
|
6
|
+
|
|
7
|
+
| Method | Endpoint |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `getDataPlans(service)` | `GET /api/v1/data/plans?service=...` |
|
|
10
|
+
| `buyData({ service, planId, phone })` | `POST /api/v1/data/purchase` |
|
|
11
|
+
| `buyAirtime({ network, amount, phone })` | `POST /api/v1/airtime/purchase` |
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install mozosubz-sdk
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Zero dependencies. Node 18+.
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
const { Mozosubz } = require('mozosubz-sdk');
|
|
25
|
+
|
|
26
|
+
const client = new Mozosubz({ apiKey: process.env.MOZOSUBZ_API_KEY });
|
|
27
|
+
|
|
28
|
+
// 1. Fetch plans in real-time (docs: never hardcode plan IDs)
|
|
29
|
+
const { plans } = await client.getDataPlans('mtn_sme');
|
|
30
|
+
|
|
31
|
+
// 2. Buy data — response is the final status
|
|
32
|
+
const order = await client.buyData({
|
|
33
|
+
service: 'mtn_sme',
|
|
34
|
+
planId: plans[0].id,
|
|
35
|
+
phone: '08012345678',
|
|
36
|
+
});
|
|
37
|
+
console.log(order.transaction_id, order.balance_after);
|
|
38
|
+
|
|
39
|
+
// 3. Buy airtime (minimum ₦50)
|
|
40
|
+
await client.buyAirtime({ network: 'mtn', amount: 100, phone: '08012345678' });
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Get your Connect Key (format `connect_...`) from the API Keys page on [mozosubz.xyz](https://mozosubz.xyz) — the SDK sends it in the `X-Connect-Key` header for you.
|
|
44
|
+
|
|
45
|
+
## Data services
|
|
46
|
+
|
|
47
|
+
`mtn_sme`, `mtn_datashare`, `mtn_gifting`, `mtn_awoof`, `glo_data`, `glo_sme`, `airtel_sme`, `airtel_gifting`, `etisalat_data`
|
|
48
|
+
|
|
49
|
+
Also available as constants: `Mozosubz.DATA_SERVICES.MTN_SME`, etc.
|
|
50
|
+
|
|
51
|
+
## Airtime networks
|
|
52
|
+
|
|
53
|
+
`mtn`, `glo`, `airtel`, `etisalat` (9mobile)
|
|
54
|
+
|
|
55
|
+
## Error handling
|
|
56
|
+
|
|
57
|
+
All failures throw `MozosubzError` with helpers mapped to the documented status codes:
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
const { MozosubzError } = require('mozosubz-sdk');
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
await client.buyData({ service: 'mtn_sme', planId: 'plan_1', phone: '08012345678' });
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err instanceof MozosubzError) {
|
|
66
|
+
if (err.isAuthError) { // 401 — bad/missing key
|
|
67
|
+
} else if (err.isInsufficientBalance) { // 402 — fund your wallet
|
|
68
|
+
} else if (err.isRateLimited) { // 429 — 10,000 requests/day limit
|
|
69
|
+
} else {
|
|
70
|
+
console.error(err.status, err.message); // 400, 500, or network failure (status null)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Safety choices
|
|
77
|
+
|
|
78
|
+
- **Purchases are never auto-retried.** A network failure mid-purchase is ambiguous — retrying could double-charge your wallet. Only the read-only `getDataPlans` retries (2× by default).
|
|
79
|
+
- Phone numbers are validated client-side (`08012345678` form) before any request is sent.
|
|
80
|
+
- Airtime below the documented ₦50 minimum is rejected locally.
|
|
81
|
+
|
|
82
|
+
## Notes
|
|
83
|
+
|
|
84
|
+
- Rate limit: 10,000 requests/day.
|
|
85
|
+
- Keep your key server-side only — never ship it in client-side code.
|
|
86
|
+
- Base URL: `https://mozosubz.xyz/api/v1` (overridable via `new Mozosubz({ baseUrl })`).
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mozosubz-sdk",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Node.js SDK for the Mozosubz V1 API — buy cheap data and airtime in Nigeria programmatically. Purchases process in milliseconds.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"types": "src/index.d.ts",
|
|
7
|
+
"type": "commonjs",
|
|
8
|
+
"files": [
|
|
9
|
+
"src",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://mozosubz.xyz/doc",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"mozosubz",
|
|
18
|
+
"vtu",
|
|
19
|
+
"data",
|
|
20
|
+
"airtime",
|
|
21
|
+
"nigeria",
|
|
22
|
+
"mtn",
|
|
23
|
+
"glo",
|
|
24
|
+
"airtel",
|
|
25
|
+
"9mobile"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT"
|
|
28
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export interface DataPlan {
|
|
2
|
+
id: string;
|
|
3
|
+
displayName: string;
|
|
4
|
+
price: number;
|
|
5
|
+
validity: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface DataPlansResponse {
|
|
9
|
+
success: true;
|
|
10
|
+
service: string;
|
|
11
|
+
plans: DataPlan[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DataPurchaseResponse {
|
|
15
|
+
success: true;
|
|
16
|
+
transaction_id: string;
|
|
17
|
+
plan: string;
|
|
18
|
+
phone: string;
|
|
19
|
+
amount: number;
|
|
20
|
+
balance_after: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AirtimePurchaseResponse {
|
|
24
|
+
success: true;
|
|
25
|
+
transaction_id: string;
|
|
26
|
+
network: string;
|
|
27
|
+
phone: string;
|
|
28
|
+
amount: number;
|
|
29
|
+
balance_after: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type DataService =
|
|
33
|
+
| 'mtn_sme'
|
|
34
|
+
| 'mtn_datashare'
|
|
35
|
+
| 'mtn_gifting'
|
|
36
|
+
| 'mtn_awoof'
|
|
37
|
+
| 'glo_data'
|
|
38
|
+
| 'glo_sme'
|
|
39
|
+
| 'airtel_sme'
|
|
40
|
+
| 'airtel_gifting'
|
|
41
|
+
| 'etisalat_data';
|
|
42
|
+
|
|
43
|
+
export type AirtimeNetwork = 'mtn' | 'glo' | 'airtel' | 'etisalat';
|
|
44
|
+
|
|
45
|
+
export declare class MozosubzError extends Error {
|
|
46
|
+
status: number | null;
|
|
47
|
+
body: object | null;
|
|
48
|
+
isAuthError: boolean;
|
|
49
|
+
isInsufficientBalance: boolean;
|
|
50
|
+
isRateLimited: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface MozosubzOptions {
|
|
54
|
+
apiKey: string;
|
|
55
|
+
baseUrl?: string;
|
|
56
|
+
timeoutMs?: number;
|
|
57
|
+
maxRetries?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export declare class Mozosubz {
|
|
61
|
+
constructor(options: MozosubzOptions);
|
|
62
|
+
getDataPlans(service: DataService): Promise<DataPlansResponse>;
|
|
63
|
+
buyData(params: { service: DataService; planId: string; phone: string }): Promise<DataPurchaseResponse>;
|
|
64
|
+
buyAirtime(params: { network: AirtimeNetwork; amount: number; phone: string }): Promise<AirtimePurchaseResponse>;
|
|
65
|
+
|
|
66
|
+
static DATA_SERVICES: Record<string, DataService>;
|
|
67
|
+
static AIRTIME_NETWORKS: Record<string, AirtimeNetwork>;
|
|
68
|
+
static MIN_AIRTIME_AMOUNT: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export declare const DATA_SERVICES: Record<string, DataService>;
|
|
72
|
+
export declare const AIRTIME_NETWORKS: Record<string, AirtimeNetwork>;
|
|
73
|
+
export declare const MIN_AIRTIME_AMOUNT: number;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mozosubz Node.js SDK
|
|
5
|
+
*
|
|
6
|
+
* Built strictly on the documented Mozosubz V1 API (https://mozosubz.xyz/doc):
|
|
7
|
+
* - GET /api/v1/data/plans?service=<service_id>
|
|
8
|
+
* - POST /api/v1/data/purchase
|
|
9
|
+
* - POST /api/v1/airtime/purchase
|
|
10
|
+
*
|
|
11
|
+
* Auth: X-Connect-Key header. All responses are JSON.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const BASE_URL = 'https://mozosubz.xyz/api/v1';
|
|
15
|
+
|
|
16
|
+
/** Data service IDs, exactly as listed in the API reference. */
|
|
17
|
+
const DATA_SERVICES = Object.freeze({
|
|
18
|
+
MTN_SME: 'mtn_sme',
|
|
19
|
+
MTN_DATASHARE: 'mtn_datashare',
|
|
20
|
+
MTN_GIFTING: 'mtn_gifting',
|
|
21
|
+
MTN_AWOOF: 'mtn_awoof',
|
|
22
|
+
GLO_DATA: 'glo_data',
|
|
23
|
+
GLO_SME: 'glo_sme',
|
|
24
|
+
AIRTEL_SME: 'airtel_sme',
|
|
25
|
+
AIRTEL_GIFTING: 'airtel_gifting',
|
|
26
|
+
ETISALAT_DATA: 'etisalat_data',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/** Airtime network IDs, exactly as listed in the API reference. */
|
|
30
|
+
const AIRTIME_NETWORKS = Object.freeze({
|
|
31
|
+
MTN: 'mtn',
|
|
32
|
+
GLO: 'glo',
|
|
33
|
+
AIRTEL: 'airtel',
|
|
34
|
+
ETISALAT: 'etisalat',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** Documented minimum airtime amount (₦). */
|
|
38
|
+
const MIN_AIRTIME_AMOUNT = 50;
|
|
39
|
+
|
|
40
|
+
class MozosubzError extends Error {
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} message
|
|
43
|
+
* @param {number|null} status HTTP status code, null for network failures
|
|
44
|
+
* @param {object|null} body Parsed response body if available
|
|
45
|
+
*/
|
|
46
|
+
constructor(message, status = null, body = null) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = 'MozosubzError';
|
|
49
|
+
this.status = status;
|
|
50
|
+
this.body = body;
|
|
51
|
+
// Documented statuses: 400, 401, 402, 429, 500
|
|
52
|
+
this.isAuthError = status === 401;
|
|
53
|
+
this.isInsufficientBalance = status === 402;
|
|
54
|
+
this.isRateLimited = status === 429;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
class Mozosubz {
|
|
59
|
+
/**
|
|
60
|
+
* @param {object} options
|
|
61
|
+
* @param {string} options.apiKey Your Connect Key (format: connect_...)
|
|
62
|
+
* @param {string} [options.baseUrl] Override base URL (default: https://mozosubz.xyz/api/v1)
|
|
63
|
+
* @param {number} [options.timeoutMs] Request timeout in ms (default: 30000)
|
|
64
|
+
* @param {number} [options.maxRetries] Retries for network failures on GET requests only (default: 2)
|
|
65
|
+
*/
|
|
66
|
+
constructor({ apiKey, baseUrl = BASE_URL, timeoutMs = 30000, maxRetries = 2 } = {}) {
|
|
67
|
+
if (!apiKey || typeof apiKey !== 'string') {
|
|
68
|
+
throw new MozosubzError('apiKey is required. Get one from the API Keys page on mozosubz.xyz.');
|
|
69
|
+
}
|
|
70
|
+
this.apiKey = apiKey;
|
|
71
|
+
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
|
72
|
+
this.timeoutMs = timeoutMs;
|
|
73
|
+
this.maxRetries = maxRetries;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* GET /data/plans — list plans for a data service.
|
|
78
|
+
* Docs: fetch plans in real-time, don't hardcode plan IDs.
|
|
79
|
+
* @param {string} service e.g. 'mtn_sme', 'glo_data' (see Mozosubz.DATA_SERVICES)
|
|
80
|
+
* @returns {Promise<{success: boolean, service: string, plans: Array<{id: string, displayName: string, price: number, validity: string}>}>}
|
|
81
|
+
*/
|
|
82
|
+
async getDataPlans(service) {
|
|
83
|
+
assertOneOf('service', service, Object.values(DATA_SERVICES));
|
|
84
|
+
const qs = new URLSearchParams({ service }).toString();
|
|
85
|
+
// Safe to retry: read-only request.
|
|
86
|
+
return this._request('GET', `/data/plans?${qs}`, null, { retriable: true });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* POST /data/purchase — buy a data plan.
|
|
91
|
+
* NOT retried automatically: retrying a purchase after an ambiguous network
|
|
92
|
+
* failure could double-charge your wallet.
|
|
93
|
+
* @param {object} params
|
|
94
|
+
* @param {string} params.service Data service ID, e.g. 'mtn_sme'
|
|
95
|
+
* @param {string} params.planId Plan ID from getDataPlans()
|
|
96
|
+
* @param {string} params.phone Nigerian phone number, e.g. '08012345678'
|
|
97
|
+
* @returns {Promise<{success: boolean, transaction_id: string, plan: string, phone: string, amount: number, balance_after: number}>}
|
|
98
|
+
*/
|
|
99
|
+
async buyData({ service, planId, phone } = {}) {
|
|
100
|
+
assertOneOf('service', service, Object.values(DATA_SERVICES));
|
|
101
|
+
assertNonEmptyString('planId', planId);
|
|
102
|
+
assertPhone(phone);
|
|
103
|
+
return this._request('POST', '/data/purchase', {
|
|
104
|
+
service,
|
|
105
|
+
plan_id: planId,
|
|
106
|
+
phone,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* POST /airtime/purchase — buy airtime.
|
|
112
|
+
* NOT retried automatically (same double-charge risk as buyData).
|
|
113
|
+
* @param {object} params
|
|
114
|
+
* @param {string} params.network 'mtn' | 'glo' | 'airtel' | 'etisalat'
|
|
115
|
+
* @param {number} params.amount Amount in ₦. Minimum ₦50 per the docs.
|
|
116
|
+
* @param {string} params.phone Nigerian phone number
|
|
117
|
+
* @returns {Promise<{success: boolean, transaction_id: string, network: string, phone: string, amount: number, balance_after: number}>}
|
|
118
|
+
*/
|
|
119
|
+
async buyAirtime({ network, amount, phone } = {}) {
|
|
120
|
+
assertOneOf('network', network, Object.values(AIRTIME_NETWORKS));
|
|
121
|
+
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < MIN_AIRTIME_AMOUNT) {
|
|
122
|
+
throw new MozosubzError(`amount must be a number >= ₦${MIN_AIRTIME_AMOUNT}`);
|
|
123
|
+
}
|
|
124
|
+
assertPhone(phone);
|
|
125
|
+
return this._request('POST', '/airtime/purchase', { network, amount, phone });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async _request(method, path, body, { retriable = false } = {}) {
|
|
129
|
+
const url = `${this.baseUrl}${path}`;
|
|
130
|
+
const attempts = retriable ? this.maxRetries + 1 : 1;
|
|
131
|
+
let lastNetworkError;
|
|
132
|
+
|
|
133
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
134
|
+
const controller = new AbortController();
|
|
135
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
136
|
+
let res;
|
|
137
|
+
try {
|
|
138
|
+
res = await fetch(url, {
|
|
139
|
+
method,
|
|
140
|
+
headers: {
|
|
141
|
+
'X-Connect-Key': this.apiKey,
|
|
142
|
+
Accept: 'application/json',
|
|
143
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
144
|
+
},
|
|
145
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
146
|
+
signal: controller.signal,
|
|
147
|
+
});
|
|
148
|
+
} catch (err) {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
lastNetworkError = err;
|
|
151
|
+
if (attempt < attempts) {
|
|
152
|
+
await sleep(300 * attempt);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
throw new MozosubzError(`Network error calling ${method} ${path}: ${err.message}`, null, null);
|
|
156
|
+
}
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
|
|
159
|
+
let json = null;
|
|
160
|
+
try {
|
|
161
|
+
json = await res.json();
|
|
162
|
+
} catch {
|
|
163
|
+
// Non-JSON response despite docs saying all responses are JSON.
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (res.ok && json && json.success === true) return json;
|
|
167
|
+
|
|
168
|
+
// Error shape confirmed against the live API: {"success":false,"error":"..."}
|
|
169
|
+
const message =
|
|
170
|
+
(json && (json.error || json.message)) || `HTTP ${res.status} from ${method} ${path}`;
|
|
171
|
+
throw new MozosubzError(message, res.status, json);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
throw new MozosubzError(`Network error: ${lastNetworkError?.message}`, null, null);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function assertNonEmptyString(name, value) {
|
|
179
|
+
if (!value || typeof value !== 'string') {
|
|
180
|
+
throw new MozosubzError(`${name} is required and must be a non-empty string`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function assertOneOf(name, value, allowed) {
|
|
185
|
+
assertNonEmptyString(name, value);
|
|
186
|
+
if (!allowed.includes(value)) {
|
|
187
|
+
throw new MozosubzError(`${name} must be one of: ${allowed.join(', ')} (got "${value}")`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Docs say "Nigerian phone number" and show the 0-prefixed 11-digit form
|
|
192
|
+
// (e.g. 08012345678). Validate that documented form only.
|
|
193
|
+
function assertPhone(phone) {
|
|
194
|
+
assertNonEmptyString('phone', phone);
|
|
195
|
+
if (!/^0\d{10}$/.test(phone)) {
|
|
196
|
+
throw new MozosubzError('phone must be an 11-digit Nigerian number starting with 0, e.g. 08012345678');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function sleep(ms) {
|
|
201
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
Mozosubz.DATA_SERVICES = DATA_SERVICES;
|
|
205
|
+
Mozosubz.AIRTIME_NETWORKS = AIRTIME_NETWORKS;
|
|
206
|
+
Mozosubz.MIN_AIRTIME_AMOUNT = MIN_AIRTIME_AMOUNT;
|
|
207
|
+
|
|
208
|
+
module.exports = { Mozosubz, MozosubzError, DATA_SERVICES, AIRTIME_NETWORKS, MIN_AIRTIME_AMOUNT };
|