PowerShell 7: verify a Sume webhook with HMACSHA256, constant-time

A PowerShell function that refuses an empty secret, checks the 300-second window and accepts any entry in a rotated sume-v1 header. Under 30 lines.

5 min readSume
All posts

In PowerShell 7 you can verify a Sume webhook with .NET's HMACSHA256 and CryptographicOperations.FixedTimeEquals. Build the message as <timestamp>.<raw body bytes>, compute the HMAC, prefix the hex with sume-v1=, and compare it with every comma-separated entry of x-sume-webhook-signature.

The scheme

The values below come from the Sume webhook docs. The key point for a script is to hash the body bytes exactly as received, not a re-serialized object.

Sume webhook verification inputs (read 2026-10-08)
InputValue
Message<x-sume-webhook-timestamp>.<raw body>
AlgorithmHMAC-SHA256, hex output
Expected header entrysume-v1=<hex>
During rotationcomma-separated entries, newest first, for 24 hours
Replay window300 seconds (the docs call five minutes a reasonable default)
SecretSUME_COM_WEBHOOK_SIGNING_SECRET, from GET /v1/webhooks/signing-secret

The function

The function throws on an empty secret instead of returning false, because an empty secret means the receiver is misconfigured and would otherwise pass every forged request into a different code path. It returns $false for a bad timestamp, an old timestamp or a wrong signature. It checks every header entry and does not stop at the first match, so timing does not reveal which entry matched.

function Test-SumeSignature {
  param([byte[]]$Body, [string]$Timestamp, [string]$Header,
        [string]$Secret, [int]$Tolerance = 300)
  if ([string]::IsNullOrEmpty($Secret)) { throw "empty webhook secret" }
  $ts = 0L
  if (-not [long]::TryParse($Timestamp, [ref]$ts)) { return $false }
  $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
  if ([math]::Abs($now - $ts) -gt $Tolerance) { return $false }
  $key = [Text.Encoding]::UTF8.GetBytes($Secret)
  $msg = [Text.Encoding]::UTF8.GetBytes("$Timestamp.") + $Body
  $mac = [Security.Cryptography.HMACSHA256]::new($key)
  $hex = [Convert]::ToHexString($mac.ComputeHash($msg)).ToLowerInvariant()
  $want = [Text.Encoding]::UTF8.GetBytes("sume-v1=$hex")
  $ok = $false
  foreach ($entry in $Header.Split(",")) {
    $got = [Text.Encoding]::UTF8.GetBytes($entry.Trim())
    if ($got.Length -eq $want.Length -and
        [Security.Cryptography.CryptographicOperations]::FixedTimeEquals($got, $want)) {
      $ok = $true
    }
  }
  return $ok
}

Edge cases the function covers

A missing x-sume-webhook-timestamp header arrives as an empty string, which fails TryParse and returns $false. A header with a stray space after the comma still verifies, because each entry is trimmed. An entry that does not start with sume-v1= can never equal the expected bytes, so it is ignored without a special case.

A timestamp from the future by more than 300 seconds fails the same check as one from the past, because the code uses the absolute difference. If your server clock drifts, fix the clock; do not widen the window to hide it.

Using it

Feed it the request body as bytes. With an HttpListener, copy request.InputStream into a MemoryStream and call ToArray(); do not read it as a string and re-encode it. Return a 2xx quickly after you write the job_id somewhere durable, because Sume waits 10 seconds per attempt and retries a non-2xx answer up to 10 times.

Test it with a body you sign yourself, then flip one byte. For a longer scripted check without PowerShell, see signing a fake delivery with openssl and curl.

  • Convert.ToHexString needs PowerShell 7 (.NET 5 or later).
  • Do not use -eq on the signature strings: it is not constant-time.
  • Read the secret from an environment variable, not from the script.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume