sebpay-node 0.0.0-stage → 0.2.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,3 +1,125 @@
1
- # Temporary Holding Version
1
+ # sebpay-node
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Official Node.js SDK for [Sebpay](https://sebpay.africa) **hosted checkout**. It does four things:
4
+ - create a checkout session;
5
+ - redirect your customer to the Sebpay payment page;
6
+ - confirm the payment when they come back;
7
+ - verify Sebpay's webhooks.
8
+
9
+ **Server-side only.** This SDK runs in Node.js (≥ 18). Never bundle it into a browser build: your secret key must never reach the client.
10
+
11
+ It uses Node's native `fetch` and has no runtime dependency. Direct collections, payouts and reference data are not part of the SDK. They remain available over plain HTTP: see [`docs/SEBPAY_PUBLIC_API.md`](../../docs/SEBPAY_PUBLIC_API.md).
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install sebpay-node
17
+ ```
18
+
19
+ Requires Node.js 18 or later. The package ships its TypeScript types.
20
+
21
+ ## Usage
22
+
23
+ ### 1. Create a session and redirect your customer
24
+
25
+ ```ts
26
+ import { SebpayClient } from "sebpay-node";
27
+
28
+ // Keys from your dashboard (API développeur): pk_test_ keys go to the sandbox server, pk_live_ keys to live.
29
+ const sebpay = new SebpayClient(process.env.SEBPAY_PUBLIC_KEY!, process.env.SEBPAY_SECRET_KEY!);
30
+
31
+ const session = await sebpay.checkoutSessions.create({
32
+ amount: 5000,
33
+ currency: "XOF",
34
+ redirect_url: "https://yoursite.com/orders/return",
35
+ external_reference: order.id, // your order id: sending it again returns the same session
36
+ callback_url: "https://yoursite.com/webhook/sebpay",
37
+ metadata: { order_id: order.id },
38
+ });
39
+
40
+ res.redirect(session.checkout_url);
41
+ ```
42
+
43
+ ### 2. Confirm the payment when your customer comes back
44
+
45
+ Your `redirect_url` receives `?checkout_session_id=<id>`. Coming back proves nothing, so ask Sebpay before you deliver:
46
+
47
+ ```ts
48
+ const session = await sebpay.checkoutSessions.retrieve(req.query.checkout_session_id);
49
+
50
+ if (session.transaction_status === "approved") {
51
+ // Paid: deliver the order (session.external_reference, session.metadata).
52
+ }
53
+ ```
54
+
55
+ `retrieve()` also accepts your `external_reference`.
56
+
57
+ ### 3. Verify the webhook
58
+
59
+ Sebpay POSTs the final status (`approved` or `rejected`) to your `callback_url`. For a checkout, the request also carries `checkout_session_id` and your `metadata`.
60
+
61
+ ```ts
62
+ import express from "express";
63
+ import { SIGNATURE_HEADER, verifySignature, type WebhookPayload } from "sebpay-node";
64
+
65
+ app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
66
+
67
+ app.post("/webhook/sebpay", (req, res) => {
68
+ if (!verifySignature(req.rawBody, req.get(SIGNATURE_HEADER), process.env.SEBPAY_SECRET_KEY!)) {
69
+ return res.status(401).json({ error: "Invalid signature" });
70
+ }
71
+
72
+ const payload = req.body as WebhookPayload;
73
+ // payload.status: "approved" | "rejected"
74
+ res.status(200).json({ received: true });
75
+ });
76
+ ```
77
+
78
+ - Always pass the **raw** request body. Re-serializing a parsed payload can reorder keys and break the signature.
79
+ - A missing header returns `false`; it never throws.
80
+
81
+ ## Options
82
+
83
+ ```ts
84
+ new SebpayClient(publicKey, secretKey, {
85
+ baseUrl: "http://localhost:8000/api/v1", // only to test against a local API; otherwise chosen from the keys
86
+ timeout: 10_000, // ms, default 30 000
87
+ });
88
+ ```
89
+
90
+ `sebpay.baseUrl` tells which server the client talks to.
91
+
92
+ ## Errors
93
+
94
+ Every failed call throws a `SebpayApiError`, with three fields:
95
+ - `status`: the HTTP status, or 0 when no response came back;
96
+ - `code`: the API's `errors.code`, or one of the SDK's own codes. Examples: `EXTERNAL_REFERENCE_CONFLICT`, `RATE_LIMITED`, `WRONG_ENVIRONMENT`, `NETWORK_ERROR`, `TIMEOUT`, `INVALID_RESPONSE`. It is `null` otherwise;
97
+ - `body`: the decoded response.
98
+
99
+ ```ts
100
+ import { SebpayApiError } from "sebpay-node";
101
+
102
+ try {
103
+ await sebpay.checkoutSessions.create({ ... });
104
+ } catch (err) {
105
+ if (err instanceof SebpayApiError && err.code === "EXTERNAL_REFERENCE_CONFLICT") {
106
+ // This order id was already used with other parameters.
107
+ }
108
+ }
109
+ ```
110
+
111
+ ## Examples
112
+
113
+ See [`examples/`](examples/). Build first (`npm run build`), set `SEBPAY_PUBLIC_KEY` and `SEBPAY_SECRET_KEY`, then:
114
+ - `node examples/create-checkout.mjs` creates a session and prints its `checkout_url`;
115
+ - `node examples/webhook-server.mjs` starts an Express server (`npm install express`) that handles the webhook and the return page, on `PORT` (default 3001).
116
+
117
+ For local testing, also set `SEBPAY_BASE_URL`.
118
+
119
+ ## Tests
120
+
121
+ `npm test` builds the SDK and runs its offline tests with `node --test`. They cover the transport, against a local HTTP server, and the shared signature vectors.
122
+
123
+ ## Requirements
124
+
125
+ Node.js 18+ (for native `fetch` and `node:crypto`).
@@ -0,0 +1,20 @@
1
+ import { CheckoutSessions } from "./resources/checkoutSessions";
2
+ export interface SebpayClientOptions {
3
+ /** API URL. By default it follows the keys: pk_test_ → sandbox server, otherwise live. */
4
+ baseUrl?: string;
5
+ /** Request timeout, in milliseconds (default 30 000). */
6
+ timeout?: number;
7
+ }
8
+ /** How a resource reaches the API — kept internal, so the SDK only calls the endpoints it wraps. */
9
+ export type Requester = <T>(method: "GET" | "POST", path: string, body?: Record<string, unknown>) => Promise<T>;
10
+ /**
11
+ * Entry point for Sebpay hosted checkout. Server-side only — the secret key must never reach a
12
+ * browser bundle. Pass the key pair from your dashboard (API développeur): the sandbox and live
13
+ * are separate servers, and pk_test_ keys are sent to the sandbox, pk_live_ keys to live.
14
+ */
15
+ export declare class SebpayClient {
16
+ #private;
17
+ readonly baseUrl: string;
18
+ readonly checkoutSessions: CheckoutSessions;
19
+ constructor(publicKey: string, secretKey: string, options?: SebpayClientOptions);
20
+ }
package/dist/client.js ADDED
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SebpayClient = void 0;
4
+ const errors_1 = require("./errors");
5
+ const checkoutSessions_1 = require("./resources/checkoutSessions");
6
+ const version_1 = require("./version");
7
+ const LIVE_BASE_URL = "https://newapi.sebpay.africa/api/v1";
8
+ const SANDBOX_BASE_URL = "https://api.sandbox.sebpay.bj/api/v1";
9
+ // Same 30 s default as the PHP and Python SDKs — fetch has no timeout of its own.
10
+ const DEFAULT_TIMEOUT_MS = 30_000;
11
+ /**
12
+ * Entry point for Sebpay hosted checkout. Server-side only — the secret key must never reach a
13
+ * browser bundle. Pass the key pair from your dashboard (API développeur): the sandbox and live
14
+ * are separate servers, and pk_test_ keys are sent to the sandbox, pk_live_ keys to live.
15
+ */
16
+ class SebpayClient {
17
+ baseUrl;
18
+ checkoutSessions;
19
+ #publicKey;
20
+ #secretKey;
21
+ #timeout;
22
+ constructor(publicKey, secretKey, options = {}) {
23
+ this.#publicKey = publicKey;
24
+ this.#secretKey = secretKey;
25
+ this.#timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
26
+ this.baseUrl = (options.baseUrl ?? (publicKey.startsWith("pk_test_") ? SANDBOX_BASE_URL : LIVE_BASE_URL)).replace(/\/+$/, "");
27
+ this.checkoutSessions = new checkoutSessions_1.CheckoutSessions((method, path, body) => this.#request(method, path, body));
28
+ }
29
+ /** Returns the `data` field of a successful response; throws SebpayApiError otherwise. */
30
+ async #request(method, path, body) {
31
+ const headers = {
32
+ "X-Public-Key": this.#publicKey,
33
+ "X-Secret-Key": this.#secretKey,
34
+ Accept: "application/json",
35
+ "User-Agent": `sebpay-node/${version_1.VERSION}`,
36
+ };
37
+ if (body !== undefined)
38
+ headers["Content-Type"] = "application/json";
39
+ let res;
40
+ let text;
41
+ try {
42
+ res = await fetch(`${this.baseUrl}${path}`, {
43
+ method,
44
+ headers,
45
+ body: body === undefined ? undefined : JSON.stringify(body),
46
+ signal: AbortSignal.timeout(this.#timeout),
47
+ });
48
+ text = await res.text();
49
+ }
50
+ catch (err) {
51
+ if (err instanceof Error && err.name === "TimeoutError") {
52
+ throw new errors_1.SebpayApiError(`Sebpay request timed out after ${this.#timeout} ms`, 0, "TIMEOUT", null);
53
+ }
54
+ const cause = err instanceof Error ? (err.cause?.message ?? err.message) : String(err);
55
+ throw new errors_1.SebpayApiError(`Sebpay request failed: ${cause}`, 0, "NETWORK_ERROR", null);
56
+ }
57
+ let decoded = null;
58
+ try {
59
+ decoded = JSON.parse(text);
60
+ }
61
+ catch {
62
+ // not JSON: handled below
63
+ }
64
+ if (!res.ok || decoded?.success === false) {
65
+ const code = typeof decoded?.errors?.code === "string" ? decoded.errors.code : null;
66
+ throw new errors_1.SebpayApiError(decoded?.message ?? `Sebpay API error (HTTP ${res.status})`, res.status, code, decoded ?? text);
67
+ }
68
+ if (decoded === null || typeof decoded !== "object" || !("data" in decoded)) {
69
+ throw new errors_1.SebpayApiError("Sebpay API returned an unexpected response", res.status, "INVALID_RESPONSE", decoded ?? text);
70
+ }
71
+ return decoded.data;
72
+ }
73
+ }
74
+ exports.SebpayClient = SebpayClient;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Thrown for every failed call. `status` is the HTTP status, or 0 when no response came back
3
+ * (`code` "NETWORK_ERROR" or "TIMEOUT"). `code` is the API's `errors.code` when it sends one
4
+ * (e.g. "EXTERNAL_REFERENCE_CONFLICT", "RATE_LIMITED", "WRONG_ENVIRONMENT"), else null.
5
+ * `body` is the decoded response.
6
+ */
7
+ export declare class SebpayApiError extends Error {
8
+ readonly status: number;
9
+ readonly code: string | null;
10
+ readonly body: unknown;
11
+ constructor(message: string, status: number, code: string | null, body: unknown);
12
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SebpayApiError = void 0;
4
+ /**
5
+ * Thrown for every failed call. `status` is the HTTP status, or 0 when no response came back
6
+ * (`code` "NETWORK_ERROR" or "TIMEOUT"). `code` is the API's `errors.code` when it sends one
7
+ * (e.g. "EXTERNAL_REFERENCE_CONFLICT", "RATE_LIMITED", "WRONG_ENVIRONMENT"), else null.
8
+ * `body` is the decoded response.
9
+ */
10
+ class SebpayApiError extends Error {
11
+ status;
12
+ code;
13
+ body;
14
+ constructor(message, status, code, body) {
15
+ super(message);
16
+ this.status = status;
17
+ this.code = code;
18
+ this.body = body;
19
+ this.name = "SebpayApiError";
20
+ }
21
+ }
22
+ exports.SebpayApiError = SebpayApiError;
@@ -0,0 +1,7 @@
1
+ export { SebpayClient } from "./client";
2
+ export type { SebpayClientOptions } from "./client";
3
+ export { SebpayApiError } from "./errors";
4
+ export { SIGNATURE_HEADER, verifySignature } from "./webhooks";
5
+ export type { WebhookPayload } from "./webhooks";
6
+ export { VERSION } from "./version";
7
+ export type { CreateCheckoutSessionParams, CheckoutSession, CheckoutSessionDetails, CheckoutSessionStatus, } from "./resources/checkoutSessions";
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.VERSION = exports.verifySignature = exports.SIGNATURE_HEADER = exports.SebpayApiError = exports.SebpayClient = void 0;
4
+ var client_1 = require("./client");
5
+ Object.defineProperty(exports, "SebpayClient", { enumerable: true, get: function () { return client_1.SebpayClient; } });
6
+ var errors_1 = require("./errors");
7
+ Object.defineProperty(exports, "SebpayApiError", { enumerable: true, get: function () { return errors_1.SebpayApiError; } });
8
+ var webhooks_1 = require("./webhooks");
9
+ Object.defineProperty(exports, "SIGNATURE_HEADER", { enumerable: true, get: function () { return webhooks_1.SIGNATURE_HEADER; } });
10
+ Object.defineProperty(exports, "verifySignature", { enumerable: true, get: function () { return webhooks_1.verifySignature; } });
11
+ var version_1 = require("./version");
12
+ Object.defineProperty(exports, "VERSION", { enumerable: true, get: function () { return version_1.VERSION; } });
@@ -0,0 +1,50 @@
1
+ import type { Requester } from "../client";
2
+ export interface CreateCheckoutSessionParams {
3
+ amount: number;
4
+ /** ISO code, e.g. "XOF". */
5
+ currency: string;
6
+ /** Where your customer lands after paying, with `?checkout_session_id=<id>` appended. */
7
+ redirect_url: string;
8
+ /** Your order id. Sending it again with the same parameters returns the same session (`idempotent_replay`). */
9
+ external_reference?: string;
10
+ /** Receives the signed webhook when the payment is approved or rejected (https). */
11
+ callback_url?: string;
12
+ customer_name?: string;
13
+ customer_email?: string;
14
+ customer_phone?: string;
15
+ /** Echoed back on the session and in its webhook. */
16
+ metadata?: Record<string, unknown>;
17
+ }
18
+ export type CheckoutSessionStatus = "open" | "completed" | "expired" | "canceled";
19
+ export interface CheckoutSession {
20
+ id: string;
21
+ status: CheckoutSessionStatus;
22
+ /** The Sebpay-hosted page to redirect your customer to. */
23
+ checkout_url: string;
24
+ amount: number;
25
+ currency: string;
26
+ external_reference: string | null;
27
+ metadata: Record<string, unknown> | null;
28
+ expires_at: string;
29
+ /** Present (true) when this external_reference had already created the session: it is returned as is. */
30
+ idempotent_replay?: true;
31
+ }
32
+ export interface CheckoutSessionDetails extends Omit<CheckoutSession, "idempotent_replay"> {
33
+ /** The latest payment attempt, if any. */
34
+ transaction_id: string | null;
35
+ /** "approved" is the only status that means paid. */
36
+ transaction_status: "pending" | "approved" | "rejected" | null;
37
+ created_at: string;
38
+ }
39
+ export declare class CheckoutSessions {
40
+ private readonly request;
41
+ constructor(request: Requester);
42
+ /** Create a hosted checkout session and get back a `checkout_url` to redirect your customer to. */
43
+ create(params: CreateCheckoutSessionParams): Promise<CheckoutSession>;
44
+ /**
45
+ * Retrieve a session by its id (the `checkout_session_id` of your redirect_url), its token or your
46
+ * external_reference. This is the server-to-server confirmation: when your customer comes back,
47
+ * deliver only if `transaction_status` is "approved" — the redirect alone proves nothing.
48
+ */
49
+ retrieve(idOrReference: string): Promise<CheckoutSessionDetails>;
50
+ }
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CheckoutSessions = void 0;
4
+ class CheckoutSessions {
5
+ request;
6
+ constructor(request) {
7
+ this.request = request;
8
+ }
9
+ /** Create a hosted checkout session and get back a `checkout_url` to redirect your customer to. */
10
+ create(params) {
11
+ return this.request("POST", "/checkout/sessions", { ...params });
12
+ }
13
+ /**
14
+ * Retrieve a session by its id (the `checkout_session_id` of your redirect_url), its token or your
15
+ * external_reference. This is the server-to-server confirmation: when your customer comes back,
16
+ * deliver only if `transaction_status` is "approved" — the redirect alone proves nothing.
17
+ */
18
+ retrieve(idOrReference) {
19
+ return this.request("GET", `/checkout/sessions/${encodeURIComponent(idOrReference)}`);
20
+ }
21
+ }
22
+ exports.CheckoutSessions = CheckoutSessions;
@@ -0,0 +1,2 @@
1
+ /** SDK version, sent in the User-Agent header. Keep in sync with package.json (checked by the tests). */
2
+ export declare const VERSION = "0.2.0";
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.VERSION = void 0;
4
+ /** SDK version, sent in the User-Agent header. Keep in sync with package.json (checked by the tests). */
5
+ exports.VERSION = "0.2.0";
@@ -0,0 +1,24 @@
1
+ /** The header carrying the signature of every Sebpay webhook. */
2
+ export declare const SIGNATURE_HEADER = "X-SebPay-Signature";
3
+ /** The body Sebpay POSTs to your callback_url once a payment is final. */
4
+ export interface WebhookPayload {
5
+ transaction_id: string;
6
+ external_reference: string | null;
7
+ /** Only final statuses are sent. */
8
+ status: "approved" | "rejected";
9
+ amount: number;
10
+ currency: string;
11
+ customer_phone: string | null;
12
+ created_at: string;
13
+ updated_at: string;
14
+ /** The checkout session this payment belongs to. */
15
+ checkout_session_id?: string;
16
+ /** The metadata you attached to that session. */
17
+ metadata?: Record<string, unknown> | null;
18
+ }
19
+ /**
20
+ * Verifies the `X-SebPay-Signature` header Sebpay sends with every webhook callback. Always pass
21
+ * the raw request body — re-serializing a parsed payload (e.g. via JSON.stringify after parsing)
22
+ * can reorder keys and break the signature. A missing signature or body returns false.
23
+ */
24
+ export declare function verifySignature(rawBody: string | Uint8Array | null | undefined, signature: string | string[] | null | undefined, secretKey: string): boolean;
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SIGNATURE_HEADER = void 0;
4
+ exports.verifySignature = verifySignature;
5
+ const node_crypto_1 = require("node:crypto");
6
+ /** The header carrying the signature of every Sebpay webhook. */
7
+ exports.SIGNATURE_HEADER = "X-SebPay-Signature";
8
+ /**
9
+ * Verifies the `X-SebPay-Signature` header Sebpay sends with every webhook callback. Always pass
10
+ * the raw request body — re-serializing a parsed payload (e.g. via JSON.stringify after parsing)
11
+ * can reorder keys and break the signature. A missing signature or body returns false.
12
+ */
13
+ function verifySignature(rawBody, signature, secretKey) {
14
+ if (typeof signature !== "string" || signature === "")
15
+ return false;
16
+ if (typeof rawBody !== "string" && !(rawBody instanceof Uint8Array))
17
+ return false;
18
+ const expected = (0, node_crypto_1.createHmac)("sha256", secretKey).update(rawBody).digest("hex");
19
+ const expectedBuf = Buffer.from(expected, "utf8");
20
+ const signatureBuf = Buffer.from(signature, "utf8");
21
+ if (expectedBuf.length !== signatureBuf.length) {
22
+ return false;
23
+ }
24
+ return (0, node_crypto_1.timingSafeEqual)(expectedBuf, signatureBuf);
25
+ }
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "sebpay-node",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Official Node.js SDK for Sebpay hosted checkout — create a checkout session, redirect your customer, confirm the payment and verify webhooks. Server-side only.",
5
+ "license": "MIT",
6
+ "author": "Sebpay",
7
+ "homepage": "https://sebpay.africa",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/mjims/sebpay-sdk-js.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/mjims/sebpay-sdk-js/issues"
14
+ },
15
+ "keywords": [
16
+ "sebpay",
17
+ "payment",
18
+ "payments",
19
+ "checkout",
20
+ "mobile-money",
21
+ "africa",
22
+ "webhook",
23
+ "sdk"
24
+ ],
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "main": "dist/index.js",
29
+ "types": "dist/index.d.ts",
30
+ "files": [
31
+ "dist"
32
+ ],
33
+ "scripts": {
34
+ "build": "tsc",
35
+ "prepack": "npm run build",
36
+ "test": "npm run build && node --test test/*.test.mjs"
37
+ },
38
+ "devDependencies": {
39
+ "typescript": "^5.4.0",
40
+ "@types/node": "^20.0.0"
41
+ }
42
+ }