@patalink/node-api-client 1.0.6 → 1.1.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 CHANGED
@@ -1,623 +1,247 @@
1
- Here's a professional, detailed documentation for your `@patlink/node-api-client` package:
1
+ # @patalink/node-api-client
2
2
 
3
- ```markdown
4
- # @patlink/node-api-client - Official PataLink API Client
3
+ [![npm version](https://img.shields.io/npm/v/@patalink/node-api-client.svg?style=flat-square)](https://www.npmjs.com/package/@patalink/node-api-client)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-emerald.svg?style=flat-square)](https://opensource.org/licenses/MIT)
5
+ [![Node.js](https://img.shields.io/badge/Node.js-18+-blue.svg?style=flat-square)](https://nodejs.org/)
6
+ [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/)
5
7
 
6
- **Server-side API client for Node.js. Accept payments programmatically with automatic encryption.**
7
-
8
- [![npm version](https://badge.fury.io/js/@patlink%2Fapi-client.svg)](https://badge.fury.io/js/@patlink%2Fapi-client)
9
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
10
- [![Node Version](https://img.shields.io/node/v/@patlink/node-api-client)](https://nodejs.org/)
11
-
12
- ---
13
-
14
- ## 📖 Table of Contents
15
-
16
- - [Quick Start](#rocket-quick-start)
17
- - [Installation](#package-installation)
18
- - [Configuration](#gear-configuration)
19
- - [API Reference](#api-reference)
20
- - [Usage Examples](#usage-examples)
21
- - [Error Handling](#error-handling)
22
- - [TypeScript Support](#typescript-support)
23
- - [Browser Support](#browser-support)
24
- - [FAQ](#faq)
8
+ The official server-side Node.js SDK for **[PataLink](https://patalink.me)**. Integrate Mobile Money collections, instant B2C payouts (MTN Rwanda), real-time wallet balances, and webhook processing with automatic end-to-end payload encryption (AES-256-CBC + HMAC-SHA256).
25
9
 
26
10
  ---
27
11
 
28
- ## 🚀 Quick Start
29
-
30
- Add payment processing to your Node.js backend in 3 simple steps:
31
-
32
- ### Step 1: Install the package
33
-
34
- ```bash
35
- npm install @patlink/node-api-client
36
- ```
37
-
38
- ### Step 2: Initialize the client
39
-
40
- ```javascript
41
- import { createClient } from '@patlink/node-api-client';
42
-
43
- const client = createClient({
44
- apiKey: 'pk_live_abc123...',
45
- encryptionKey: 'xK8j3Hd9Fq2Wm5R...',
46
- baseUrl: 'https://patalink.me'
47
- });
48
- ```
49
-
50
- ### Step 3: Create a payment
12
+ ## ⚡ Features
51
13
 
52
- ```javascript
53
- const payment = await client.createPayment({
54
- amount: 25000,
55
- paymentMethod: 'MTN',
56
- phoneNumber: '0788888888',
57
- customerName: 'John Doe',
58
- customerEmail: 'john@example.com'
59
- });
60
-
61
- console.log('Transaction ID:', payment.transactionId);
62
- ```
14
+ - **Inbound Collections**: Accept customer payments via MTN Mobile Money USSD prompt and card checkout (PesaPal).
15
+ - **Instant B2C Disbursements**: Send payouts directly to any MTN Mobile Money number in Rwanda (`078...` / `079...`).
16
+ - **Live Wallet Balances**: Check available cleared funds, pending amounts, and total volume before initiating payouts.
17
+ - **Automated Encryption**: Payload encryption (AES-256-CBC) and HMAC signing are handled seamlessly under the hood.
18
+ - **Built-in Polling Utilities**: Convenient `waitForCompletion` and `waitForWithdrawalCompletion` with live progress callbacks.
19
+ - **Universal ESM & CJS**: Full support for ES Modules (`import`), CommonJS (`require`), and TypeScript.
63
20
 
64
21
  ---
65
22
 
66
23
  ## 📦 Installation
67
24
 
68
- ### Node.js (Recommended)
69
-
70
- ```bash
71
- npm install @patlink/node-api-client
72
- ```
73
-
74
- ### With yarn
75
-
76
25
  ```bash
77
- yarn add @patlink/node-api-client
26
+ npm install @patalink/node-api-client
78
27
  ```
79
28
 
80
- ### With pnpm
81
-
82
- ```bash
83
- pnpm add @patlink/node-api-client
84
- ```
85
-
86
- ### Requirements
87
-
88
- - Node.js 16.x or higher
89
- - TypeScript 4.x or higher (optional)
29
+ *Requires Node.js 18.0.0 or higher.*
90
30
 
91
31
  ---
92
32
 
93
- ## ⚙️ Configuration
94
-
95
- ### Client Configuration Options
96
-
97
- ```typescript
98
- interface ClientConfig {
99
- apiKey: string; // Required: Your API key from dashboard
100
- encryptionKey: string; // Required: Your encryption key from dashboard
101
- baseUrl: string; // Required: API endpoint URL
102
- timeout?: number; // Optional: Request timeout in ms (default: 30000)
103
- }
104
- ```
105
-
106
- ### Getting Your Keys
33
+ ## 🔑 Authentication & Prerequisites
107
34
 
108
- 1. Sign up at [patalink.me](https://patalink.me)
109
- 2. Navigate to **Settings** → **API Keys**
110
- 3. Click **Generate API Key** and **Generate Encryption Key**
111
- 4. Copy both keys to your environment variables
35
+ To use this SDK in production:
36
+ 1. Create an account at **[patalink.me](https://patalink.me)**.
37
+ 2. Complete **KYC identity verification** in your dashboard (required for API keys and automated payouts).
38
+ 3. Navigate to **Dashboard → Settings → Security & API Keys** to copy:
39
+ - **API Key**: Starts with `pt_live_...` (or `pt_test_...` for sandbox)
40
+ - **Encryption Key**: 32-byte Base64-encoded string
112
41
 
113
- ### Environment Setup (.env)
42
+ Store these securely in your environment variables:
114
43
 
115
44
  ```env
