Verifying signatures
Check the ShopAssist-Signature header with HMAC-SHA256 so you only accept genuine webhook deliveries.
Every delivery is signed with your endpoint's secret. Always verify the signature before you trust the payload.
The signature
The ShopAssist-Signature header looks like t=1791450000,v1=5f0c…e21a:
tis the Unix time of signing. Every retry is signed again with a newt.v1ishex(HMAC-SHA256(key, "<t>.<raw body>")). The key is the UTF-8 bytes of the full secret string as issued, including thewhsec_prefix.- During a secret rotation the header carries two
v1values. Accept the request if any of them matches.
Reject requests where t is more than 300 seconds from your clock.
Use the raw body
Compute the HMAC over the exact bytes you received. Parsing and re-serializing the JSON changes whitespace and key order, and the signature will not match.
Python
import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = [p.strip().split("=", 1) for p in header.split(",") if "=" in p]
t = next((v for k, v in parts if k == "t"), None)
sigs = [v for k, v in parts if k == "v1"]
if t is None or not t.isdigit() or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in sigs)Node.js
import { createHmac, timingSafeEqual } from "node:crypto"
export function verify(rawBody /* Buffer */, header, secret, tolerance = 300) {
const parts = header.split(",").map((p) => p.trim().split("="))
const t = parts.find(([k]) => k === "t")?.[1]
const sigs = parts.filter(([k]) => k === "v1").map(([, v]) => v)
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > tolerance) return false
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex")
return sigs.some((s) => s.length === expected.length && timingSafeEqual(Buffer.from(s), Buffer.from(expected)))
}PHP
<?php
function shopassist_verify(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
$t = null; $sigs = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
if ($k === 't') { $t = $v; } elseif ($k === 'v1') { $sigs[] = $v; }
}
if ($t === null || !ctype_digit($t) || abs(time() - (int) $t) > $tolerance) { return false; }
$expected = hash_hmac('sha256', "$t." . $rawBody, $secret);
foreach ($sigs as $sig) { if (hash_equals($expected, $sig)) { return true; } }
return false;
}Deduplicate
A valid event can arrive more than once (for example when your 2xx was lost). Store ShopAssist-Event-Id and skip ids you have already processed.
Rotating the secret
Rotate secret on the endpoint issues a new secret and shows it once. For 24 hours deliveries are signed with both the new and the previous secret, so you can deploy the new one without dropping events. Rotating again within 24 hours replaces the previous secret: only two secrets are ever valid.
Last updated on