Skip to content

Every webhook delivery includes an HMAC-SHA256 signature in the X-Webhook-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-Webhook-Signature: sha256=4f9a8b2c...

The signature is sha256=<hex> — an HMAC-SHA256 of the raw request body using the webhook's secret (the same GitHub-style format used by Svix and others, so off-the-shelf verification examples work). There is no timestamp component in the signature itself; instead, the payload carries timestamp and a unique deliveryId you can use for replay-window checks.

Every delivery also includes convenience headers:

HeaderContent
X-Webhook-Signaturesha256=<hex> — HMAC-SHA256 of the raw body
X-Webhook-EventEvent name, e.g. upload.completed
X-Webhook-DeliveryUnique delivery attempt ID

Verification (Node.js) ​

If you use the SDK, this is one call — verifyWebhookSignature does a constant-time comparison and handles the sha256= prefix:

typescript
import { verifyWebhookSignature, parseWebhookEvent } from '@socifyr/sdk'
import express from 'express'

const app = express()

// IMPORTANT: capture the raw body before any JSON parsing middleware
app.post(
  '/webhooks/socifyr',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const isValid = verifyWebhookSignature(
      req.body,                                     // raw Buffer or string
      req.headers['x-webhook-signature'] as string, // "sha256=<hex>"
      process.env.WEBHOOK_SECRET!,                 // from webhook.secret
    )
    if (!isValid) return res.status(401).send('Invalid signature')

    const event = parseWebhookEvent(req.body)
    // event.event, event.payload, event.timestamp, event.deliveryId
    res.sendStatus(200)
  },
)

Without the SDK:

typescript
import crypto from 'crypto'

function verifySocifyrWebhook(
  rawBody: string,
  signatureHeader: string, // "sha256=<hex>"
  secret: string,
): boolean {
  if (!signatureHeader.startsWith('sha256=')) return false

  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody) // the body only — no timestamp prefix
    .digest('hex')

  try {
    return crypto.timingSafeEqual(
      Buffer.from(expected, 'hex'),
      Buffer.from(signatureHeader.slice(7), 'hex'),
    )
  } catch {
    return false // malformed hex in the header
  }
}

Verification (other languages) ​

python
import hmac, hashlib

def verify(raw_body: bytes, signature: str, secret: str) -> bool:
    if not signature.startswith("sha256="):
        return False
    expected = hmac.new(
        secret.encode(),
        raw_body,  # the body only — no timestamp prefix
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature[7:])
go
package main

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

func Verify(body, sig, secret string) bool {
  if !strings.HasPrefix(sig, "sha256=") {
    return false
  }
  mac := hmac.New(sha256.New, []byte(secret))
  mac.Write([]byte(body))
  expected := hex.EncodeToString(mac.Sum(nil))
  return hmac.Equal([]byte(expected), []byte(sig[7:]))
}
ruby
require 'openssl'

def verify(body, sig, secret)
  return false unless sig.start_with?('sha256=')
  expected = OpenSSL::HMAC.hexdigest('SHA256', secret, body)
  OpenSSL::Util.respond_to?(:secure_compare) ?
    OpenSSL::Util.secure_compare(expected, sig[7..]) :
    expected == sig[7..]
end

Replay protection (optional) ​

The signature doesn't include a timestamp, so strictly by signature alone an intercepted request could be replayed. To harden against that, use the fields inside the signed payload:

typescript
const event = parseWebhookEvent(rawBody) // only after signature verification

// 1. Window check: timestamp is the delivery's creation time and is identical
//    across all retry attempts, so retries never fall outside your window
if (Date.now() - new Date(event.timestamp).getTime() > 5 * 60 * 1000) {
  return res.status(200).send('Stale delivery ignored')
}

// 2. Dedup: each attempt has a unique deliveryId — remember the ones you've
//    processed (e.g. in Redis with a TTL) and skip repeats
if (await seen(event.deliveryId)) return res.sendStatus(200)

Common mistakes ​

  • Parsing JSON before verifying. Always verify the raw body bytes — re-serializing JSON changes whitespace and key order and breaks the signature.
  • HMAC'ing a timestamp prefix. Unlike Stripe's t=,v1= scheme, Socifyr signs the body alone. The timestamp lives inside the payload, not in the signature input.
  • Using == for comparison. Always use a constant-time comparison (timingSafeEqual, hmac.compare_digest, hmac.Equal) to prevent timing attacks.
  • Forgetting the sha256= prefix when extracting the hex digest before comparing.