116
- PATALINK_API_KEY=pk_live_abc123def456...
117
- PATALINK_ENCRYPTION_KEY=xK8j3Hd9Fq2Wm5R...
118
- PATALINK_BASE_URL=https://patalink.me
119
- ```
120
-
121
- ```javascript
122
- import { createClient } from '@patlink/node-api-client';
123
-
124
- const client = createClient({
125
- apiKey: process.env.PATALINK_API_KEY,
126
- encryptionKey: process.env.PATALINK_ENCRYPTION_KEY,
127
- baseUrl: process.env.PATALINK_BASE_URL,
128
- timeout: 30000 // optional
129
- });
45
+ PATALINK_API_KEY=pt_live_your_api_key_here
46
+ PATALINK_ENCRYPTION_KEY=your_base64_encryption_key_here
47
+ PATALINK_BASE_URL=https://api.patalink.me
130
48
  ```
131
49
 
132
50
  ---
133
51
 
134
- ## 📚 API Reference
135
-
136
- ### `createPayment(request)`
137
-
138
- Create a new payment transaction.
52
+ ## 🚀 Quick Start
139
53
 
140
- **Parameters:**
54
+ Initialize the client once in your application:
141
55
 
142
56
  ```typescript
143
- interface PaymentRequest {
144
- amount: number; // Required: Amount in RWF (min 100, max 2,000,000 for mobile money)
145
- currency?: string; // Optional: Currency code (default: 'RWF')
146
- paymentMethod: 'MTN' | 'Airtel' | 'PesaPal'; // Required: Payment method
147
- phoneNumber?: string; // Required for MTN/Airtel
148
- customerName?: string; // Optional: Customer's full name
149
- customerEmail?: string; // Optional: Customer's email address
150
- }
151
- ```
152
-
153
- **Returns:**
57
+ import { createClient } from '@patalink/node-api-client';
154
58
 
155
- ```typescript
156
- interface PaymentResponse {
157
- transactionId: string; // Unique transaction identifier
158
- status: 'pending' | 'success' | 'failed'; // Current payment status
159
- providerRef?: string; // Payment provider reference
160
- redirectUrl?: string; // For PesaPal card payments
161
- message?: string; // Additional response message
162
- }
59
+ const client = createClient({
60
+ apiKey: process.env.PATALINK_API_KEY!,
61
+ encryptionKey: process.env.PATALINK_ENCRYPTION_KEY!,
62
+ baseUrl: process.env.PATALINK_BASE_URL || 'https://api.patalink.me'
63
+ });
163
64
  ```
164
65
 
165
- **Example:**
166
-
66
+ *CommonJS syntax is also fully supported:*
167
67
  ```javascript
168
- // MTN Mobile Money
169
- const payment = await client.createPayment({
170
- amount: 25000,
171
- paymentMethod: 'MTN',
172
- phoneNumber: '0788888888',
173
- customerName: 'John Doe',
174
- customerEmail: 'john@example.com'
175
- });
176
-
177
- // Card Payment (PesaPal)
178
- const cardPayment = await client.createPayment({
179
- amount: 50000,
180
- paymentMethod: 'PesaPal',
181
- customerName: 'Jane Smith',
182
- customerEmail: 'jane@example.com'
183
- });
184
-
185
- // Handle redirect for card payments
186
- if (cardPayment.redirectUrl) {
187
- // Redirect user to payment page
188
- res.redirect(cardPayment.redirectUrl);
189
- }
68
+ const { createClient } = require('@patalink/node-api-client');
190
69
  ```
191
70
 
192
71
  ---
193
72
 
194
- ### `getTransactionStatus(transactionId)`
195
-
196
- Get the current status of a transaction.
73
+ ## 📖 Usage Examples
197
74
 
198
- **Parameters:**
75
+ ### 1. Inbound Collections (Customer Payments)
199
76
 
200
- | Parameter | Type | Description |
201
- |-----------|------|-------------|
202
- | `transactionId` | `string` | Transaction ID from `createPayment` response |
203
-
204
- **Returns:**
77
+ Trigger a payment prompt on the customer's phone (MTN MoMo) or generate a checkout redirect for card payments (PesaPal):
205
78
 
206
79
  ```typescript
207
- interface TransactionStatus {
208
- id: string; // Transaction ID
209
- status: 'pending' | 'success' | 'failed'; // Current status
210
- amount: number; // Transaction amount
211
- currency: string; // Currency code
212
- paymentMethod: string; // Payment method used
213
- createdAt: string; // ISO timestamp of creation
214
- completedAt?: string; // ISO timestamp of completion
215
- providerRef?: string; // Provider reference
216
- }
217
- ```
80
+ // 1. Initiate payment request
81
+ const payment = await client.createPayment({
82
+ amount: 5000, // Amount in RWF (min 100 RWF)
83
+ paymentMethod: 'MTN', // 'MTN' or 'PesaPal'
84
+ phoneNumber: '0788820857', // Customer Mobile Money number
85
+ customerName: 'Alice Umutoni', // Optional
86
+ customerEmail: 'alice@example.com', // Optional
87
+ callbackUrl: 'https://example.com/done' // Required for Card payments
88
+ });
218
89
 
219
- **Example:**
90
+ console.log('Payment Initiated! Transaction ID:', payment.transactionId);
220
91
 
221
- ```javascript
222
- const status = await client.getTransactionStatus('txn_abc123');
92
+ // 2. Poll until customer confirms payment with their MoMo PIN
93
+ const verified = await client.waitForCompletion(payment.transactionId, {
94
+ maxAttempts: 30, // Poll up to 30 times
95
+ interval: 2500, // Check every 2.5 seconds
96
+ onProgress: (attempt, status) => {
97
+ console.log(`Poll #${attempt}: Status is ${status}`);
98
+ }
99
+ });
223
100
 
