@thepayulink/server 2.0.0 → 2.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 +3 -2
- package/dist/index.d.ts +74 -48
- package/dist/payulink-server.cjs +136 -61
- package/package.json +4 -3
- package/src/index.js +22 -8
package/README.md
CHANGED
|
@@ -22,8 +22,9 @@ Node 18+ (uses global `fetch`). Works with `import` and `require`.
|
|
|
22
22
|
import PayuLink, { rupees } from '@thepayulink/server';
|
|
23
23
|
|
|
24
24
|
const pl = new PayuLink({
|
|
25
|
-
keyId: process.env.PAYULINK_KEY_ID,
|
|
26
|
-
keySecret: process.env.PAYULINK_KEY_SECRET,
|
|
25
|
+
keyId: process.env.PAYULINK_KEY_ID, // pl_live_… / pl_test_…
|
|
26
|
+
keySecret: process.env.PAYULINK_KEY_SECRET, // backend only — never ship this
|
|
27
|
+
webhookSecret: process.env.PAYULINK_WEBHOOK_SECRET, // whs_… from panel → Config (NOT keySecret)
|
|
27
28
|
});
|
|
28
29
|
|
|
29
30
|
// 1) Create an order for the TRUE amount, computed on your server.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,81 +1,107 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// Type definitions for @thepayulink/server
|
|
2
|
+
|
|
3
|
+
export interface PayuLinkOptions {
|
|
4
|
+
/** Publishable key: pl_live_… / pl_test_… */
|
|
5
|
+
keyId: string;
|
|
6
|
+
/** Secret key. BACKEND ONLY — never ship this to a browser or app. */
|
|
3
7
|
keySecret: string;
|
|
4
|
-
/**
|
|
5
|
-
keyId?: string;
|
|
6
|
-
/** Pay server base URL. Default https://eliteyatra.vip. */
|
|
8
|
+
/** Gateway base URL. Default https://payulink.io/api */
|
|
7
9
|
apiBase?: string;
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Webhook signing secret (webhook_secret_v2, whs_…) from merchant panel → Config.
|
|
12
|
+
* Required to verify webhooks — it is NOT your keySecret. Pass [newSecret, oldSecret]
|
|
13
|
+
* while rotating.
|
|
14
|
+
*/
|
|
15
|
+
webhookSecret?: string | string[];
|
|
10
16
|
/** Request timeout in ms. Default 15000. */
|
|
11
17
|
timeout?: number;
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
export interface CreateOrderParams {
|
|
15
|
-
/** Amount in
|
|
21
|
+
/** Amount in PAISE. 19950 = ₹199.50. Minimum 100. */
|
|
16
22
|
amount: number;
|
|
17
|
-
|
|
18
|
-
|
|
23
|
+
/** Default 'INR'. */
|
|
24
|
+
currency?: string;
|
|
25
|
+
/** Your reference. Unique per merchant — reusing it returns the SAME order. */
|
|
26
|
+
receipt?: string;
|
|
27
|
+
/** Arbitrary key/value metadata. */
|
|
28
|
+
notes?: Record<string, unknown>;
|
|
29
|
+
/** Optional customer prefill. */
|
|
30
|
+
customer?: { name?: string; contact?: string; email?: string };
|
|
31
|
+
/**
|
|
32
|
+
* @deprecated Only affects legacy version-1 callbacks. Signed (v2) webhooks always go to
|
|
33
|
+
* the URL configured in the merchant panel.
|
|
34
|
+
*/
|
|
35
|
+
notifyUrl?: string;
|
|
19
36
|
}
|
|
20
37
|
|
|
21
38
|
export interface Order {
|
|
22
|
-
|
|
23
|
-
|
|
39
|
+
/** e.g. order_9f2c4b… */
|
|
40
|
+
id: string;
|
|
41
|
+
entity: 'order';
|
|
42
|
+
/** PAISE */
|
|
24
43
|
amount: number;
|
|
25
|
-
|
|
26
|
-
|
|
44
|
+
amount_paid: number;
|
|
45
|
+
amount_due: number;
|
|
46
|
+
currency: string;
|
|
47
|
+
receipt: string | null;
|
|
48
|
+
status: 'created' | 'attempted' | 'paid' | 'expired' | 'failed';
|
|
49
|
+
mode: 'live' | 'test';
|
|
50
|
+
attempts: number;
|
|
51
|
+
notes: Record<string, unknown>;
|
|
52
|
+
payment_id?: string;
|
|
53
|
+
utr?: string;
|
|
54
|
+
created_at: number | null;
|
|
27
55
|
}
|
|
28
56
|
|
|
29
|
-
export interface
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
item: string;
|
|
38
|
-
signature: string | null;
|
|
39
|
-
expires_at: number | null;
|
|
40
|
-
server_now: number;
|
|
57
|
+
export interface PaymentHandoff {
|
|
58
|
+
payulink_order_id?: string;
|
|
59
|
+
payulink_payment_id?: string;
|
|
60
|
+
payulink_signature?: string;
|
|
61
|
+
/** Aliases also accepted. */
|
|
62
|
+
order_id?: string;
|
|
63
|
+
payment_id?: string;
|
|
64
|
+
signature?: string;
|
|
41
65
|
}
|
|
42
66
|
|
|
43
67
|
export interface WebhookEvent {
|
|
44
|
-
id
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
order_id: string;
|
|
49
|
-
status: number;
|
|
50
|
-
status_label: string;
|
|
51
|
-
utr: string | null;
|
|
52
|
-
amount: number;
|
|
53
|
-
item: string;
|
|
54
|
-
};
|
|
68
|
+
id?: string;
|
|
69
|
+
event_type?: string;
|
|
70
|
+
data?: Record<string, any>;
|
|
71
|
+
[k: string]: any;
|
|
55
72
|
}
|
|
56
73
|
|
|
57
|
-
export class
|
|
74
|
+
export class PayuLinkError extends Error {
|
|
58
75
|
status?: number;
|
|
76
|
+
code?: string;
|
|
59
77
|
body?: unknown;
|
|
60
78
|
}
|
|
61
79
|
|
|
62
|
-
|
|
63
|
-
|
|
80
|
+
/** Convert rupees to paise. rupees(199.5) === 19950 */
|
|
81
|
+
export function rupees(r: number): number;
|
|
82
|
+
/** Convert paise to rupees. toRupees(19950) === 199.5 */
|
|
83
|
+
export function toRupees(paise: number): number;
|
|
84
|
+
|
|
85
|
+
export default class PayuLink {
|
|
86
|
+
constructor(options: PayuLinkOptions);
|
|
87
|
+
|
|
88
|
+
readonly keyId: string;
|
|
89
|
+
readonly apiBase: string;
|
|
90
|
+
/** Derived from the key prefix. */
|
|
91
|
+
readonly mode: 'live' | 'test';
|
|
64
92
|
|
|
65
93
|
orders: {
|
|
66
94
|
create(params: CreateOrderParams): Promise<Order>;
|
|
67
|
-
fetch(orderId: string): Promise<
|
|
95
|
+
fetch(orderId: string): Promise<Order>;
|
|
68
96
|
isPaid(orderId: string): Promise<boolean>;
|
|
69
97
|
};
|
|
70
98
|
|
|
71
|
-
payments: {
|
|
72
|
-
fetch(orderId: string): Promise<OrderStatus>;
|
|
73
|
-
};
|
|
74
|
-
|
|
75
99
|
webhooks: {
|
|
76
|
-
verify(rawBody: string | Buffer, signature: string, secret?: string): boolean;
|
|
77
|
-
|
|
100
|
+
verify(rawBody: string | Buffer, signature: string, timestamp: string | number, secret?: string | string[]): boolean;
|
|
101
|
+
/** Verifies signature + ±5min timestamp tolerance. Throws PayuLinkError if invalid. */
|
|
102
|
+
constructEvent(rawBody: string | Buffer, headers: Record<string, any>, secret?: string | string[]): WebhookEvent;
|
|
78
103
|
};
|
|
79
104
|
|
|
80
|
-
|
|
105
|
+
/** signature = HMAC_SHA256(order_id + "|" + payment_id, key_secret) */
|
|
106
|
+
verifyPaymentSignature(p: PaymentHandoff): boolean;
|
|
81
107
|
}
|
package/dist/payulink-server.cjs
CHANGED
|
@@ -29,52 +29,64 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
29
29
|
// src/index.js
|
|
30
30
|
var src_exports = {};
|
|
31
31
|
__export(src_exports, {
|
|
32
|
-
|
|
33
|
-
default: () =>
|
|
32
|
+
PayuLinkError: () => PayuLinkError,
|
|
33
|
+
default: () => PayuLink,
|
|
34
|
+
rupees: () => rupees,
|
|
35
|
+
toRupees: () => toRupees
|
|
34
36
|
});
|
|
35
37
|
module.exports = __toCommonJS(src_exports);
|
|
36
38
|
var import_node_crypto = __toESM(require("node:crypto"), 1);
|
|
37
|
-
var
|
|
38
|
-
|
|
39
|
+
var rupees = (r) => Math.round(Number(r) * 100);
|
|
40
|
+
var toRupees = (paise) => Number(paise) / 100;
|
|
41
|
+
var PayuLinkError = class extends Error {
|
|
42
|
+
constructor(message, { status, code, body } = {}) {
|
|
39
43
|
super(message);
|
|
40
|
-
this.name = "
|
|
44
|
+
this.name = "PayuLinkError";
|
|
41
45
|
this.status = status;
|
|
46
|
+
this.code = code;
|
|
42
47
|
this.body = body;
|
|
43
48
|
}
|
|
44
49
|
};
|
|
45
50
|
function safeEqual(a, b) {
|
|
46
|
-
const ba = Buffer.from(String(a
|
|
47
|
-
const bb = Buffer.from(String(b
|
|
51
|
+
const ba = Buffer.from(String(a ?? ""));
|
|
52
|
+
const bb = Buffer.from(String(b ?? ""));
|
|
48
53
|
return ba.length === bb.length && ba.length > 0 && import_node_crypto.default.timingSafeEqual(ba, bb);
|
|
49
54
|
}
|
|
50
|
-
var
|
|
55
|
+
var PayuLink = class {
|
|
51
56
|
/**
|
|
52
57
|
* @param {object} opts
|
|
53
|
-
* @param {string} opts.
|
|
54
|
-
* @param {string}
|
|
55
|
-
* @param {string} [opts.apiBase]
|
|
56
|
-
* @param {string} [opts.webhookSecret]
|
|
57
|
-
*
|
|
58
|
+
* @param {string} opts.keyId Publishable key (pl_live_… / pl_test_…).
|
|
59
|
+
* @param {string} opts.keySecret Secret key. BACKEND ONLY — never ship this.
|
|
60
|
+
* @param {string} [opts.apiBase] Default https://payulink.io/api
|
|
61
|
+
* @param {string|string[]} [opts.webhookSecret] Webhook signing secret (webhook_secret_v2,
|
|
62
|
+
* whs_…, from merchant panel → Config). Required to verify
|
|
63
|
+
* webhooks. Pass [new, old] while rotating — see constructEvent.
|
|
64
|
+
* It is NOT your keySecret: the two are different secrets.
|
|
65
|
+
* @param {number} [opts.timeout] Request timeout ms. Default 15000.
|
|
58
66
|
*/
|
|
59
67
|
constructor(opts = {}) {
|
|
60
|
-
if (!opts.
|
|
68
|
+
if (!opts.keyId) throw new PayuLinkError("`keyId` (pl_live_\u2026 / pl_test_\u2026) is required");
|
|
69
|
+
if (!opts.keySecret) throw new PayuLinkError("`keySecret` is required and must stay on your server");
|
|
70
|
+
this.keyId = opts.keyId;
|
|
61
71
|
this.keySecret = opts.keySecret;
|
|
62
|
-
this.
|
|
63
|
-
this.
|
|
64
|
-
this.webhookSecret = opts.webhookSecret || opts.keySecret;
|
|
72
|
+
this.apiBase = String(opts.apiBase || "https://payulink.io/api").replace(/\/$/, "");
|
|
73
|
+
this.webhookSecret = opts.webhookSecret || null;
|
|
65
74
|
this.timeout = opts.timeout || 15e3;
|
|
75
|
+
this.mode = /^pl_test_/.test(opts.keyId) ? "test" : "live";
|
|
66
76
|
this.orders = {
|
|
67
77
|
create: (params) => this._createOrder(params),
|
|
68
78
|
fetch: (orderId) => this._fetchOrder(orderId),
|
|
69
|
-
isPaid: async (orderId) => (await this._fetchOrder(orderId)).
|
|
79
|
+
isPaid: async (orderId) => (await this._fetchOrder(orderId)).status === "paid"
|
|
70
80
|
};
|
|
71
|
-
this.payments = { fetch: (orderId) => this._fetchOrder(orderId) };
|
|
72
81
|
this.webhooks = {
|
|
73
|
-
verify: (rawBody, signature, secret) => this._verifyWebhook(rawBody, signature, secret),
|
|
74
|
-
constructEvent: (rawBody,
|
|
82
|
+
verify: (rawBody, signature, timestamp, secret) => this._verifyWebhook(rawBody, signature, timestamp, secret),
|
|
83
|
+
constructEvent: (rawBody, headers, secret) => this._constructEvent(rawBody, headers, secret)
|
|
75
84
|
};
|
|
76
85
|
}
|
|
77
|
-
|
|
86
|
+
get _authHeader() {
|
|
87
|
+
return "Basic " + Buffer.from(`${this.keyId}:${this.keySecret}`).toString("base64");
|
|
88
|
+
}
|
|
89
|
+
async _request(method, path, { body } = {}) {
|
|
78
90
|
const ctrl = new AbortController();
|
|
79
91
|
const t = setTimeout(() => ctrl.abort(), this.timeout);
|
|
80
92
|
let res;
|
|
@@ -83,15 +95,14 @@ var Payulink = class {
|
|
|
83
95
|
method,
|
|
84
96
|
headers: {
|
|
85
97
|
"Content-Type": "application/json",
|
|
86
|
-
|
|
87
|
-
...this.keyId ? { "x-payulink-key": this.keyId } : {}
|
|
98
|
+
Authorization: this._authHeader
|
|
88
99
|
},
|
|
89
100
|
body: body ? JSON.stringify(body) : void 0,
|
|
90
101
|
signal: ctrl.signal
|
|
91
102
|
});
|
|
92
103
|
} catch (e) {
|
|
93
104
|
clearTimeout(t);
|
|
94
|
-
throw new
|
|
105
|
+
throw new PayuLinkError(`Network error: ${e.message}`);
|
|
95
106
|
}
|
|
96
107
|
clearTimeout(t);
|
|
97
108
|
let data = {};
|
|
@@ -99,64 +110,128 @@ var Payulink = class {
|
|
|
99
110
|
data = await res.json();
|
|
100
111
|
} catch {
|
|
101
112
|
}
|
|
102
|
-
if (!res.ok)
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
const err = data && data.error ? data.error : {};
|
|
115
|
+
throw new PayuLinkError(
|
|
116
|
+
err.description || `Request failed (HTTP ${res.status})`,
|
|
117
|
+
{ status: res.status, code: err.code, body: data }
|
|
118
|
+
);
|
|
119
|
+
}
|
|
103
120
|
return data;
|
|
104
121
|
}
|
|
105
122
|
/**
|
|
106
|
-
* Create an order with a server-fixed amount.
|
|
107
|
-
*
|
|
108
|
-
* @param {
|
|
109
|
-
* @
|
|
123
|
+
* Create an order with a server-fixed amount.
|
|
124
|
+
* @param {object} p
|
|
125
|
+
* @param {number} p.amount Amount in PAISE (19950 = ₹199.50). Minimum 100.
|
|
126
|
+
* @param {string} [p.currency] INR (default).
|
|
127
|
+
* @param {string} [p.receipt] Your reference. Unique per merchant — reusing it
|
|
128
|
+
* returns the SAME order, which makes retries safe.
|
|
129
|
+
* @param {object} [p.notes] Arbitrary key/value metadata.
|
|
130
|
+
* @param {object} [p.customer] { name, contact, email } — prefill for the checkout.
|
|
131
|
+
*
|
|
132
|
+
* Webhooks always go to the URL set in the merchant panel. (`notifyUrl` is still sent for
|
|
133
|
+
* backward compatibility but only affects legacy version-1 callbacks, not signed webhooks.)
|
|
110
134
|
*/
|
|
111
|
-
_createOrder(
|
|
112
|
-
const amount = Math.round(Number(
|
|
113
|
-
if (!(amount
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
135
|
+
_createOrder(p = {}) {
|
|
136
|
+
const amount = Math.round(Number(p.amount));
|
|
137
|
+
if (!Number.isFinite(amount) || amount < 100) {
|
|
138
|
+
throw new PayuLinkError("`amount` is required, in PAISE, and must be >= 100 (\u20B91). Tip: use the `rupees()` helper \u2014 rupees(199.5) === 19950.");
|
|
139
|
+
}
|
|
140
|
+
return this._request("POST", "/v1/orders", {
|
|
141
|
+
body: {
|
|
142
|
+
amount,
|
|
143
|
+
currency: p.currency || "INR",
|
|
144
|
+
receipt: p.receipt,
|
|
145
|
+
notes: p.notes,
|
|
146
|
+
customer_name: p.customer?.name,
|
|
147
|
+
customer_mobile: p.customer?.contact,
|
|
148
|
+
customer_email: p.customer?.email,
|
|
149
|
+
notify_url: p.notifyUrl
|
|
150
|
+
}
|
|
117
151
|
});
|
|
118
152
|
}
|
|
119
|
-
/**
|
|
120
|
-
* Fetch the authoritative status of an order.
|
|
121
|
-
* @returns {Promise<{merchant_order_no, status, status_label, paid, utr, amount, signature}>}
|
|
122
|
-
*/
|
|
153
|
+
/** Authoritative order status. */
|
|
123
154
|
_fetchOrder(orderId) {
|
|
124
|
-
if (!orderId) throw new
|
|
125
|
-
return this._request("GET", `/
|
|
155
|
+
if (!orderId) throw new PayuLinkError("orderId is required");
|
|
156
|
+
return this._request("GET", `/v1/orders/${encodeURIComponent(orderId)}`);
|
|
126
157
|
}
|
|
127
158
|
/**
|
|
128
|
-
* Verify
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
159
|
+
* Verify the success handoff the client SDK returns. Mirrors Razorpay's
|
|
160
|
+
* `validatePaymentVerification`.
|
|
161
|
+
*
|
|
162
|
+
* signature = hex(HMAC_SHA256(order_id + "|" + payment_id, key_secret))
|
|
163
|
+
*
|
|
164
|
+
* ALWAYS do this on your server before fulfilling. The client callback alone
|
|
165
|
+
* is not proof of payment.
|
|
166
|
+
*
|
|
167
|
+
* @param {object} p { payulink_order_id, payulink_payment_id, payulink_signature }
|
|
168
|
+
* (also accepts order_id / payment_id / signature)
|
|
132
169
|
*/
|
|
133
|
-
verifyPaymentSignature(
|
|
134
|
-
|
|
135
|
-
const
|
|
170
|
+
verifyPaymentSignature(p = {}) {
|
|
171
|
+
const orderId = p.payulink_order_id || p.order_id;
|
|
172
|
+
const paymentId = p.payulink_payment_id || p.payment_id;
|
|
173
|
+
const signature = p.payulink_signature || p.signature;
|
|
174
|
+
if (!orderId || !paymentId || !signature) return false;
|
|
175
|
+
const expected = import_node_crypto.default.createHmac("sha256", this.keySecret).update(`${orderId}|${paymentId}`).digest("hex");
|
|
136
176
|
return safeEqual(expected, signature);
|
|
137
177
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
178
|
+
/**
|
|
179
|
+
* Verify a webhook. PayuLink signs webhooks Stripe-style:
|
|
180
|
+
* X-PayuLink-Signature: sha256=<hex> over `${timestamp}.${rawBody}`
|
|
181
|
+
* X-PayuLink-Timestamp: <epoch ms>
|
|
182
|
+
*/
|
|
183
|
+
_verifyWebhook(rawBody, signature, timestamp, secret) {
|
|
184
|
+
if (!signature || !timestamp) return false;
|
|
185
|
+
const keys = [].concat(secret || this.webhookSecret || []).filter(Boolean);
|
|
186
|
+
if (keys.length === 0) {
|
|
187
|
+
throw new PayuLinkError("No webhook secret: pass `webhookSecret` (whs_\u2026, merchant panel \u2192 Config) to new PayuLink({...}). Webhooks are not signed with your keySecret.");
|
|
188
|
+
}
|
|
189
|
+
return keys.some((key) => safeEqual("sha256=" + import_node_crypto.default.createHmac("sha256", key).update(`${timestamp}.${String(rawBody)}`).digest("hex"), signature));
|
|
141
190
|
}
|
|
142
191
|
/**
|
|
143
|
-
* Verify
|
|
144
|
-
*
|
|
145
|
-
*
|
|
192
|
+
* Verify + parse a webhook. Throws if the signature is bad or the timestamp is
|
|
193
|
+
* outside the tolerance window (replay protection).
|
|
194
|
+
*
|
|
195
|
+
* Pass the RAW body (string/Buffer) — a re-serialised object will not match.
|
|
196
|
+
*
|
|
197
|
+
* app.post('/webhook', express.raw({type:'*\/*'}), (req, res) => {
|
|
198
|
+
* const event = pl.webhooks.constructEvent(req.body, req.headers);
|
|
199
|
+
* if (event.event_type === 'payin.verified') fulfil(event);
|
|
200
|
+
* res.sendStatus(200);
|
|
201
|
+
* });
|
|
202
|
+
*
|
|
203
|
+
* @param {string|Buffer} rawBody
|
|
204
|
+
* @param {object} headers The request headers object.
|
|
205
|
+
* @param {string|string[]} [secret] Overrides opts.webhookSecret.
|
|
206
|
+
* @param {number} [toleranceSec=300]
|
|
146
207
|
*/
|
|
147
|
-
_constructEvent(rawBody,
|
|
148
|
-
|
|
149
|
-
|
|
208
|
+
_constructEvent(rawBody, headers = {}, secret, toleranceSec = 300) {
|
|
209
|
+
const get = (n) => headers[n] || headers[n.toLowerCase()] || (typeof headers.get === "function" ? headers.get(n) : void 0);
|
|
210
|
+
const signature = get("X-PayuLink-Signature");
|
|
211
|
+
const timestamp = get("X-PayuLink-Timestamp");
|
|
212
|
+
if (!signature) throw new PayuLinkError("Missing X-PayuLink-Signature", { status: 401 });
|
|
213
|
+
if (!timestamp) throw new PayuLinkError("Missing X-PayuLink-Timestamp", { status: 401 });
|
|
214
|
+
const ageSec = Math.abs(Date.now() - Number(timestamp)) / 1e3;
|
|
215
|
+
if (!Number.isFinite(ageSec) || ageSec > toleranceSec) {
|
|
216
|
+
throw new PayuLinkError(`Webhook timestamp outside tolerance (${Math.round(ageSec)}s)`, { status: 401 });
|
|
217
|
+
}
|
|
218
|
+
if (!this._verifyWebhook(rawBody, signature, timestamp, secret)) {
|
|
219
|
+
throw new PayuLinkError("Invalid webhook signature", { status: 401 });
|
|
150
220
|
}
|
|
151
221
|
try {
|
|
152
|
-
|
|
222
|
+
const event = JSON.parse(String(rawBody));
|
|
223
|
+
event.id = event.id || get("X-PayuLink-Event-Id");
|
|
224
|
+
event.event_type = event.event_type || get("X-PayuLink-Event-Type");
|
|
225
|
+
return event;
|
|
153
226
|
} catch {
|
|
154
|
-
throw new
|
|
227
|
+
throw new PayuLinkError("Invalid webhook JSON", { status: 400 });
|
|
155
228
|
}
|
|
156
229
|
}
|
|
157
230
|
};
|
|
158
231
|
// Annotate the CommonJS export names for ESM import in node:
|
|
159
232
|
0 && (module.exports = {
|
|
160
|
-
|
|
233
|
+
PayuLinkError,
|
|
234
|
+
rupees,
|
|
235
|
+
toRupees
|
|
161
236
|
});
|
|
162
237
|
(()=>{const ns=module.exports,C=ns.default;if(C){C.PayulinkError=ns.PayulinkError;C.Payulink=C;C.default=C;module.exports=C;}})();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thepayulink/server",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "PayuLink server SDK
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "PayuLink server SDK — create orders and verify payments/webhooks with your key_secret.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/payulink-server.cjs",
|
|
7
7
|
"module": "./src/index.js",
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
"node": ">=18"
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
|
-
"build": "node build.mjs"
|
|
25
|
+
"build": "node build.mjs",
|
|
26
|
+
"prepublishOnly": "npm run build"
|
|
26
27
|
},
|
|
27
28
|
"keywords": [
|
|
28
29
|
"payulink",
|
package/src/index.js
CHANGED
|
@@ -41,8 +41,10 @@ export default class PayuLink {
|
|
|
41
41
|
* @param {string} opts.keyId Publishable key (pl_live_… / pl_test_…).
|
|
42
42
|
* @param {string} opts.keySecret Secret key. BACKEND ONLY — never ship this.
|
|
43
43
|
* @param {string} [opts.apiBase] Default https://payulink.io/api
|
|
44
|
-
* @param {string} [opts.webhookSecret] Webhook signing secret (webhook_secret_v2
|
|
45
|
-
* merchant panel).
|
|
44
|
+
* @param {string|string[]} [opts.webhookSecret] Webhook signing secret (webhook_secret_v2,
|
|
45
|
+
* whs_…, from merchant panel → Config). Required to verify
|
|
46
|
+
* webhooks. Pass [new, old] while rotating — see constructEvent.
|
|
47
|
+
* It is NOT your keySecret: the two are different secrets.
|
|
46
48
|
* @param {number} [opts.timeout] Request timeout ms. Default 15000.
|
|
47
49
|
*/
|
|
48
50
|
constructor(opts = {}) {
|
|
@@ -51,7 +53,9 @@ export default class PayuLink {
|
|
|
51
53
|
this.keyId = opts.keyId;
|
|
52
54
|
this.keySecret = opts.keySecret;
|
|
53
55
|
this.apiBase = String(opts.apiBase || 'https://payulink.io/api').replace(/\/$/, '');
|
|
54
|
-
|
|
56
|
+
// No fallback to keySecret. Webhooks are never signed with it, so the old fallback
|
|
57
|
+
// turned a missing env var into "every webhook fails signature" with no clue why.
|
|
58
|
+
this.webhookSecret = opts.webhookSecret || null;
|
|
55
59
|
this.timeout = opts.timeout || 15000;
|
|
56
60
|
this.mode = /^pl_test_/.test(opts.keyId) ? 'test' : 'live';
|
|
57
61
|
|
|
@@ -109,6 +113,10 @@ export default class PayuLink {
|
|
|
109
113
|
* @param {string} [p.receipt] Your reference. Unique per merchant — reusing it
|
|
110
114
|
* returns the SAME order, which makes retries safe.
|
|
111
115
|
* @param {object} [p.notes] Arbitrary key/value metadata.
|
|
116
|
+
* @param {object} [p.customer] { name, contact, email } — prefill for the checkout.
|
|
117
|
+
*
|
|
118
|
+
* Webhooks always go to the URL set in the merchant panel. (`notifyUrl` is still sent for
|
|
119
|
+
* backward compatibility but only affects legacy version-1 callbacks, not signed webhooks.)
|
|
112
120
|
*/
|
|
113
121
|
_createOrder(p = {}) {
|
|
114
122
|
const amount = Math.round(Number(p.amount));
|
|
@@ -165,10 +173,16 @@ export default class PayuLink {
|
|
|
165
173
|
*/
|
|
166
174
|
_verifyWebhook(rawBody, signature, timestamp, secret) {
|
|
167
175
|
if (!signature || !timestamp) return false;
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
176
|
+
const keys = [].concat(secret || this.webhookSecret || []).filter(Boolean);
|
|
177
|
+
if (keys.length === 0) {
|
|
178
|
+
throw new PayuLinkError('No webhook secret: pass `webhookSecret` (whs_…, merchant panel → Config) '
|
|
179
|
+
+ 'to new PayuLink({...}). Webhooks are not signed with your keySecret.');
|
|
180
|
+
}
|
|
181
|
+
// Several secrets are accepted so a rotation never drops events: deliveries already
|
|
182
|
+
// queued (and their retries, for up to ~30h) stay signed with the secret that was
|
|
183
|
+
// current when each event was created.
|
|
184
|
+
return keys.some((key) => safeEqual('sha256=' + crypto.createHmac('sha256', key)
|
|
185
|
+
.update(`${timestamp}.${String(rawBody)}`).digest('hex'), signature));
|
|
172
186
|
}
|
|
173
187
|
|
|
174
188
|
/**
|
|
@@ -185,7 +199,7 @@ export default class PayuLink {
|
|
|
185
199
|
*
|
|
186
200
|
* @param {string|Buffer} rawBody
|
|
187
201
|
* @param {object} headers The request headers object.
|
|
188
|
-
* @param {string} [secret]
|
|
202
|
+
* @param {string|string[]} [secret] Overrides opts.webhookSecret.
|
|
189
203
|
* @param {number} [toleranceSec=300]
|
|
190
204
|
*/
|
|
191
205
|
_constructEvent(rawBody, headers = {}, secret, toleranceSec = 300) {
|