Skip to content
ShopAssist Docs
Webhooks

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:

  • t is the Unix time of signing. Every retry is signed again with a new t.
  • v1 is hex(HMAC-SHA256(key, "<t>.<raw body>")). The key is the UTF-8 bytes of the full secret string as issued, including the whsec_ prefix.
  • During a secret rotation the header carries two v1 values. 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

On this page