224
- if (status.status === 'success') {
225
- console.log('Payment completed at:', status.completedAt);
226
- } else if (status.status === 'pending') {
227
- console.log('Still waiting for confirmation...');
101
+ if (verified.status === 'success') {
102
+ console.log('✅ Payment confirmed! Deliver goods or grant access.');
228
103
  } else {
229
- console.log('Payment failed');
104
+ console.log('❌ Payment failed or expired.');
230
105
  }
231
106
  ```
232
107
 
233
108
  ---
234
109
 
235
- ## 💡 Usage Examples
110
+ ### 2. Instant B2C Disbursements (Payouts)
236
111
 
237
- ### Webhook Endpoint (Express.js)
238
-
239
- ```javascript
240
- import express from 'express';
241
- import { createClient } from '@patlink/node-api-client';
242
-
243
- const app = express();
244
- const client = createClient({
245
- apiKey: process.env.PATALINK_API_KEY,
246
- encryptionKey: process.env.PATALINK_ENCRYPTION_KEY,
247
- baseUrl: process.env.PATALINK_BASE_URL
248
- });
249
-
250
- app.post('/api/create-payment', async (req, res) => {
251
- try {
252
- const { amount, phoneNumber, customerName, customerEmail } = req.body;
253
-
254
- const payment = await client.createPayment({
255
- amount,
256
- paymentMethod: 'MTN',
257
- phoneNumber,
258
- customerName,
259
- customerEmail
260
- });
261
-
262
- res.json({
263
- success: true,
264
- transactionId: payment.transactionId,
265
- status: payment.status
266
- });
267
- } catch (error) {
268
- res.status(400).json({
269
- success: false,
270
- error: error.message
271
- });
272
- }
273
- });
274
-
275
- app.get('/api/payment-status/:transactionId', async (req, res) => {
276
- try {
277
- const status = await client.getTransactionStatus(req.params.transactionId);
278
- res.json(status);
279
- } catch (error) {
280
- res.status(404).json({ error: error.message });
281
- }
282
- });
283
-
284
- app.listen(3000, () => {
285
- console.log('Server running on port 3000');
286
- });
287
- ```
288
-
289
- ### Polling Implementation
290
-
291
- ```javascript
292
- async function pollPaymentStatus(transactionId, maxAttempts = 30, interval = 2000) {
293
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
294
- console.log(`Checking status (attempt ${attempt}/${maxAttempts})...`);
295
-
296
- const status = await client.getTransactionStatus(transactionId);
297
-
298
- if (status.status === 'success') {
299
- console.log('✅ Payment successful!');
300
- return status;
301
- }
302
-
303
- if (status.status === 'failed') {
304
- console.log('❌ Payment failed!');
305
- return status;
306
- }
307
-
308
- // Wait before next attempt
309
- await new Promise(resolve => setTimeout(resolve, interval));
310
- }
311
-
312
- throw new Error('Payment timeout - still pending after maximum attempts');
313
- }
314
-
315
- // Usage
316
- const transaction = await client.createPayment({
317
- amount: 25000,
318
- paymentMethod: 'MTN',
319
- phoneNumber: '0788888888'
320
- });
321
-
322
- const result = await pollPaymentStatus(transaction.transactionId);
323
- console.log('Final status:', result.status);
324
- ```
325
-
326
- ### Next.js API Route
112
+ Send funds directly from your PataLink cleared wallet balance to any MTN Mobile Money number in Rwanda:
327
113
 
328
114
  ```typescript
329
- // app/api/payments/route.ts
330
- import { NextResponse } from 'next/server';
331
- import { createClient } from '@patlink/node-api-client';
332
-
333
- const client = createClient({
334
- apiKey: process.env.PATALINK_API_KEY!,
335
- encryptionKey: process.env.PATALINK_ENCRYPTION_KEY!,
336
- baseUrl: process.env.PATALINK_BASE_URL!
115
+ // 1. Dispatch payout
116
+ const payout = await client.createWithdrawal({
117
+ amount: 25000, // Amount in RWF (min 1,000, max 2,000,000)
118
+ destinationNumber: '0788820857', // MTN Rwanda number (078... / 079...)
119
+ network: 'MTN', // Network (default: 'MTN')
120
+ reference: 'PAYOUT-ORD-9021', // Optional: Your internal reference
121
+ recipientNote: 'Weekly Creator Earnings',// Optional: Note to recipient
122
+ idempotencyKey: 'payout-uuid-v4' // Optional: Prevents duplicate transfers
337
123
  });
338
124
 
339
- export async function POST(request: Request) {
340
- try {
341
- const body = await request.json();
342
-
343
- const payment = await client.createPayment({
344
- amount: body.amount,
345
- paymentMethod: body.paymentMethod,
346
- phoneNumber: body.phoneNumber,
347
- customerName: body.customerName,
348
- customerEmail: body.customerEmail
349
- });
350
-
351
- return NextResponse.json(payment);
352
- } catch (error: any) {
353
- return NextResponse.json(
354
- { error: error.message },
355
- { status: 400 }
356
- );
357
- }
358
- }
359
-
360
- export async function GET(request: Request) {
361
- const { searchParams } = new URL(request.url);
362
- const transactionId = searchParams.get('transactionId');
363
-
364
- if (!transactionId) {
365
- return NextResponse.json(
366
- { error: 'transactionId is required' },
367
- { status: 400 }
368
- );
369
- }
370
-
371
- try {
372
- const status = await client.getTransactionStatus(transactionId);
373
- return NextResponse.json(status);
374
- } catch (error: any) {
375
- return NextResponse.json(
376
- { error: error.message },
377
- { status: 404 }
378
- );
379
- }
380
- }
381
- ```
125
+ console.log('Payout Submitted:', payout.id, 'Status:', payout.status);
382
126
 
383
- ### Bulk Payment Processing
127
+ // 2. Poll until terminal status (paid or failed)
128
+ const finalPayout = await client.waitForWithdrawalCompletion(payout.id, {
129
+ maxAttempts: 30,
130
+ interval: 2000,
131
+ onProgress: (attempt, status) => {
132
+ console.log(`Disbursement Poll #${attempt}: ${status}`);
133
+ }
134
+ });
384
135
 
