Verify Webhook Signatures
TL;DR
Verify the X-Repull-Signature header using HMAC-SHA256 with your webhook signing secret. Always use the raw request body, not parsed JSON.
Every webhook delivery includes an X-Repull-Signature header containing an HMAC-SHA256 signature. Always verify this signature to confirm the request came from Repull and has not been tampered with.
How Signing Works
When you create a webhook subscription, Repull generates a unique signing secret (whsec_...). On each delivery:
- Repull computes
HMAC-SHA256(signing_secret, raw_request_body) - The hex-encoded result is sent in the
X-Repull-Signatureheader - Your server recomputes the HMAC using the same secret and compares it
Use the raw body
You must verify against the raw request body, not a parsed/re-serialized version. JSON parsing and re-stringifying can change key order or whitespace, which will invalidate the signature.
Node.js (Express)
import crypto from 'crypto'
import express from 'express'
const app = express()
// IMPORTANT: Use raw body for signature verification
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-repull-signature']
const signingSecret = process.env.REPULL_WEBHOOK_SECRET // whsec_...
const expectedSignature = crypto
.createHmac('sha256', signingSecret)
.update(req.body) // raw Buffer
.digest('hex')
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
)) {
return res.status(401).json({ error: 'Invalid signature' })
}
const event = JSON.parse(req.body.toString())
console.log('Verified event:', event.type)
// Process the event...
res.sendStatus(200)
})Python (Flask)
import hmac
import hashlib
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = "whsec_your_signing_secret"
@app.route("/webhooks", methods=["POST"])
def handle_webhook():
signature = request.headers.get("X-Repull-Signature")
if not signature:
abort(401)
expected = hmac.new(
WEBHOOK_SECRET.encode(),
request.get_data(), # raw bytes
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
event = request.get_json()
print(f"Verified event: {event['type']}")
# Process the event...
return "", 200Using the SDK
The TypeScript SDK includes a built-in verification helper:
import { verifyWebhookSignature } from '@repull/sdk'
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const isValid = verifyWebhookSignature(
req.body,
req.headers['x-repull-signature'],
process.env.REPULL_WEBHOOK_SECRET
)
if (!isValid) return res.status(401).send('Invalid signature')
const event = JSON.parse(req.body.toString())
// Process event...
res.sendStatus(200)
})On Verification Failure
If signature verification fails:
- Return a
401status code immediately. Do not process the event. - Log the failure for monitoring. Repeated failures may indicate a misconfigured secret or a man-in-the-middle attempt.
- Check that you are using the correct signing secret for this webhook subscription. Each subscription has its own secret.
- Verify you are reading the raw request body, not a parsed JSON object.
Timing-safe comparison
Always use a constant-time comparison function (
crypto.timingSafeEqual in Node, hmac.compare_digest in Python) to prevent timing attacks.AI