@herberthtk/yo-payments-api 0.1.0 → 0.3.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,5 +1,9 @@
1
1
  # @herberthtk/yo-payments-api
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@herberthtk/yo-payments-api.svg)](https://www.npmjs.com/package/@herberthtk/yo-payments-api)
4
+ [![CI](https://github.com/herberthk/yo-payments-api/actions/workflows/ci.yml/badge.svg)](https://github.com/herberthk/yo-payments-api/actions)
5
+ [![license](https://img.shields.io/npm/l/@herberthtk/yo-payments-api.svg)](https://github.com/herberthk/yo-payments-api/blob/main/LICENSE)
6
+
3
7
  TypeScript client for the [Yo! Payments API PHP library](https://github.com/YO-Uganda) (`YoAPI.php`) for mobile money, airtime and account operations on the Yo! Payments gateway. Runs on [Bun](https://bun.com) and Node.js 18+ (uses `fetch` + `node:crypto`), including Next.js App Router handlers, Server Actions and Server Components (**server-side only** — never import it into a Client Component).
4
8
 
5
9
  ## Install
@@ -9,7 +13,7 @@ npm install @herberthtk/yo-payments-api
9
13
  # or: bun add @herberthtk/yo-payments-api
10
14
  ```
11
15
 
12
- ## Usage
16
+ ## Quick start
13
17
 
14
18
  ```ts
15
19
  import { YoAPI } from "@herberthtk/yo-payments-api";
@@ -18,9 +22,13 @@ import { YoAPI } from "@herberthtk/yo-payments-api";
18
22
  const yoAPI = new YoAPI("API_USERNAME", "API_PASSWORD");
19
23
 
20
24
  // Request a mobile money user to deposit funds into your account
21
- const response = await yoAPI.acDepositFunds("256770000000", 10000, "Reason for transfer of funds");
25
+ const response = await yoAPI.acDepositFunds(
26
+ "256770000000",
27
+ 10000,
28
+ "Reason for transfer of funds",
29
+ );
22
30
  if (response.Status === "OK") {
23
- console.log("Transaction Reference =", response.TransactionReference);
31
+ console.log("Transaction Reference =", response.TransactionReference);
24
32
  }
25
33
 
26
34
  // Check the balance of your account
@@ -30,55 +38,447 @@ console.log(balance.balance); // [{ code: "UGX", balance: "50000" }, ...]
30
38
 
31
39
  All network methods are `async` and return typed response objects. Method names use idiomatic camelCase (e.g. `acDepositFunds`, `setExternalReference`, `getTransactionLimitAccountIdentifier`) — the one intentional divergence from the PHP library's `snake_case` names; the XML wire format is unchanged.
32
40
 
33
- ### Available operations
41
+ ## Configuration
34
42
 
35
- - `acDepositFunds(msisdn, amount, narrative)`
36
- - `acTransactionCheckStatus(transactionReference, privateTransactionReference?)`
37
- - `acInternalTransfer(currencyCode, amount, beneficiaryAccount, beneficiaryEmail, narrative)`
38
- - `acAcctBalance()`
39
- - `acGetMinistatement(startDate?, endDate?, transactionStatus?, currencyCode?, resultSetLimit?, transactionEntryDesignation?, externalReference?)`
40
- - `acSendAirtimeMobile(msisdn, amount, narrative)`
41
- - `acSendAirtimeInternal(currencyCode, amount, beneficiaryAccount, beneficiaryEmail, narrative)`
42
- - `acWithdrawFunds(msisdn, amount, narrative)`
43
- - `acUserPurchaseAirtimestock(airtimeCurrencyCode, amount)`
44
- - `acGetMsisdnKycInfo(msisdn)`
45
- - `generatePublicKeyAuthenticationSignature(msisdn, amount, narrative)`
43
+ ```ts
44
+ const yoAPI = new YoAPI(username: string, password: string, mode: "production" | "sandbox" = "production");
45
+ ```
46
46
 
47
- ### Receiving payment notifications (IPN)
47
+ | Setter | Type | Default | Purpose |
48
+ | ------------------------------------------- | ------------------- | ------------ | ------------------------------------------------------------------------------ |
49
+ | `setExternalReference` | `string \| null` | `null` | Your reference for the payment (e.g. invoice number); sent with most requests |
50
+ | `setInternalReference` | `string \| null` | `null` | Reference to another Yo! Payments system transaction |
51
+ | `setNonblocking` | `"TRUE" \| "FALSE"` | `"FALSE"` | `"TRUE"` returns immediately; poll status or use IPN URLs |
52
+ | `setInstantNotificationUrl` | `string \| null` | `null` | URL POSTed on successful deposit (non-blocking flow) |
53
+ | `setFailureNotificationUrl` | `string \| null` | `null` | URL POSTed on failed deposit (non-blocking flow) |
54
+ | `setProviderReferenceText` | `string \| null` | `null` | Text appended to the subscriber's confirmation SMS |
55
+ | `setAuthenticationSignatureBase64` | `string \| null` | `null` | Required for certain deposit requests (ask Yo! support) |
56
+ | `setDepositTransactionType` | `"PULL" \| "PUSH"` | `"PULL"` | Which deposit flow `acTransactionCheckStatus` follows up on |
57
+ | `setTransactionLimitAccountIdentifier` | `string \| null` | `null` | Ask your account administrator before using |
58
+ | `setPublicKeyAuthenticationNonce` | `string \| null` | `null` | Unique-per-request nonce for public-key-auth payouts |
59
+ | `setPublicKeyAuthenticationSignatureBase64` | `string \| null` | `null` | Usually set via `generatePublicKeyAuthenticationSignature` |
60
+ | `setPrivateKeyFileLocation` | `string \| null` | `null` | Path to the signing private key (PEM file) |
61
+ | `setPrivateKeyContent` | `string \| null` | `null` | Key PEM text; for serverless hosts without key files (wins over file location) |
62
+ | `setPublicKeyFileUrl` | `string` | bundled cert | Certificate used to verify IPN signatures |
63
+ | `setUrl` | `string` | gateway URL | Override the API endpoint (testing/proxies) |
64
+ | `setTimeout` | `number` (ms) | `120000` | Request timeout; `<= 0` disables it |
65
+ | `setTlsVerificationEnabled` | `boolean` | `true` | Only disable for testing against self-signed endpoints |
66
+ | `setMaxResponseBytes` | `number` | `1048576` | Cap on gateway response bodies |
67
+
68
+ Every setter has a matching getter (`getExternalReference()`, `getMode()`, …). One instance holds per-request state, so create a fresh client per request — never share one across concurrent operations.
69
+
70
+ ## API reference
71
+
72
+ Conventions used below:
73
+
74
+ - **Success** — `Status: "OK"` (and usually `TransactionStatus: "SUCCEEDED"`); reference fields are present.
75
+ - **Business failure** — returned as a normal object, never thrown: `Status: "FAILED"` with `ErrorMessageCode` / `ErrorMessage` set. Check `Status` (and `TransactionStatus`) before trusting reference fields.
76
+ - **Transport failure** — thrown as `YoAPIError`: connection errors, timeouts, non-2xx HTTP, oversized bodies, malformed XML, missing `<Response>`. See [Error handling](#error-handling).
77
+
78
+ Amounts accept `number | string` — pass a string when exact formatting matters (e.g. `"100.50"`), since numbers use JavaScript float-to-string conversion. Phone numbers use international format without `+` (e.g. `"256770000000"`).
79
+
80
+ ### acDepositFunds — request a mobile money deposit (USSD PIN prompt)
81
+
82
+ ```ts
83
+ const res: DepositFundsResponse = await yoAPI.acDepositFunds(
84
+ msisdn,
85
+ amount,
86
+ narrative,
87
+ );
88
+ ```
89
+
90
+ | Parameter | Type | Description |
91
+ | ----------- | ------------------ | --------------------------------------- |
92
+ | `msisdn` | `string` | Subscriber phone, e.g. `"256770000000"` |
93
+ | `amount` | `number \| string` | Amount to collect |
94
+ | `narrative` | `string` | Reason shown to the subscriber |
48
95
 
49
- PHP reads `$_POST` / `php://input` globals, which is impossible in TypeScript, so you pass the parsed form body yourself. Point `setUrl`-style config is not affected; use `setPublicKeyFileUrl` if you need a different certificate (sandbox vs production is picked automatically by the constructor `mode`).
96
+ Response (`DepositFundsResponse`): `Status`, `StatusCode`, `StatusMessage`, `TransactionStatus` always present. On success also `TransactionReference` (save this it identifies the payment everywhere else), `MNOTransactionReferenceId`, `IssuedReceiptNumber`. On business failure, `ErrorMessageCode` / `ErrorMessage` instead. Optional request tweaks: `setNonblocking("TRUE")` + IPN URLs, `setAuthenticationSignatureBase64`.
97
+
98
+ ### acTransactionCheckStatus — poll a transaction
50
99
 
51
100
  ```ts
52
- // Bun HTTP server example
53
- Bun.serve({
54
- port: 3000,
55
- async fetch(req) {
56
- const form = await req.formData();
57
- const body = Object.fromEntries(form.entries()) as any;
58
-
59
- const yoAPI = new YoAPI("API_USERNAME", "API_PASSWORD", "sandbox");
60
- const payment = yoAPI.receivePaymentNotification(body);
61
- if (payment.is_verified) {
62
- console.log(`Payment from ${payment.msisdn} of ${payment.amount} (ref ${payment.external_ref})`);
63
- // update your transaction status where external_ref = payment.external_ref
64
- }
65
-
66
- // Failure notifications:
67
- // const failure = yoAPI.receivePaymentFailureNotification(body);
68
- return new Response("OK");
69
- },
101
+ const res: TransactionCheckStatusResponse = await yoAPI.acTransactionCheckStatus(
102
+ transactionReference: string | null,
103
+ privateTransactionReference: string | null = null,
104
+ );
105
+ ```
106
+
107
+ Pass the gateway `TransactionReference`, or `null` plus the `ExternalReference` you sent (`privateTransactionReference`). `setDepositTransactionType("PUSH")` first when following up a push deposit. Same base fields as deposits, plus (when available): `Amount`, `AmountFormatted`, `CurrencyCode`, `TransactionInitiationDate`, `TransactionCompletionDate`. `TransactionStatus` is one of `SUCCEEDED`, `PENDING`, `FAILED`, `INDETERMINATE` — poll until it leaves `PENDING`.
108
+
109
+ ### acInternalTransfer pay another Yo! Payments account
110
+
111
+ ```ts
112
+ const res: DepositFundsResponse = await yoAPI.acInternalTransfer(
113
+ currencyCode: string, // e.g. "UGX-MTNMM", "UGX-MTNAT", "UGX-WTLAT", "UGX-OULAT", "UGX-AIRAT"
114
+ amount: number | string,
115
+ beneficiaryAccount: number | string, // recipient Yo! account number
116
+ beneficiaryEmail: string,
117
+ narrative: string,
118
+ );
119
+ ```
120
+
121
+ Same response shape as deposits (success/failure fields as above).
122
+
123
+ ### acAcctBalance — account balances
124
+
125
+ ```ts
126
+ const res: AcctBalanceResponse = await yoAPI.acAcctBalance();
127
+ // res.balance → [{ code: "UGX", balance: "50000" }, { code: "UGX-MTNAT", balance: "1500" }, ...]
128
+ ```
129
+
130
+ `Status` / `StatusCode` always present, `balance` always an array (possibly empty), plus optional `StatusMessage` / error fields.
131
+
132
+ ### acGetMinistatement — transaction history
133
+
134
+ ```ts
135
+ const res: MinistatementResponse = await yoAPI.acGetMinistatement(
136
+ startDate: string | null = null, // "YYYY-MM-DD HH:MM:SS"
137
+ endDate: string | null = null, // "YYYY-MM-DD HH:MM:SS"
138
+ transactionStatus: string | null = null, // "SUCCEEDED", "FAILED", "PENDING", "INDETERMINATE", or comma-joined
139
+ currencyCode: string | null = null, // e.g. "UGX-MTNMM", "UGX-WARIDMM"
140
+ resultSetLimit: number | null = null, // 0 returns all; gateway default is 15
141
+ transactionEntryDesignation = "ANY", // "TRANSACTION" | "CHARGES" | "ANY"
142
+ externalReference: string | null = null,
143
+ );
144
+ ```
145
+
146
+ `Status`, `StatusCode`, `TotalTransactions`, `ReturnedTransactions` and `Transactions` always present. Each `TransactionDetail` carries `TransactionSystemId`, `TransactionReference`, `TransactionStatus`, `InitiationDate`, `CompletionDate`, `NarrativeBase64`, `Currency`, `Amount`, `Balance`, `GeneralType`, `DetailedType`, `BeneficiaryBase64`, `SenderBase64`, `TransactionEntryDesignation`, plus optional `BeneficiaryMsisdn`, `SenderMsisdn`, `Base64TransactionExternalReference` (present only when the gateway sends them).
147
+
148
+ ### acSendAirtimeMobile / acSendAirtimeInternal — send airtime
149
+
150
+ ```ts
151
+ // to a phone number
152
+ await yoAPI.acSendAirtimeMobile(msisdn, amount, narrative);
153
+ // to another Yo! account ("UGX-MTNAT" | "UGX-WTLAT" | "UGX-OULAT" | "UGX-AIRAT")
154
+ await yoAPI.acSendAirtimeInternal(
155
+ currencyCode,
156
+ amount,
157
+ beneficiaryAccount,
158
+ beneficiaryEmail,
159
+ narrative,
160
+ );
161
+ ```
162
+
163
+ Same response shape as deposits.
164
+
165
+ ### acWithdrawFunds — pay out to mobile money (handle with care)
166
+
167
+ ```ts
168
+ const res: DepositFundsResponse = await yoAPI.acWithdrawFunds(
169
+ msisdn,
170
+ amount,
171
+ narrative,
172
+ );
173
+ ```
174
+
175
+ Same response shape as deposits. Requires an API Access Letter; some payouts additionally require public-key authentication — see below. Optional: `setTransactionLimitAccountIdentifier`, `setPublicKeyAuthenticationNonce` + `setPublicKeyAuthenticationSignatureBase64`.
176
+
177
+ ### acUserPurchaseAirtimestock — buy airtime stock with mobile money credit
178
+
179
+ ```ts
180
+ const res: PurchaseAirtimeStockResponse = await yoAPI.acUserPurchaseAirtimestock(
181
+ airtimeCurrencyCode: string, // "UGX-MTNAT" | "UGX-AIRAT" | "UGX-OULAT" | "UGX-UTLAT" | "UGX-SMTAT"
182
+ amount: number | string,
183
+ );
184
+ ```
185
+
186
+ `Status` / `StatusCode` always present; on success `TransactionReference`, `TotalCurrencyDebited`, `CommissionAmount`, `StatusMessage`. (Parity note: your external reference is sent inside a `<TransactionReference>` tag, exactly like the PHP library.)
187
+
188
+ ### acGetMsisdnKycInfo — name lookup before paying out
189
+
190
+ ```ts
191
+ const res: MsisdnKycInfoResponse =
192
+ await yoAPI.acGetMsisdnKycInfo("256770000000");
193
+ // res.FirstName / res.MiddleName / res.Surname when the gateway returns them
194
+ ```
195
+
196
+ MTN Uganda and Airtel Uganda only; needs permission from support@yo.co.ug. `Status` / `StatusCode` always present.
197
+
198
+ ### receivePaymentNotification / receivePaymentFailureNotification — verify IPNs
199
+
200
+ ```ts
201
+ const payment: PaymentNotificationResult = yoAPI.receivePaymentNotification({
202
+ date_time,
203
+ amount,
204
+ narrative,
205
+ network_ref,
206
+ external_ref,
207
+ msisdn,
208
+ signature,
70
209
  });
210
+ // payment.is_verified === true → trust payment.msisdn / .amount / .external_ref / ...
211
+ const failure: PaymentFailureNotificationResult =
212
+ yoAPI.receivePaymentFailureNotification({
213
+ failed_transaction_reference,
214
+ transaction_init_date,
215
+ verification,
216
+ });
71
217
  ```
72
218
 
73
- ### Public key authentication (payouts)
219
+ Pass the parsed POST form body (PHP reads `$_POST`; here you supply it). Verification is RSA-SHA256 against the bundled Yo! certificate and is fail-closed: any problem (bad signature, missing cert) yields `is_verified: false`, never a throw. Always gate crediting on `is_verified` **and** dedupe on `external_ref` — notifications carry no replay protection.
220
+
221
+ ### generatePublicKeyAuthenticationSignature — sign a payout
74
222
 
75
223
  ```ts
76
- const yoAPI = new YoAPI("API_USERNAME", "API_PASSWORD");
77
224
  yoAPI.setExternalReference("INV-123");
78
- yoAPI.setPublicKeyAuthenticationNonce(crypto.randomUUID());
79
- yoAPI.setPrivateKeyFileLocation("/path/to/your-private-key.pem");
80
- yoAPI.generatePublicKeyAuthenticationSignature("256770000000", 5000, "Salary payout");
81
- const res = await yoAPI.acWithdrawFunds("256770000000", 5000, "Salary payout");
225
+ yoAPI.setPublicKeyAuthenticationNonce(crypto.randomUUID()); // unique per request
226
+ yoAPI.setPrivateKeyContent(process.env.YO_PRIVATE_KEY!.replace(/\\n/g, "\n")); // or setPrivateKeyFileLocation(path)
227
+ yoAPI.generatePublicKeyAuthenticationSignature(msisdn, amount, narrative); // throws on missing/invalid key
228
+ const res = await yoAPI.acWithdrawFunds(msisdn, amount, narrative);
229
+ ```
230
+
231
+ Signs `username + amount + msisdn + narrative + externalReference + nonce` (SHA1+RSA per the gateway protocol) and stores it for the next payout call. Throws `"Public key authentication nonce is not set…"`, `"Private key file location cannot be NULL"`, `"Private key file could not be opened…"`, or `"Private key is invalid"`.
232
+
233
+ ### Response examples
234
+
235
+ Concrete objects each call resolves to. Absent optional fields are omitted (never `null`).
236
+
237
+ **Deposits, transfers, airtime, withdrawals** (`DepositFundsResponse` family) — success:
238
+
239
+ ```ts
240
+ {
241
+ Status: "OK",
242
+ StatusCode: "200",
243
+ StatusMessage: "OK",
244
+ TransactionStatus: "SUCCEEDED",
245
+ TransactionReference: "TRX-EX-1",
246
+ MNOTransactionReferenceId: "MNO-9",
247
+ IssuedReceiptNumber: "R-77",
248
+ }
249
+ ```
250
+
251
+ Same calls — business failure (returned, not thrown):
252
+
253
+ ```ts
254
+ {
255
+ Status: "FAILED",
256
+ StatusCode: "500",
257
+ StatusMessage: "Failed",
258
+ TransactionStatus: "FAILED",
259
+ ErrorMessageCode: "INVALID_MSISDN",
260
+ ErrorMessage: "The MSISDN is invalid",
261
+ }
262
+ ```
263
+
264
+ **Transaction status** (`TransactionCheckStatusResponse`) — success carries the money fields:
265
+
266
+ ```ts
267
+ {
268
+ Status: "OK",
269
+ StatusCode: "200",
270
+ StatusMessage: "OK",
271
+ TransactionStatus: "SUCCEEDED",
272
+ TransactionReference: "TRX-EX-1",
273
+ Amount: "10000",
274
+ AmountFormatted: "UGX 10,000",
275
+ CurrencyCode: "UGX",
276
+ TransactionInitiationDate: "2026-09-07T10:00:00",
277
+ TransactionCompletionDate: "2026-09-07T10:01:00",
278
+ IssuedReceiptNumber: "R-77",
279
+ }
280
+ ```
281
+
282
+ Still pending — keep polling:
283
+
284
+ ```ts
285
+ {
286
+ Status: "OK",
287
+ StatusCode: "200",
288
+ StatusMessage: "OK",
289
+ TransactionStatus: "PENDING",
290
+ }
291
+ ```
292
+
293
+ **Balance** (`AcctBalanceResponse`):
294
+
295
+ ```ts
296
+ {
297
+ Status: "OK",
298
+ StatusCode: "200",
299
+ balance: [
300
+ { code: "UGX", balance: "50000" },
301
+ { code: "UGX-MTNAT", balance: "1500" },
302
+ ],
303
+ }
304
+ ```
305
+
306
+ **Ministatement** (`MinistatementResponse`) — `Transactions` is always an array:
307
+
308
+ ```ts
309
+ {
310
+ Status: "OK",
311
+ StatusCode: "200",
312
+ TotalTransactions: "2",
313
+ ReturnedTransactions: "2",
314
+ Transactions: [
315
+ {
316
+ TransactionSystemId: "SYS-1",
317
+ TransactionReference: "TRX-EX-1",
318
+ TransactionStatus: "SUCCEEDED",
319
+ InitiationDate: "2026-09-07 10:00:00",
320
+ CompletionDate: "2026-09-07 10:01:00",
321
+ NarrativeBase64: "SGVsbG8=",
322
+ Currency: "UGX",
323
+ Amount: "100",
324
+ Balance: "900",
325
+ GeneralType: "DEPOSIT",
326
+ DetailedType: "MOBILE_MONEY_DEPOSIT",
327
+ BeneficiaryMsisdn: "256770000000",
328
+ BeneficiaryBase64: "QmVuZQ==",
329
+ SenderMsisdn: "256780000000",
330
+ SenderBase64: "U2VuZGVy",
331
+ Base64TransactionExternalReference: "RVhULTE=",
332
+ TransactionEntryDesignation: "TRANSACTION",
333
+ },
334
+ ],
335
+ }
336
+ ```
337
+
338
+ **Airtimestock purchase** (`PurchaseAirtimeStockResponse`):
339
+
340
+ ```ts
341
+ {
342
+ Status: "OK",
343
+ StatusCode: "200",
344
+ StatusMessage: "Purchased",
345
+ TransactionReference: "TRX-EX-1",
346
+ TotalCurrencyDebited: "1000",
347
+ CommissionAmount: "50",
348
+ }
349
+ ```
350
+
351
+ **KYC lookup** (`MsisdnKycInfoResponse`):
352
+
353
+ ```ts
354
+ {
355
+ Status: "OK",
356
+ StatusCode: "200",
357
+ StatusMessage: "Found",
358
+ FirstName: "John",
359
+ MiddleName: "Middle",
360
+ Surname: "Doe",
361
+ }
362
+ ```
363
+
364
+ **Verified payment notification** (`PaymentNotificationResult`):
365
+
366
+ ```ts
367
+ {
368
+ is_verified: true,
369
+ date_time: "2026-09-07 10:00:00",
370
+ amount: "1000",
371
+ narrative: "Payment",
372
+ network_ref: "NET-1",
373
+ external_ref: "EXT-1",
374
+ msisdn: "256770000000",
375
+ }
376
+ ```
377
+
378
+ Unverifiable notification (bad signature or cert problem) — credit nothing:
379
+
380
+ ```ts
381
+ {
382
+ is_verified: false,
383
+ date_time: "2026-09-07 10:00:00",
384
+ amount: "9999",
385
+ narrative: "Payment",
386
+ network_ref: "NET-1",
387
+ external_ref: "EXT-1",
388
+ msisdn: "256770000000",
389
+ }
390
+ ```
391
+
392
+ **Transport failure** — thrown as `YoAPIError`, e.g. gateway HTTP 502:
393
+
394
+ ```ts
395
+ // caught error instance:
396
+ YoAPIError: Yo! Payments gateway responded with HTTP 502
397
+ // e.status === 502
398
+ // e.body === "<html><body>Bad Gateway</body></html>"
399
+ // e.cause === undefined (set only for connection/timeout errors)
400
+ ```
401
+
402
+ ## Usage cases
403
+
404
+ **1. Blocking deposit** — simplest collection flow; the call returns after the subscriber approves:
405
+
406
+ ```ts
407
+ const api = new YoAPI(u, p, "sandbox");
408
+ api.setExternalReference(`INV-${Date.now()}`);
409
+ const res = await api.acDepositFunds("256770000000", 10000, "Order payment");
410
+ if (res.Status === "OK" && res.TransactionStatus === "SUCCEEDED") {
411
+ await markPaid(res.TransactionReference!);
412
+ } else {
413
+ console.error(res.ErrorMessageCode, res.ErrorMessage);
414
+ }
415
+ ```
416
+
417
+ **2. Non-blocking deposit with IPN + polling fallback** — instant response, then confirm:
418
+
419
+ ```ts
420
+ api.setNonblocking("TRUE");
421
+ api.setInstantNotificationUrl("https://example.com/api/yo/ipn");
422
+ api.setFailureNotificationUrl("https://example.com/api/yo/failure");
423
+ const res = await api.acDepositFunds("256770000000", 10000, "Order payment");
424
+ // ...meanwhile your IPN endpoint verifies and credits on payment.external_ref...
425
+ // ...and/or poll until settled:
426
+ for (;;) {
427
+ const st = await api.acTransactionCheckStatus(null, externalRef);
428
+ if (st.TransactionStatus !== "PENDING") break;
429
+ await new Promise((r) => setTimeout(r, 5000));
430
+ }
431
+ ```
432
+
433
+ **3. Daily reconciliation from the ministatement:**
434
+
435
+ ```ts
436
+ const st = await api.acGetMinistatement(
437
+ "2026-09-10 00:00:00",
438
+ "2026-09-10 23:59:59",
439
+ "SUCCEEDED",
440
+ "UGX-MTNMM",
441
+ 0,
442
+ );
443
+ for (const tx of st.Transactions) await reconcile(tx);
444
+ ```
445
+
446
+ **4. Serverless payout with key material (no key files on Vercel/Lambda):**
447
+
448
+ ```ts
449
+ api.setExternalReference("SAL-SEP-001");
450
+ api.setPublicKeyAuthenticationNonce(crypto.randomUUID());
451
+ api.setPrivateKeyContent(process.env.YO_PRIVATE_KEY!.replace(/\\n/g, "\n"));
452
+ api.generatePublicKeyAuthenticationSignature("256770000000", 5000, "Payout");
453
+ const res = await api.acWithdrawFunds("256770000000", 5000, "Payout");
454
+ ```
455
+
456
+ ### Receiving payment notifications (IPN)
457
+
458
+ PHP reads `$_POST` / `php://input` globals, which is impossible in TypeScript, so you pass the parsed form body yourself. Use `setPublicKeyFileUrl` if you need a different certificate (sandbox vs production is picked automatically by the constructor `mode`).
459
+
460
+ ```ts
461
+ // Bun HTTP server example
462
+ Bun.serve({
463
+ port: 3000,
464
+ async fetch(req) {
465
+ const form = await req.formData();
466
+ const body = Object.fromEntries(form.entries()) as any;
467
+
468
+ const yoAPI = new YoAPI("API_USERNAME", "API_PASSWORD", "sandbox");
469
+ const payment = yoAPI.receivePaymentNotification(body);
470
+ if (payment.is_verified) {
471
+ console.log(
472
+ `Payment from ${payment.msisdn} of ${payment.amount} (ref ${payment.external_ref})`,
473
+ );
474
+ // update your transaction status where external_ref = payment.external_ref
475
+ }
476
+
477
+ // Failure notifications:
478
+ // const failure = yoAPI.receivePaymentFailureNotification(body);
479
+ return new Response("OK");
480
+ },
481
+ });
82
482
  ```
83
483
 
84
484
  ### Usage in Next.js (App Router)
@@ -95,7 +495,11 @@ import "server-only";
95
495
  import { YoAPI } from "@herberthtk/yo-payments-api";
96
496
 
97
497
  export function getYoClient() {
98
- return new YoAPI(process.env.YO_API_USERNAME!, process.env.YO_API_PASSWORD!, "sandbox");
498
+ return new YoAPI(
499
+ process.env.YO_API_USERNAME!,
500
+ process.env.YO_API_PASSWORD!,
501
+ "sandbox",
502
+ );
99
503
  }
100
504
  ```
101
505
 
@@ -104,27 +508,18 @@ export function getYoClient() {
104
508
  import "server-only";
105
509
  import { getYoClient } from "@/lib/yo";
106
510
 
107
- export const runtime = "nodejs";
108
511
  export const dynamic = "force-dynamic";
512
+ export const runtime = "nodejs";
109
513
 
110
514
  export async function POST(req: Request) {
111
- const form = await req.formData();
112
- const body: Record<string, string> = {};
113
- for (const [k, v] of form.entries()) if (typeof v === "string") body[k] = v;
114
-
115
- const payment = getYoClient().receivePaymentNotification({
116
- date_time: body.date_time ?? "",
117
- amount: body.amount ?? "",
118
- narrative: body.narrative ?? "",
119
- network_ref: body.network_ref ?? "",
120
- external_ref: body.external_ref ?? "",
121
- msisdn: body.msisdn ?? "",
122
- signature: body.signature ?? "",
123
- });
124
- if (!payment.is_verified) return new Response("NOT VERIFIED", { status: 400 });
125
-
126
- // TODO: persist + mark processed idempotently on payment.external_ref
127
- return new Response("OK");
515
+ const form = await req.formData();
516
+ const body = Object.fromEntries(form.entries()) as any;
517
+ const payment = getYoClient().receivePaymentNotification(body);
518
+ if (!payment.is_verified)
519
+ return new Response("NOT VERIFIED", { status: 400 });
520
+
521
+ // TODO: persist + mark processed idempotently on payment.external_ref
522
+ return new Response("OK");
128
523
  }
129
524
  ```
130
525
 
@@ -133,12 +528,17 @@ export async function POST(req: Request) {
133
528
  "use server";
134
529
  import { getYoClient } from "@/lib/yo";
135
530
 
136
- export async function requestDeposit(msisdn: string, amount: number, narrative: string) {
137
- const api = getYoClient();
138
- api.setExternalReference(`${Date.now()}`);
139
- const res = await api.acDepositFunds(msisdn, amount, narrative);
140
- if (res.Status === "OK") return { ok: true, reference: res.TransactionReference };
141
- return { ok: false, message: res.StatusMessage };
531
+ export async function requestDeposit(
532
+ msisdn: string,
533
+ amount: number,
534
+ narrative: string,
535
+ ) {
536
+ const api = getYoClient();
537
+ api.setExternalReference(`${Date.now()}`);
538
+ const res = await api.acDepositFunds(msisdn, amount, narrative);
539
+ if (res.Status === "OK")
540
+ return { ok: true, reference: res.TransactionReference };
541
+ return { ok: false, message: res.StatusMessage };
142
542
  }
143
543
  ```
144
544
 
@@ -147,8 +547,14 @@ export async function requestDeposit(msisdn: string, amount: number, narrative:
147
547
  import { getYoClient } from "@/lib/yo";
148
548
 
149
549
  export default async function StatementPage() {
150
- const res = await getYoClient().acGetMinistatement(null, null, "SUCCEEDED", "UGX-MTNMM", 0);
151
- return <pre>{JSON.stringify(res.Transactions, null, 2)}</pre>;
550
+ const res = await getYoClient().acGetMinistatement(
551
+ null,
552
+ null,
553
+ "SUCCEEDED",
554
+ "UGX-MTNMM",
555
+ 0,
556
+ );
557
+ return <pre>{JSON.stringify(res.Transactions, null, 2)}</pre>;
152
558
  }
153
559
  ```
154
560
 
@@ -162,24 +568,26 @@ Transport-level and protocol-level failures throw `YoAPIError` (an `Error` subcl
162
568
  import { YoAPI, YoAPIError } from "@herberthtk/yo-payments-api";
163
569
 
164
570
  try {
165
- await yoAPI.acAcctBalance();
571
+ await yoAPI.acAcctBalance();
166
572
  } catch (e) {
167
- if (e instanceof YoAPIError) {
168
- console.error(e.message, "status:", e.status, "cause:", e.cause);
169
- }
573
+ if (e instanceof YoAPIError) {
574
+ console.error(e.message, "status:", e.status, "cause:", e.cause);
575
+ }
170
576
  }
171
577
  ```
172
578
 
173
- `YoAPIError` is thrown for connection errors, timeouts, non-2xx HTTP statuses, oversized bodies, malformed XML and responses missing the `<Response>` node. Gateway-level business failures (e.g. `Status: "FAILED"`) are still returned as normal response objects, exactly like the PHP library.
579
+ `YoAPIError` fields: `message` (what failed), `status?: number` (HTTP status when a response was received), `body?: string` (first 500 chars of the response, when any), `cause?: unknown` (the underlying fetch error). Thrown for connection errors, timeouts, non-2xx HTTP statuses, oversized bodies, malformed XML and responses missing the `<Response>` node. Gateway-level business failures (e.g. `Status: "FAILED"`) are still returned as normal response objects, exactly like the PHP library.
174
580
 
175
581
  ### Examples
176
582
 
177
- The `examples/` directory ports all six PHP examples; each exports testable functions and is runnable with `bun run`:
583
+ The `examples/` directory ports the PHP examples (plus balance and KYC extras); each exports testable functions and is runnable with `bun run`:
178
584
 
179
585
  ```bash
180
586
  YO_API_USERNAME=... YO_API_PASSWORD=... YO_API_MODE=sandbox bun run examples/deposit_funds.ts
181
587
  YO_API_USERNAME=... YO_API_PASSWORD=... YO_API_MODE=sandbox bun run examples/deposit_funds_nonblocking.ts
182
588
  YO_API_USERNAME=... YO_API_PASSWORD=... YO_API_MODE=sandbox bun run examples/get_ministatement.ts
589
+ YO_API_USERNAME=... YO_API_PASSWORD=... YO_API_MODE=sandbox bun run examples/get_account_balance.ts
590
+ YO_API_USERNAME=... YO_API_PASSWORD=... YO_API_MODE=sandbox bun run examples/get_user_info.ts
183
591
  YO_API_USERNAME=... YO_API_PASSWORD=... YO_API_MODE=sandbox \
184
592
  YO_PRIVATE_KEY_FILE=/path/to/private-key.pem \
185
593
  bun run examples/withdraw_funds_public_key_authentication.ts
@@ -215,7 +623,7 @@ The suite (`tests/YoAPI.test.ts`, `tests/examples.test.ts`, `tests/keys.test.ts`
215
623
 
216
624
  ### Releasing (maintainers)
217
625
 
218
- Versions follow [Conventional Commits](https://www.conventionalcommits.org/) (`feat:` → minor, `fix:` → patch, `feat!:`/`BREAKING CHANGE:` → major). To cut a release, run **Actions → Release → Run workflow** — release-it bumps the version, updates `CHANGELOG.md`, tags, creates the GitHub release and publishes to npm via trusted publishing (no npm token needed). First-time setup only: `npm login` + one manual `npm publish --access public`, then register the repo as a trusted publisher in the npm package settings.
626
+ Versions follow [Conventional Commits](https://www.conventionalcommits.org/) (`feat:` → minor, `fix:` → patch, `feat!:`/`BREAKING CHANGE:` → major). Before the first release, configure npm trusted publishing for `@herberthtk/yo-payments-api` in npm package settings: select GitHub Actions, set the repository to `herberthk/yo-payments-api`, and set the workflow filename to `release.yml` (not its full path). See npm's [trusted publishers guide](https://docs.npmjs.com/trusted-publishers/) for the full setup. Then run **Actions → Release → Run workflow** — release-it bumps the version, updates `CHANGELOG.md`, tags, creates the GitHub release and publishes to npm via trusted publishing (no npm token needed).
219
627
 
220
628
  ## Project structure
221
629
 
@@ -227,7 +635,7 @@ Versions follow [Conventional Commits](https://www.conventionalcommits.org/) (`f
227
635
  - `src/keys.ts` — cached verification-key loading (file-first, embedded fallback)
228
636
  - `src/constants.ts` — gateway URLs, certificate names, defaults
229
637
  - `src/embeddedCerts.ts` — auto-generated from `certs/` (`bun run embed-certs`)
230
- - `examples/` — runnable ports of the six PHP examples
638
+ - `examples/` — runnable ports of the PHP examples (plus balance and KYC extras)
231
639
  - `examples/nextjs/` — Next.js App Router handlers, Server Actions and queries
232
640
  - `certs/` — Yo! Uganda public certificates for IPN verification
233
641
  - `.github/workflows/` — `ci.yml` (test/typecheck/build/pack-check) and `release.yml` (release-it via trusted publishing)