385
- ```javascript
386
- async function processBulkPayments(payments) {
387
- const results = [];
388
-
389
- for (const payment of payments) {
390
- try {
391
- const result = await client.createPayment(payment);
392
- results.push({
393
- success: true,
394
- transactionId: result.transactionId,
395
- amount: payment.amount,
396
- phoneNumber: payment.phoneNumber
397
- });
398
- } catch (error) {
399
- results.push({
400
- success: false,
401
- error: error.message,
402
- amount: payment.amount,
403
- phoneNumber: payment.phoneNumber
404
- });
405
- }
406
-
407
- // Rate limiting - avoid overwhelming the API
408
- await new Promise(resolve => setTimeout(resolve, 500));
409
- }
410
-
411
- return results;
136
+ if (finalPayout.status === 'paid') {
137
+ console.log('✅ Funds delivered to recipient mobile wallet!');
412
138
  }
413
-
414
- // Usage
415
- const payments = [
416
- { amount: 10000, paymentMethod: 'MTN', phoneNumber: '0788888888' },
417
- { amount: 25000, paymentMethod: 'Airtel', phoneNumber: '0789999999' },
418
- { amount: 5000, paymentMethod: 'MTN', phoneNumber: '0787777777' }
419
- ];
420
-
421
- const results = await processBulkPayments(payments);
422
- console.log(results);
423
139
  ```
424
140
 
425
141
  ---
426
142
 
427
- ## 🚨 Error Handling
143
+ ### 3. Check Wallet Balances & Account Info
428
144
 
429
- ### Common Errors
145
+ Verify your merchant cleared balance before initiating payouts:
430
146
 
431
- ```javascript
432
- try {
433
- const payment = await client.createPayment({
434
- amount: 500,
435
- paymentMethod: 'MTN',
436
- phoneNumber: '0788888888'
437
- });
438
- } catch (error) {
439
- switch (error.message) {
440
- case 'Amount must be at least 100 RWF':
441
- // Handle minimum amount error
442
- break;
443
- case 'Mobile Money limit is 2,000,000 RWF':
444
- // Handle maximum amount error
445
- break;
446
- case 'phoneNumber is required for mobile money payments':
447
- // Handle missing phone number
448
- break;
449
- case 'Request timeout':
450
- // Handle timeout - implement retry logic
451
- break;
452
- default:
453
- // Handle other errors
454
- console.error('Payment failed:', error.message);
455
- }
456
- }
457
- ```
147
+ ```typescript
148
+ const account = await client.getAccountInfo();
458
149
 
459
- ### Retry Logic with Exponential Backoff
150
+ console.log('User ID: ', account.id);
151
+ console.log('Available Cleared: ', account.availableBalance, 'RWF');
152
+ console.log('Total Gross Volume:', account.walletBalance, 'RWF');
153
+ console.log('Pending Funds: ', account.pendingBalance, 'RWF');
460
154
 
461
- ```javascript
462
- async function createPaymentWithRetry(request, maxRetries = 3) {
463
- let lastError;
464
-
465
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
466
- try {
467
- return await client.createPayment(request);
468
- } catch (error) {
469
- lastError = error;
470
-
471
- if (attempt === maxRetries) break;
472
-
473
- // Exponential backoff: 1s, 2s, 4s
474
- const delay = Math.pow(2, attempt - 1) * 1000;
475
- console.log(`Retry ${attempt}/${maxRetries} after ${delay}ms...`);
476
- await new Promise(resolve => setTimeout(resolve, delay));
477
- }
478
- }
479
-
480
- throw lastError;
155
+ // Example: Guard payout against insufficient balance
156
+ if (account.availableBalance < 50000) {
157
+ throw new Error('Insufficient cleared balance to perform payout');
481
158
  }
482
-
483
- // Usage
484
- const payment = await createPaymentWithRetry({
485
- amount: 25000,
486
- paymentMethod: 'MTN',
487
- phoneNumber: '0788888888'
488
- });
489
159
  ```
490
160
 
491
161
  ---
492
162
 
493
- ## 🔷 TypeScript Support
163
+ ### 4. Webhook Listener (Express / Node.js)
494
164
 
495
- Full TypeScript support with complete type definitions.
165
+ Configure a webhook endpoint in your server to receive asynchronous real-time events for collections and disbursements:
496
166
 
497
- ### Type Definitions
498
-
499
- ```typescript
500
- import { createClient, PaymentRequest, PaymentResponse, TransactionStatus } from '@patlink/node-api-client';
167
+ ```javascript
168
+ import express from 'express';
501
169
 
502
- const client = createClient({
503
- apiKey: process.env.PATALINK_API_KEY!,
504
- encryptionKey: process.env.PATALINK_ENCRYPTION_KEY!,
505
- baseUrl: process.env.PATALINK_BASE_URL!
170
+ const app = express();
171
+ app.use(express.json());
172
+
173
+ app.post('/api/webhooks/patalink', (req, res) => {
174
+ const { event, data } = req.body;
175
+
176
+ // Handle Payout Events (Disbursements)
177
+ if (event === 'withdrawal.paid') {
178
+ console.log(`✅ Payout Delivered: ${data.withdrawalId} (${data.amount} RWF)`);
179
+ // Update order status in your database
180
+ } else if (event === 'withdrawal.failed') {
181
+ console.log(`❌ Payout Failed: ${data.withdrawalId} - Reason: ${data.failureReason}`);
182
+ }
183
+
184
+ // Handle Collection Events (Inbound Payments)
185
+ else if (event === 'payment.completed' || req.body.status === 'success') {
186
+ const txnId = req.body.transactionId || data?.transactionId;
187
+ console.log(`💰 Inbound Payment Confirmed: ${txnId}`);
188
+ // Fulfill customer order
189
+ }
190
+
191
+ // Always respond with 200 OK immediately
192
+ res.status(200).send('OK');
506
193
  });
507
194
 
508
- async function processPayment(request: PaymentRequest): Promise<PaymentResponse> {
509
- const payment = await client.createPayment(request);
510
- return payment;
511
- }
512
-
513
- async function checkStatus(transactionId: string): Promise<TransactionStatus> {
514
- const status = await client.getTransactionStatus(transactionId);
515
- return status;
516
- }
195
+ app.listen(3000, () => console.log('Webhook server running on port 3000'));
517
196
  ```
