@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 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, // pl_live_… / pl_test_…
26
- keySecret: process.env.PAYULINK_KEY_SECRET, // backend only — never ship this
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
- export interface PayulinkConfig {
2
- /** Secret key (pl_sk_…). Required. Backend only. */
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
- /** Publishable key (pl_pk_…). Optional. */
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
- /** Secret to verify inbound webhooks (defaults to keySecret). */
9
- webhookSecret?: string;
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 rupees. Required. */
21
+ /** Amount in PAISE. 19950 = ₹199.50. Minimum 100. */
16
22
  amount: number;
17
- item?: string;
18
- customer?: { name?: string; mobile?: string; email?: string };
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
- order_id: string;
23
- key_id: string | null;
39
+ /** e.g. order_9f2c4b… */
40
+ id: string;
41
+ entity: 'order';
42
+ /** PAISE */
24
43
  amount: number;
25
- item: string;
26
- status: string;
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 OrderStatus {
30
- merchant_order_no: string;
31
- /** 0=SUCCESS, 1=FAILED, 2=PENDING, 3=EXPIRED, … */
32
- status: number;
33
- status_label: string;
34
- paid: boolean;
35
- utr: string | null;
36
- amount: number;
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: string;
45
- event: string;
46
- created_at: number;
47
- data: {
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 PayulinkError extends Error {
74
+ export class PayuLinkError extends Error {
58
75
  status?: number;
76
+ code?: string;
59
77
  body?: unknown;
60
78
  }
61
79
 
62
- export default class Payulink {
63
- constructor(config: PayulinkConfig);
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<OrderStatus>;
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
- constructEvent(rawBody: string | Buffer, signature: string, secret?: string): WebhookEvent;
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
- verifyPaymentSignature(p: { order_id: string; utr?: string | null; signature: string }): boolean;
105
+ /** signature = HMAC_SHA256(order_id + "|" + payment_id, key_secret) */
106
+ verifyPaymentSignature(p: PaymentHandoff): boolean;
81
107
  }
@@ -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
- PayulinkError: () => PayulinkError,
33
- default: () => Payulink
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 PayulinkError = class extends Error {
38
- constructor(message, { status, body } = {}) {
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 = "PayulinkError";
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 Payulink = class {
55
+ var PayuLink = class {
51
56
  /**
52
57
  * @param {object} opts
53
- * @param {string} opts.keySecret Secret key (pl_sk_). Required. Backend only.
54
- * @param {string} [opts.keyId] Publishable key (pl_pk_…). Optional.
55
- * @param {string} [opts.apiBase] Pay server URL. Default https://eliteyatra.vip.
56
- * @param {string} [opts.webhookSecret] Secret used to verify inbound webhooks (defaults to keySecret).
57
- * @param {number} [opts.timeout] Request timeout in ms. Default 15000.
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.keySecret) throw new PayulinkError("`keySecret` (pl_sk_\u2026) is required");
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.keyId = opts.keyId || null;
63
- this.apiBase = String(opts.apiBase || "https://eliteyatra.vip").replace(/\/$/, "");
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)).paid === true
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, signature, secret) => this._constructEvent(rawBody, signature, secret)
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
- async _request(method, path, { body, auth } = {}) {
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
- ...auth ? { Authorization: `Bearer ${this.keySecret}` } : {},
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 PayulinkError(`Network error: ${e.message}`);
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) throw new PayulinkError(data.error || `Request failed (HTTP ${res.status})`, { status: res.status, body: data });
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. The client only receives the
107
- * returned `order_id` and cannot change the amount.
108
- * @param {object} params { amount (rupees), item?, customer? }
109
- * @returns {Promise<{order_id, key_id, amount, item, status}>}
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(params = {}) {
112
- const amount = Math.round(Number(params.amount) || 0);
113
- if (!(amount >= 1)) throw new PayulinkError("`amount` (in rupees) is required and must be >= 1");
114
- return this._request("POST", "/api/orders", {
115
- auth: true,
116
- body: { amount, item: params.item, customer: params.customer }
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 PayulinkError("orderId is required");
125
- return this._request("GET", `/api/order/${encodeURIComponent(orderId)}`);
155
+ if (!orderId) throw new PayuLinkError("orderId is required");
156
+ return this._request("GET", `/v1/orders/${encodeURIComponent(orderId)}`);
126
157
  }
127
158
  /**
128
- * Verify a payment handoff signature returned to the client on success.
129
- * Mirrors Razorpay's signature check.
130
- * @param {object} p { order_id, utr, signature }
131
- * @returns {boolean}
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({ order_id, utr, signature } = {}) {
134
- if (!order_id || !signature) return false;
135
- const expected = import_node_crypto.default.createHmac("sha256", this.keySecret).update(`${order_id}|${utr || ""}`).digest("hex");
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
- _verifyWebhook(rawBody, signature, secret) {
139
- const expected = import_node_crypto.default.createHmac("sha256", secret || this.webhookSecret).update(String(rawBody)).digest("hex");
140
- return safeEqual(expected, signature);
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 an inbound webhook and return the parsed event. Throws if the
144
- * signature is invalid. Pass the RAW request body (string/Buffer) and the
145
- * `x-payulink-signature` header.
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, signature, secret) {
148
- if (!this._verifyWebhook(rawBody, signature, secret)) {
149
- throw new PayulinkError("Invalid webhook signature", { status: 401 });
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
- return JSON.parse(String(rawBody));
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 PayulinkError("Invalid webhook JSON", { status: 400 });
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
- PayulinkError
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.0.0",
4
- "description": "PayuLink server SDK \u2014 create orders and verify payments/webhooks with your key_secret.",
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 from your
45
- * merchant panel). Falls back to keySecret.
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
- this.webhookSecret = opts.webhookSecret || opts.keySecret;
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 key = secret || this.webhookSecret;
169
- const expected = 'sha256=' + crypto.createHmac('sha256', key)
170
- .update(`${timestamp}.${String(rawBody)}`).digest('hex');
171
- return safeEqual(expected, signature);
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) {