Every webhook delivery includes an HMAC-SHA256 signature in the X-Webhook-Signature header. Verifying it ensures:
- The request actually came from Socifyr (not a spoofer)
- The body wasn't tampered with in transit
The header
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:
| Header | Content |
|---|---|
X-Webhook-Signature | sha256=<hex> — HMAC-SHA256 of the raw body |
X-Webhook-Event | Event name, e.g. upload.completed |
X-Webhook-Delivery | Unique 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:
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:
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)
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:])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:]))
}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..]
endReplay 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:
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.