518
197
 
519
198
  ---
520
199
 
521
- ## 🌐 Browser Support
200
+ ## 🧪 Sandbox & Testing
522
201
 
523
- This package is designed for **Node.js server-side** use only. For browser/client-side integration, use our HTML web component:
202
+ You can test full workflows in the sandbox without touching real money:
203
+ - Use an API key starting with `pt_test_...`.
204
+ - Use test phone number `0788820857`.
205
+ - **Withdrawal Simulation Conventions**:
206
+ - Amounts ending in `99` (e.g., `1099 RWF`) simulate a **failed disbursement**.
207
+ - Amounts ending in `55` (e.g., `1555 RWF`) stay **pending** for testing timeout handling.
208
+ - All other amounts automatically transition to **paid**.
524
209
 
525
- ```html
526
- <script type="module">
527
- import 'https://esm.sh/@patlink/html-payment-modal';
528
- </script>
529
-
530
- <payment-modal
531
- base-url="https://patalink.me"
532
- api-key="your_api_key"
533
- encryption-key="your_encryption_key"
534
- ></payment-modal>
535
- ```
210
+ Try out live requests in the interactive **[PataLink Developer Playground](https://patalink.me/developer/docs)** before writing code.
536
211
 
537
212
  ---
538
213
 
539
- ## ❓ FAQ
540
-
541
- ### Can I use this in the browser?
542
-
543
- **No.** This package uses server-side encryption and should only be used in Node.js environments. For browser payments, use our HTML web component instead.
544
-
545
- ### How do I get my API and encryption keys?
546
-
547
- 1. Sign up at [dashboard.patalink.me](https://dashboard.patalink.me)
548
- 2. Go to **Settings** → **API Keys**
549
- 3. Click **Generate API Key** and **Generate Encryption Key**
550
- 4. Store both keys securely in environment variables
551
-
552
- ### What's the difference between API key and encryption key?
553
-
554
- - **API Key**: Identifies your account (authentication)
555
- - **Encryption Key**: Encrypts payment data (security)
556
-
557
- Both are required and should be kept secret.
558
-
559
- ### What payment methods are supported?
560
-
561
- - MTN Mobile Money
562
- - Airtel Money
563
- - Credit/Debit Cards (via PesaPal)
564
-
565
- ### What's the minimum and maximum payment amount?
566
-
567
- - **Minimum**: 100 RWF
568
- - **Maximum for Mobile Money**: 2,000,000 RWF
569
- - **Maximum for Cards**: No limit (subject to card issuer)
570
-
571
- ### How do I handle card payment redirects?
572
-
573
- For PesaPal card payments, the response includes a `redirectUrl`. You need to redirect your user to this URL to complete the payment.
574
-
575
- ```javascript
576
- const payment = await client.createPayment({
577
- amount: 50000,
578
- paymentMethod: 'PesaPal',
579
- customerEmail: 'customer@example.com'
580
- });
581
-
582
- if (payment.redirectUrl) {
583
- res.redirect(payment.redirectUrl);
584
- }
585
- ```
586
-
587
- ### Is the encryption automatic?
588
-
589
- **Yes.** The client automatically encrypts all payment data before sending to our API. You don't need to implement any encryption logic.
590
-
591
- ### Can I test without real money?
592
-
593
- **Yes.** Use your test keys from the dashboard (they start with `pk_test_`). Test transactions are processed in sandbox mode.
214
+ ## 📚 API Reference
594
215
 
595
- ### What happens if a request times out?
216
+ ### `createClient(config: ClientConfig): PataLinkClient`
596
217
 
597
- The client will throw a timeout error after the configured timeout period (default 30 seconds). You should implement retry logic with exponential backoff.
218
+ | Option | Type | Required | Description |
219
+ |---|---|---|---|
220
+ | `apiKey` | `string` | Yes | Your PataLink API key (`pt_live_...` or `pt_test_...`) |
221
+ | `encryptionKey` | `string` | Yes | 32-byte Base64 encryption key from dashboard |
222
+ | `baseUrl` | `string` | No | API endpoint (defaults to `https://api.patalink.me`) |
223
+ | `timeout` | `number` | No | Request timeout in milliseconds (default: 30000) |
598
224
 
599
- ### How do I know if a payment is complete?
225
+ ### Client Methods
600
226
 
601
- 1. Check the `status` field in the `createPayment` response
602
- 2. For pending payments, use `getTransactionStatus` to poll
603
- 3. Or set up webhooks to receive real-time updates
227
+ - `client.createPayment(request: PaymentRequest): Promise<PaymentResponse>`
228
+ - `client.getTransactionStatus(transactionId: string): Promise<TransactionStatus>`
229
+ - `client.waitForCompletion(transactionId: string, options?): Promise<TransactionStatus>`
230
+ - `client.createWithdrawal(request: WithdrawalRequest): Promise<WithdrawalResponse>`
231
+ - `client.getWithdrawalStatus(withdrawalId: string): Promise<WithdrawalStatus>`
232
+ - `client.waitForWithdrawalCompletion(withdrawalId: string, options?): Promise<WithdrawalStatus>`
233
+ - `client.getAccountInfo(): Promise<AccountInfo>`
234
+ - `client.listTransactions(options?): Promise<{ transactions, total }>`
235
+ - `client.listWithdrawals(options?): Promise<{ withdrawals, total }>`
604
236
 
605
237
  ---
606
238
 
607
- ## 📞 Support
239
+ ## 🔒 Security
608
240
 
609
- - 📖 [Full API Documentation](https://docs.patalink.me)
610
- - 💬 [Discord Community](https://discord.gg/patlink)
611
- - 🐛 [GitHub Issues](https://github.com/patlink/api-client/issues)
612
- - ✉️ [support@patalink.me](mailto:support@patalink.me)
241
+ All transaction initiation requests to PataLink are protected by **AES-256-CBC payload encryption** and signed with an **HMAC-SHA256 signature** generated using your secret encryption key. This SDK handles encryption and signing automatically for every request.
613
242
 
614
243
  ---
615
244
 
616
245
  ## 📄 License
617
246
 
618
247
  MIT © [PataLink](https://patalink.me)
619
-
620
- ---
621
-
622
- **Secure, encrypted payments for your Node.js backend.** 🚀
623
- ```
package/dist/index.d.mts CHANGED
@@ -24,6 +24,43 @@ interface TransactionStatus {
24
24
  completedAt?: string;
25
25
  providerRef?: string;
26
26
  }
27
+ interface WithdrawalRequest {
28
+ amount: number;
29
+ destinationNumber?: string;
30
+ phoneNumber?: string;
31
+ network?: 'MTN';
32
+ reference?: string;
33
+ recipientNote?: string;
34
+ callbackUrl?: string;
35
+ idempotencyKey?: string;
36
+ }
37
+ interface WithdrawalResponse {
38
+ id: string;
39
+ withdrawalId: string;
40
+ status: 'processing' | 'pending' | 'paid' | 'rejected' | 'failed';
41
+ amount: number;
42
+ feeAmount: number;
43
+ netAmount: number;
44
+ currency: string;
45
+ destinationNumber: string;
46
+ network: 'MTN';
47
+ reference?: string;
48
+ providerRef?: string;
49
+ externalId?: string;
50
+ createdAt: string;
51
+ }
52
+ interface WithdrawalStatus {
53
+ id: string;
54
+ status: 'processing' | 'pending' | 'paid' | 'rejected' | 'failed';
55
+ amount: number;
56
+ destinationNumber: string;
57
+ network: 'MTN';
58
+ reference?: string;
59
+ providerRef?: string;
60
+ reason?: string;
61
+ processedAt?: string;
62
+ createdAt: string;
63
+ }
27
64
  interface ClientConfig {
28
65
  apiKey: string;
29
66
  encryptionKey: string;
@@ -89,17 +126,21 @@ declare class PataLinkClient {
89
126
  availableBalance: number;
90
127
  }>;
91
128
  /**
92
- * Initiate withdrawal
129
+ * Initiate a withdrawal / payout (MTN Mobile Money)
93
130
  */
94
- createWithdrawal(request: {
95
- amount: number;
96
- phoneNumber: string;
97
- network: 'MTN' | 'Airtel';
98
- }): Promise<{
99
- withdrawalId: string;
100
- status: string;
101
- message: string;
102
- }>;
131
+ createWithdrawal(request: WithdrawalRequest): Promise<WithdrawalResponse>;
132
+ /**
133
+ * Get withdrawal status by ID, external ID, or reference
134
+ */
135
+ getWithdrawalStatus(withdrawalId: string): Promise<WithdrawalStatus>;
136
+ /**
137
+ * Poll until withdrawal reaches a terminal status (paid, rejected, or failed)
138
+ */
139
+ waitForWithdrawalCompletion(withdrawalId: string, options?: {
140
+ maxAttempts?: number;
141
+ interval?: number;
142
+ onProgress?: (attempt: number, status: string) => void;
143
+ }): Promise<WithdrawalStatus>;
103
144
  /**
104
145
  * List withdrawals
105
146
  */
@@ -113,4 +154,4 @@ declare class PataLinkClient {
113
154
  }
114
155
  declare function createClient(config: ClientConfig): PataLinkClient;
115
156
 
116
- export { type ClientConfig, type PaymentRequest, type PaymentResponse, type TransactionStatus, createClient, PataLinkClient as default };
157
+ export { type ClientConfig, type PaymentRequest, type PaymentResponse, type TransactionStatus, type WithdrawalRequest, type WithdrawalResponse, type WithdrawalStatus, createClient, PataLinkClient as default };
package/dist/index.d.ts CHANGED
@@ -24,6 +24,43 @@ interface TransactionStatus {
24
24
  completedAt?: string;
25
25
  providerRef?: string;
26
26
  }
27
+ interface WithdrawalRequest {
28
+ amount: number;
29
+ destinationNumber?: string;
30
+ phoneNumber?: string;
31
+ network?: 'MTN';
32
+ reference?: string;
33
+ recipientNote?: string;
34
+ callbackUrl?: string;
35
+ idempotencyKey?: string;
36
+ }
37
+ interface WithdrawalResponse {
38
+ id: string;
39
+ withdrawalId: string;
40
+ status: 'processing' | 'pending' | 'paid' | 'rejected' | 'failed';
41
+ amount: number;
42
+ feeAmount: number;
43
+ netAmount: number;
44
+ currency: string;
45
+ destinationNumber: string;
46
+ network: 'MTN';
47
+ reference?: string;
48
+ providerRef?: string;
49
+ externalId?: string;
50
+ createdAt: string;
51
+ }
52
+ interface WithdrawalStatus {
53
+ id: string;
54
+ status: 'processing' | 'pending' | 'paid' | 'rejected' | 'failed';
55
+ amount: number;
56
+ destinationNumber: string;
57
+ network: 'MTN';
58
+ reference?: string;
59
+ providerRef?: string;
60
+ reason?: string;
61
+ processedAt?: string;
62
+ createdAt: string;
63
+ }
27
64
  interface ClientConfig {
28
65
  apiKey: string;
29
66
  encryptionKey: string;
@@ -89,17 +126,21 @@ declare class PataLinkClient {
89
126
  availableBalance: number;
90
127
  }>;
91
128
  /**
92
- * Initiate withdrawal
129
+ * Initiate a withdrawal / payout (MTN Mobile Money)
93
130
  */
94
- createWithdrawal(request: {
95
- amount: number;
96
- phoneNumber: string;
97
- network: 'MTN' | 'Airtel';
98
- }): Promise<{
99
- withdrawalId: string;
100
- status: string;
101
- message: string;
102
- }>;
131
+ createWithdrawal(request: WithdrawalRequest): Promise<WithdrawalResponse>;
132
+ /**
133
+ * Get withdrawal status by ID, external ID, or reference
134
+ */
135
+ getWithdrawalStatus(withdrawalId: string): Promise<WithdrawalStatus>;
136
+ /**
137
+ * Poll until withdrawal reaches a terminal status (paid, rejected, or failed)
138
+ */
139
+ waitForWithdrawalCompletion(withdrawalId: string, options?: {
140
+ maxAttempts?: number;
141
+ interval?: number;
142
+ onProgress?: (attempt: number, status: string) => void;
143
+ }): Promise<WithdrawalStatus>;
103
144
  /**
104
145
  * List withdrawals
105
146
  */
@@ -113,4 +154,4 @@ declare class PataLinkClient {
113
154
  }
114
155
  declare function createClient(config: ClientConfig): PataLinkClient;
115
156
 
116
- export { type ClientConfig, type PaymentRequest, type PaymentResponse, type TransactionStatus, createClient, PataLinkClient as default };
157
+ export { type ClientConfig, type PaymentRequest, type PaymentResponse, type TransactionStatus, type WithdrawalRequest, type WithdrawalResponse, type WithdrawalStatus, createClient, PataLinkClient as default };
package/dist/index.js CHANGED
@@ -34,7 +34,7 @@ __export(index_exports, {
34
34
  default: () => index_default
35
35
  });
36
36
  module.exports = __toCommonJS(index_exports);
37
- var CryptoJS = __toESM(require("crypto-js"));
37
+ var import_crypto_js = __toESM(require("crypto-js"));
38
38
  var PataLinkClient = class {
39
39
  constructor(config) {
40
40
  if (!config.apiKey) throw new Error("API key is required");
@@ -52,20 +52,20 @@ var PataLinkClient = class {
52
52
  const dataString = JSON.stringify(data);
53
53
  let keyWA;
54
54
  try {
55
- keyWA = CryptoJS.enc.Base64.parse(this.encryptionKey);
55
+ keyWA = import_crypto_js.default.enc.Base64.parse(this.encryptionKey);
56
56
  } catch (error) {
57
- keyWA = CryptoJS.enc.Utf8.parse(this.encryptionKey);
57
+ keyWA = import_crypto_js.default.enc.Utf8.parse(this.encryptionKey);
58
58
  }
59
- const iv = CryptoJS.lib.WordArray.random(16);
60
- const ivHex = iv.toString(CryptoJS.enc.Hex);
61
- const ivWA = CryptoJS.enc.Hex.parse(ivHex);
62
- const encrypted = CryptoJS.AES.encrypt(dataString, keyWA, {
59
+ const iv = import_crypto_js.default.lib.WordArray.random(16);
60
+ const ivHex = iv.toString(import_crypto_js.default.enc.Hex);
61
+ const ivWA = import_crypto_js.default.enc.Hex.parse(ivHex);
62
+ const encrypted = import_crypto_js.default.AES.encrypt(dataString, keyWA, {
63
63
  iv: ivWA,
64
- mode: CryptoJS.mode.CBC,
65
- padding: CryptoJS.pad.Pkcs7
64
+ mode: import_crypto_js.default.mode.CBC,
65
+ padding: import_crypto_js.default.pad.Pkcs7
66
66
  });
67
- const encryptedBase64 = encrypted.ciphertext.toString(CryptoJS.enc.Base64);
68
- const hmac = CryptoJS.HmacSHA256(dataString, keyWA).toString();
67
+ const encryptedBase64 = encrypted.ciphertext.toString(import_crypto_js.default.enc.Base64);
68
+ const hmac = import_crypto_js.default.HmacSHA256(dataString, keyWA).toString();
69
69
  return {
70
70
  encrypted: encryptedBase64,
71
71
  iv: ivHex,
@@ -159,8 +159,8 @@ var PataLinkClient = class {
159
159
  let attempts = 0;
160
160
  while (attempts < maxAttempts) {
161
161
  attempts++;
162
- options?.onProgress?.(attempts, "checking");
163
162
  const status = await this.getTransactionStatus(transactionId);
163
+ options?.onProgress?.(attempts, status.status);
164
164
  if (status.status === "success" || status.status === "failed") {
165
165
  return status;
166
166
  }
@@ -197,21 +197,64 @@ var PataLinkClient = class {
197
197
  return this.request("/account", { method: "GET" });
198
198
  }
199
199
  /**
200
- * Initiate withdrawal
200
+ * Initiate a withdrawal / payout (MTN Mobile Money)
201
201
  */
202
202
  async createWithdrawal(request) {
203
+ const destination = request.destinationNumber || request.phoneNumber;
203
204
  if (!request.amount) throw new Error("amount is required");
204
- if (!request.phoneNumber) throw new Error("phoneNumber is required");
205
- if (request.amount < 100) throw new Error("Minimum withdrawal is 100 RWF");
205
+ if (!destination) throw new Error("destinationNumber is required");
206
+ if (request.amount < 1e3) throw new Error("Minimum withdrawal is 1,000 RWF");
206
207
  if (request.amount > 2e6) throw new Error("Maximum withdrawal is 2,000,000 RWF");
207
- const encryptedPayload = this.encrypt(request);
208
- return this.request("/withdrawals", {
208
+ const withdrawalData = {
209
+ amount: request.amount,
210
+ destinationNumber: destination,
211
+ network: request.network || "MTN",
212
+ ...request.reference && { reference: request.reference },
213
+ ...request.recipientNote && { recipientNote: request.recipientNote },
214
+ ...request.callbackUrl && { callbackUrl: request.callbackUrl },
215
+ ...request.idempotencyKey && { idempotencyKey: request.idempotencyKey }
216
+ };
217
+ const encryptedPayload = this.encrypt(withdrawalData);
218
+ const headers = {
219
+ "x-encrypted": "true"
220
+ };
221
+ if (request.idempotencyKey) {
222
+ headers["Idempotency-Key"] = request.idempotencyKey;
223
+ }
224
+ const response = await this.request("/withdraw", {
209
225
  method: "POST",
210
- headers: {
211
- "x-encrypted": "true"
212
- },
226
+ headers,
213
227
  body: JSON.stringify(encryptedPayload)
214
228
  });
229
+ response.withdrawalId = response.id;
230
+ return response;
231
+ }
232
+ /**
233
+ * Get withdrawal status by ID, external ID, or reference
234
+ */
235
+ async getWithdrawalStatus(withdrawalId) {
236
+ if (!withdrawalId) throw new Error("withdrawalId is required");
237
+ return await this.request(`/withdraw/${withdrawalId}`, {
238
+ method: "GET"
239
+ });
240
+ }
241
+ /**
242
+ * Poll until withdrawal reaches a terminal status (paid, rejected, or failed)
243
+ */
244
+ async waitForWithdrawalCompletion(withdrawalId, options) {
245
+ const maxAttempts = options?.maxAttempts || 60;
246
+ const interval = options?.interval || 3e3;
247
+ let attempts = 0;
248
+ while (attempts < maxAttempts) {
249
+ attempts++;
250
+ const status = await this.getWithdrawalStatus(withdrawalId);
251
+ options?.onProgress?.(attempts, status.status);
252
+ if (status.status === "paid" || status.status === "rejected" || status.status === "failed") {
253
+ return status;
254
+ }
255
+ await new Promise((resolve) => setTimeout(resolve, interval));
256
+ }
257
+ throw new Error("Withdrawal timeout - still processing after maximum attempts");
215
258
  }
216
259
  /**
217
260
  * List withdrawals
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- import * as CryptoJS from "crypto-js";
2
+ import CryptoJS from "crypto-js";
3
3
  var PataLinkClient = class {
4
4
  constructor(config) {
5
5
  if (!config.apiKey) throw new Error("API key is required");
@@ -124,8 +124,8 @@ var PataLinkClient = class {
124
124
  let attempts = 0;
125
125
  while (attempts < maxAttempts) {
126
126
  attempts++;
127
- options?.onProgress?.(attempts, "checking");
128
127
  const status = await this.getTransactionStatus(transactionId);
128
+ options?.onProgress?.(attempts, status.status);
129
129
  if (status.status === "success" || status.status === "failed") {
130
130
  return status;
131
131
  }
@@ -162,21 +162,64 @@ var PataLinkClient = class {
162
162
  return this.request("/account", { method: "GET" });
163
163
  }
164
164
  /**
165
- * Initiate withdrawal
165
+ * Initiate a withdrawal / payout (MTN Mobile Money)
166
166
  */
167
167
  async createWithdrawal(request) {
168
+ const destination = request.destinationNumber || request.phoneNumber;
168
169
  if (!request.amount) throw new Error("amount is required");
169
- if (!request.phoneNumber) throw new Error("phoneNumber is required");
170
- if (request.amount < 100) throw new Error("Minimum withdrawal is 100 RWF");
170
+ if (!destination) throw new Error("destinationNumber is required");
171
+ if (request.amount < 1e3) throw new Error("Minimum withdrawal is 1,000 RWF");
171
172
  if (request.amount > 2e6) throw new Error("Maximum withdrawal is 2,000,000 RWF");
172
- const encryptedPayload = this.encrypt(request);
173
- return this.request("/withdrawals", {
173
+ const withdrawalData = {
174
+ amount: request.amount,
175
+ destinationNumber: destination,
176
+ network: request.network || "MTN",
177
+ ...request.reference && { reference: request.reference },
178
+ ...request.recipientNote && { recipientNote: request.recipientNote },
179
+ ...request.callbackUrl && { callbackUrl: request.callbackUrl },
180
+ ...request.idempotencyKey && { idempotencyKey: request.idempotencyKey }
181
+ };
182
+ const encryptedPayload = this.encrypt(withdrawalData);
183
+ const headers = {
184
+ "x-encrypted": "true"
185
+ };
186
+ if (request.idempotencyKey) {
187
+ headers["Idempotency-Key"] = request.idempotencyKey;
188
+ }
189
+ const response = await this.request("/withdraw", {
174
190
  method: "POST",
175
- headers: {
176
- "x-encrypted": "true"
177
- },
191
+ headers,
178
192
  body: JSON.stringify(encryptedPayload)
179
193
  });
194
+ response.withdrawalId = response.id;
195
+ return response;
196
+ }
197
+ /**
198
+ * Get withdrawal status by ID, external ID, or reference
199
+ */
200
+ async getWithdrawalStatus(withdrawalId) {
201
+ if (!withdrawalId) throw new Error("withdrawalId is required");
202
+ return await this.request(`/withdraw/${withdrawalId}`, {
203
+ method: "GET"
204
+ });
205
+ }
206
+ /**
207
+ * Poll until withdrawal reaches a terminal status (paid, rejected, or failed)
208
+ */
209
+ async waitForWithdrawalCompletion(withdrawalId, options) {
210
+ const maxAttempts = options?.maxAttempts || 60;
211
+ const interval = options?.interval || 3e3;
212
+ let attempts = 0;
213
+ while (attempts < maxAttempts) {
214
+ attempts++;
215
+ const status = await this.getWithdrawalStatus(withdrawalId);
216
+ options?.onProgress?.(attempts, status.status);
217
+ if (status.status === "paid" || status.status === "rejected" || status.status === "failed") {
218
+ return status;
219
+ }
220
+ await new Promise((resolve) => setTimeout(resolve, interval));
221
+ }
222
+ throw new Error("Withdrawal timeout - still processing after maximum attempts");
180
223
  }
181
224
  /**
182
225
  * List withdrawals
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patalink/node-api-client",
3
- "version": "1.0.6",
3
+ "version": "1.1.0",
4
4
  "description": "Official PataLink API client - Simple, secure payment integration",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -12,7 +12,9 @@
12
12
  "require": "./dist/index.js"
13
13
  }
14
14
  },
15
- "files": ["dist"],
15
+ "files": [
16
+ "dist"
17
+ ],
16
18
  "scripts": {
17
19
  "build": "tsup src/index.ts --format cjs,esm --dts --clean",
18
20
  "test": "jest",
@@ -40,4 +42,4 @@
40
42
  "dependencies": {
41
43
  "crypto-js": "^4.2.0"
42
44
  }
43
- }
45
+ }