Verify HMAC Signatures
Every DVS webhook is signed with HMAC-SHA256 using a secret you received at onboarding. Always verify the signature before processing — if you skip this, an attacker who knows your endpoint URL can send fake events.
The algorithm
DVS computes:
payload_to_sign = timestamp + "." + raw_body
signature = hmac_sha256_hex(secret, payload_to_sign)
And sends two headers:
X-DVS-Signature: t=1748884800,v1=a8d3e8b1c2...
X-DVS-Signature-Timestamp: 1748884800
Your job:
- Extract
timestampfromX-DVS-Signature-Timestamp. - Build the expected payload:
timestamp + "." + raw_request_body. - Compute
hmac_sha256_hex(your_secret, payload). - Compare it against the
v1=...part ofX-DVS-Signatureusing constant-time comparison. - Reject if
|now - timestamp| > 300 seconds(anti-replay).
Critical rules
Use the raw request body, not the parsed JSON. The signature is over the exact bytes DVS sent. If you parse JSON and re-serialize, whitespace and key order change and the signature will not match. Configure your framework to give you raw bytes (express.raw(), FastAPI await request.body(), etc.).
Use a constant-time comparison. Use hmac.compare_digest (Python), crypto.timingSafeEqual (Node), hash_equals (PHP), MessageDigest.isEqual (Java). A naive == is vulnerable to timing attacks.
Sync your server clock with NTP. The ±5 minute anti-replay window assumes accurate time. Drifted clocks will reject legitimate events.
Implementations
- Python (FastAPI)
- Node.js (Express)
- PHP (Slim or vanilla)
- Java (Spring Boot)
- curl (debugging)
import hmac
import hashlib
import time
from fastapi import FastAPI, Request, HTTPException, Header
DVS_WEBHOOK_SECRET = os.environ["DVS_WEBHOOK_SECRET"]
SIGNATURE_TOLERANCE_SECONDS = 300
app = FastAPI()
def verify_dvs_signature(
raw_body: bytes,
signature_header: str | None,
timestamp_header: str | None,
secret: str,
) -> bool:
if not signature_header or not timestamp_header:
return False
# 1) Anti-replay window
try:
ts = int(timestamp_header)
except (TypeError, ValueError):
return False
if abs(time.time() - ts) > SIGNATURE_TOLERANCE_SECONDS:
return False
# 2) Parse signature header (format: "t=<ts>,v1=<hex>")
parts = {k: v for k, v in (kv.split("=", 1) for kv in signature_header.split(","))}
received = parts.get("v1")
if not received:
return False
# 3) Compute expected signature
payload = f"{timestamp_header}.".encode() + raw_body
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
# 4) Constant-time compare
return hmac.compare_digest(received, expected)
@app.post("/integrations/dvs/webhooks")
async def receive_dvs_webhook(
request: Request,
x_dvs_signature: str | None = Header(None),
x_dvs_signature_timestamp: str | None = Header(None),
x_dvs_event_id: str | None = Header(None),
x_dvs_event_type: str | None = Header(None),
):
raw_body = await request.body()
if not verify_dvs_signature(
raw_body, x_dvs_signature, x_dvs_signature_timestamp, DVS_WEBHOOK_SECRET
):
raise HTTPException(status_code=401, detail="Invalid signature")
if already_processed(x_dvs_event_id):
return {"status": "duplicate_ignored"}
enqueue_for_processing(x_dvs_event_id, x_dvs_event_type, raw_body)
return {"status": "received"}
import express from "express";
import crypto from "node:crypto";
const DVS_WEBHOOK_SECRET = process.env.DVS_WEBHOOK_SECRET;
const SIGNATURE_TOLERANCE_SECONDS = 300;
const app = express();
// IMPORTANT: mount as raw to access the exact bytes for HMAC
app.use(
"/integrations/dvs/webhooks",
express.raw({ type: "application/json" }),
);
function verifyDvsSignature(rawBody, signatureHeader, timestampHeader, secret) {
if (!signatureHeader || !timestampHeader) return false;
const ts = parseInt(timestampHeader, 10);
if (Number.isNaN(ts)) return false;
if (Math.abs(Date.now() / 1000 - ts) > SIGNATURE_TOLERANCE_SECONDS) return false;
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("=", 2)),
);
if (!parts.v1) return false;
const payload = Buffer.concat([
Buffer.from(`${timestampHeader}.`, "utf8"),
rawBody,
]);
const expected = crypto
.createHmac("sha256", secret)
.update(payload)
.digest("hex");
const received = Buffer.from(parts.v1, "hex");
const expectedBuf = Buffer.from(expected, "hex");
if (received.length !== expectedBuf.length) return false;
return crypto.timingSafeEqual(received, expectedBuf);
}
app.post("/integrations/dvs/webhooks", (req, res) => {
const signature = req.header("X-DVS-Signature");
const timestamp = req.header("X-DVS-Signature-Timestamp");
const eventId = req.header("X-DVS-Event-Id");
const eventType = req.header("X-DVS-Event-Type");
if (!verifyDvsSignature(req.body, signature, timestamp, DVS_WEBHOOK_SECRET)) {
return res.status(401).send({ error: "Invalid signature" });
}
if (alreadyProcessed(eventId)) {
return res.status(200).send({ status: "duplicate_ignored" });
}
enqueueForProcessing(eventId, eventType, req.body);
res.status(200).send({ status: "received" });
});
app.listen(8080);
<?php
const DVS_WEBHOOK_SECRET = ""; // load from getenv("DVS_WEBHOOK_SECRET")
const SIGNATURE_TOLERANCE_SECONDS = 300;
function verifyDvsSignature(
string $rawBody,
?string $signatureHeader,
?string $timestampHeader,
string $secret
): bool {
if (!$signatureHeader || !$timestampHeader) return false;
$ts = intval($timestampHeader);
if ($ts === 0) return false;
if (abs(time() - $ts) > SIGNATURE_TOLERANCE_SECONDS) return false;
$parts = [];
foreach (explode(",", $signatureHeader) as $kv) {
[$k, $v] = explode("=", $kv, 2);
$parts[$k] = $v;
}
if (!isset($parts["v1"])) return false;
$payload = $timestampHeader . "." . $rawBody;
$expected = hash_hmac("sha256", $payload, $secret);
return hash_equals($expected, $parts["v1"]);
}
// In your route handler:
$rawBody = file_get_contents("php://input");
$signature = $_SERVER["HTTP_X_DVS_SIGNATURE"] ?? null;
$timestamp = $_SERVER["HTTP_X_DVS_SIGNATURE_TIMESTAMP"] ?? null;
$eventId = $_SERVER["HTTP_X_DVS_EVENT_ID"] ?? null;
if (!verifyDvsSignature($rawBody, $signature, $timestamp, getenv("DVS_WEBHOOK_SECRET"))) {
http_response_code(401);
echo json_encode(["error" => "Invalid signature"]);
exit;
}
if (alreadyProcessed($eventId)) {
http_response_code(200);
echo json_encode(["status" => "duplicate_ignored"]);
exit;
}
enqueueForProcessing($eventId, $rawBody);
http_response_code(200);
echo json_encode(["status" => "received"]);
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Map;
@RestController
public class DvsWebhookController {
private static final String SECRET = System.getenv("DVS_WEBHOOK_SECRET");
private static final long TOLERANCE_SECONDS = 300;
@PostMapping(value = "/integrations/dvs/webhooks", consumes = "application/json")
public ResponseEntity<?> receive(
@RequestBody byte[] rawBody,
@RequestHeader(value = "X-DVS-Signature", required = false) String signatureHeader,
@RequestHeader(value = "X-DVS-Signature-Timestamp", required = false) String timestampHeader,
@RequestHeader(value = "X-DVS-Event-Id", required = false) String eventId
) throws Exception {
if (!verify(rawBody, signatureHeader, timestampHeader, SECRET)) {
return ResponseEntity.status(401).body(Map.of("error", "Invalid signature"));
}
if (idempotencyStore.alreadyProcessed(eventId)) {
return ResponseEntity.ok(Map.of("status", "duplicate_ignored"));
}
queue.enqueue(eventId, rawBody);
return ResponseEntity.ok(Map.of("status", "received"));
}
private boolean verify(byte[] body, String sigHeader, String tsHeader, String secret) throws Exception {
if (sigHeader == null || tsHeader == null) return false;
long ts;
try { ts = Long.parseLong(tsHeader); } catch (Exception e) { return false; }
if (Math.abs(Instant.now().getEpochSecond() - ts) > TOLERANCE_SECONDS) return false;
String v1 = null;
for (String part : sigHeader.split(",")) {
String[] kv = part.split("=", 2);
if (kv.length == 2 && kv[0].equals("v1")) v1 = kv[1];
}
if (v1 == null) return false;
byte[] prefix = (tsHeader + ".").getBytes(StandardCharsets.UTF_8);
byte[] payload = new byte[prefix.length + body.length];
System.arraycopy(prefix, 0, payload, 0, prefix.length);
System.arraycopy(body, 0, payload, prefix.length, body.length);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] expected = mac.doFinal(payload);
String expectedHex = bytesToHex(expected);
return MessageDigest.isEqual(expectedHex.getBytes(), v1.getBytes());
}
private static String bytesToHex(byte[] b) {
StringBuilder sb = new StringBuilder(b.length * 2);
for (byte x : b) sb.append(String.format("%02x", x));
return sb.toString();
}
}
# To check that you're computing the same signature DVS sent:
TIMESTAMP="1748884800"
BODY='{"event_id":"evt_test","event_type":"test.ping","event_version":1}'
SECRET="whsec_xxxxxxxxxxxxxx"
EXPECTED=$(printf "%s.%s" "$TIMESTAMP" "$BODY" | \
openssl dgst -sha256 -hmac "$SECRET" -hex | \
awk '{print $NF}')
echo "Expected v1=$EXPECTED"
echo "Compare against the v1=... part of the X-DVS-Signature header DVS sent."
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Receiver returns 401 for every webhook | Wrong secret, or computing HMAC on parsed JSON instead of raw body. |
| Some webhooks pass, others fail (after rotation) | Forgot to support both secrets during the 24h grace window. See Rotating Webhook Secrets. |
| All webhooks rejected with "timestamp too old" | Your server clock drifted. Install NTP / chrony / systemd-timesyncd. |
| Works locally with ngrok, fails in prod | Different secret in prod environment, or proxy mutating the body (some load balancers/CDNs alter JSON). Bypass any transformation between the network edge and your handler. |
Test it without real traffic
Ask OSIGU to send a test.ping event to your endpoint via the :test admin action. The payload is synthetic but signed with your real secret — confirms end-to-end verification works.