Skip to content

Every webhook delivery includes an HMAC-SHA256 signature in the X-Socifyr-Signature header. Verifying it ensures:

  1. The request actually came from Socifyr (not a spoofer)
  2. The body wasn't tampered with in transit

The header

http
X-Socifyr-Signature: t=1715731000,v1=4f9a8b...
ComponentMeaning
t=<unix_ts>Unix timestamp when Socifyr signed the request
v1=<hex>HMAC-SHA256(secret, t.body)

Verification (Node.js)

typescript
import crypto from 'crypto'

function verifySocifyrWebhook(
  rawBody: string,
  signatureHeader: string,
  secret: string,
  tolerance = 5 * 60 * 1000, // 5 minutes
): boolean {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map(p => p.split('=')) as Array<[string, string]>,
  )

  const timestamp = parseInt(parts.t, 10)
  const signature = parts.v1

  // Reject if timestamp is too old (replay protection)
  if (Math.abs(Date.now() - timestamp * 1000) > tolerance) {
    return false
  }

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex')

  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature),
  )
}

Verification (other languages)

python
import hmac, hashlib, time

def verify(raw_body: bytes, signature: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split('=') for p in signature.split(','))
    timestamp = int(parts['t'])
    if abs(time.time() - timestamp) > tolerance:
        return False
    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.{raw_body.decode()}".encode(),
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, parts['v1'])
go
package main

import (
  "crypto/hmac"
  "crypto/sha256"
  "encoding/hex"
  "strings"
  "strconv"
  "time"
)

func Verify(body, sig, secret string) bool {
  parts := map[string]string{}
  for _, p := range strings.Split(sig, ",") {
    kv := strings.SplitN(p, "=", 2)
    parts[kv[0]] = kv[1]
  }
  ts, _ := strconv.ParseInt(parts["t"], 10, 64)
  if abs(time.Now().Unix()-ts) > 300 {
    return false
  }
  mac := hmac.New(sha256.New, []byte(secret))
  mac.Write([]byte(strconv.FormatInt(ts, 10) + "." + body))
  return hmac.Equal([]byte(parts["v1"]), []byte(hex.EncodeToString(mac.Sum(nil))))
}

func abs(x int64) int64 { if x < 0 { return -x }; return x }
ruby
require 'openssl'

def verify(body, sig, secret, tolerance = 300)
  parts = sig.split(',').map { |p| p.split('=') }.to_h
  ts = parts['t'].to_i
  return false if (Time.now.to_i - ts).abs > tolerance
  expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{ts}.#{body}")
  OpenSSL::Util.respond_to?(:secure_compare) ?
    OpenSSL::Util.secure_compare(expected, parts['v1']) :
    expected == parts['v1']
end

Why use the timestamp?

The timestamp prevents replay attacks — even if an attacker intercepts a valid request, they can't replay it five minutes later because the t value is part of the signed payload.

Common mistakes

  • Parsing JSON before verifying. Always sign / verify the raw body bytes, not a re-serialized JSON string — extra whitespace or different key ordering will break the signature.
  • Ignoring the timestamp. Without timestamp validation, an attacker who replays old requests can repeatedly trigger your handlers.
  • Using == for comparison. Always use a constant-time comparison (timingSafeEqual, hmac.compare_digest, etc.) to prevent timing attacks.