@thepayulink/server 2.0.1 → 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
@@ -7,8 +7,12 @@ export interface PayuLinkOptions {
7
7
  keySecret: string;
8
8
  /** Gateway base URL. Default https://payulink.io/api */
9
9
  apiBase?: string;
10
- /** Webhook signing secret (webhook_secret_v2). Falls back to keySecret. */
11
- 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[];
12
16
  /** Request timeout in ms. Default 15000. */
13
17
  timeout?: number;
14
18
  }
@@ -24,7 +28,10 @@ export interface CreateOrderParams {
24
28
  notes?: Record<string, unknown>;
25
29
  /** Optional customer prefill. */
26
30
  customer?: { name?: string; contact?: string; email?: string };
27
- /** Per-order webhook override. */
31
+ /**
32
+ * @deprecated Only affects legacy version-1 callbacks. Signed (v2) webhooks always go to
33
+ * the URL configured in the merchant panel.
34
+ */
28
35
  notifyUrl?: string;
29
36
  }
30
37
 
@@ -90,9 +97,9 @@ export default class PayuLink {
90
97
  };
91
98
 
92
99
  webhooks: {
93
- verify(rawBody: string | Buffer, signature: string, timestamp: string | number, secret?: string): boolean;
100
+ verify(rawBody: string | Buffer, signature: string, timestamp: string | number, secret?: string | string[]): boolean;
94
101
  /** Verifies signature + ±5min timestamp tolerance. Throws PayuLinkError if invalid. */
95
- constructEvent(rawBody: string | Buffer, headers: Record<string, any>, secret?: string): WebhookEvent;
102
+ constructEvent(rawBody: string | Buffer, headers: Record<string, any>, secret?: string | string[]): WebhookEvent;
96
103
  };
97
104
 
98
105
  /** signature = HMAC_SHA256(order_id + "|" + payment_id, key_secret) */
@@ -58,8 +58,10 @@ var PayuLink = class {
58
58
  * @param {string} opts.keyId Publishable key (pl_live_… / pl_test_…).
59
59
  * @param {string} opts.keySecret Secret key. BACKEND ONLY — never ship this.
60
60
  * @param {string} [opts.apiBase] Default https://payulink.io/api
61
- * @param {string} [opts.webhookSecret] Webhook signing secret (webhook_secret_v2 from your
62
- * merchant panel). Falls back to keySecret.
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.
63
65
  * @param {number} [opts.timeout] Request timeout ms. Default 15000.
64
66
  */
65
67
  constructor(opts = {}) {
@@ -68,7 +70,7 @@ var PayuLink = class {
68
70
  this.keyId = opts.keyId;
69
71
  this.keySecret = opts.keySecret;
70
72
  this.apiBase = String(opts.apiBase || "https://payulink.io/api").replace(/\/$/, "");
71
- this.webhookSecret = opts.webhookSecret || opts.keySecret;
73
+ this.webhookSecret = opts.webhookSecret || null;
72
74
  this.timeout = opts.timeout || 15e3;
73
75
  this.mode = /^pl_test_/.test(opts.keyId) ? "test" : "live";
74
76
  this.orders = {
@@ -125,6 +127,10 @@ var PayuLink = class {
125
127
  * @param {string} [p.receipt] Your reference. Unique per merchant — reusing it
126
128
  * returns the SAME order, which makes retries safe.
127
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.)
128
134
  */
129
135
  _createOrder(p = {}) {
130
136
  const amount = Math.round(Number(p.amount));
@@ -176,9 +182,11 @@ var PayuLink = class {
176
182
  */
177
183
  _verifyWebhook(rawBody, signature, timestamp, secret) {
178
184
  if (!signature || !timestamp) return false;
179
- const key = secret || this.webhookSecret;
180
- const expected = "sha256=" + import_node_crypto.default.createHmac("sha256", key).update(`${timestamp}.${String(rawBody)}`).digest("hex");
181
- return safeEqual(expected, signature);
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));
182
190
  }
183
191
  /**
184
192
  * Verify + parse a webhook. Throws if the signature is bad or the timestamp is
@@ -194,7 +202,7 @@ var PayuLink = class {
194
202
  *
195
203
  * @param {string|Buffer} rawBody
196
204
  * @param {object} headers The request headers object.
197
- * @param {string} [secret]
205
+ * @param {string|string[]} [secret] Overrides opts.webhookSecret.
198
206
  * @param {number} [toleranceSec=300]
199
207
  */
200
208
  _constructEvent(rawBody, headers = {}, secret, toleranceSec = 300) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thepayulink/server",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
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",
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) {