Verify Webhook Signatures
TL;DR
The X-Repull-Signature header looks like t=1717243200,v1=5257a8.... Recompute HMAC-SHA256(secret, `${t}.${raw_body}`), compare it to v1 in constant time, and reject deliveries where t is older than 5 minutes.
Every webhook delivery is signed with your subscription's signing secret (whsec_...). Verify the signature before you trust the payload — it proves the request came from Repull and was not tampered with in transit. Repull uses a timestamped, Stripe-style signature so you can also reject replayed deliveries.
Delivery Headers
Every delivery carries these headers:
| Header | Description |
|---|---|
X-Repull-Signature | t=<unix_ts>,v1=<hex> — the timestamp and HMAC to verify (details below). |
X-Repull-Event-Id | Stable UUID for the logical event. Dedupe on this — it stays constant across retries and replays. |
X-Repull-Delivery-Id | UUID unique to this HTTP attempt. Changes on every retry and replay — useful for delivery-level logging, not for dedupe. |
X-Repull-Attempt | 1-based attempt counter for this delivery. |
How Signing Works
When you create a webhook subscription, Repull generates a unique signing secret (whsec_...). On each delivery Repull:
- Takes the current Unix timestamp
t(seconds). - Builds the signed payload string
`${t}.${raw_body}`— the timestamp, a literal., then the exact raw request body. - Computes
v1 = HMAC-SHA256(signing_secret, signed_payload), hex-encoded. - Sends
X-Repull-Signature: t=<t>,v1=<v1>.
To verify, split the header on ,, read t and v1, recompute the HMAC over `${t}.${raw_body}`, and compare it to v1 in constant time.
Use the raw body
Reject stale timestamps
t against your server clock and reject deliveries older than about 5 minutes. This is what stops an attacker from capturing a valid delivery and replaying it later. Keep some tolerance for clock skew.Node.js (Express)
import crypto from 'crypto'
import express from 'express'
const app = express()
const TOLERANCE_SECONDS = 5 * 60 // reject deliveries older than 5 minutes
function verifyRepullSignature(rawBody, header, secret) {
// header: "t=1717243200,v1=5257a8..."
const parts = Object.fromEntries(
String(header || '')
.split(',')
.map((kv) => kv.split('=').map((s) => s.trim()))
)
const t = parts.t
const v1 = parts.v1
if (!t || !v1) return false
// Replay protection: reject if the timestamp is too old (or in the future).
const age = Math.floor(Date.now() / 1000) - Number(t)
if (!Number.isFinite(age) || Math.abs(age) > TOLERANCE_SECONDS) return false
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`) // signed payload: "<t>.<raw_body>"
.digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(v1)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
// IMPORTANT: use the raw body — do not let a JSON parser touch it first.
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8')
const ok = verifyRepullSignature(
raw,
req.headers['x-repull-signature'],
process.env.REPULL_WEBHOOK_SECRET // whsec_...
)
if (!ok) return res.status(401).json({ error: 'Invalid signature' })
const eventId = req.headers['x-repull-event-id'] // dedupe on this
const event = JSON.parse(raw)
console.log('Verified event:', event.type, eventId)
// Process the event...
res.sendStatus(200)
})Python (Flask)
import hmac
import hashlib
import time
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = "whsec_your_signing_secret"
TOLERANCE_SECONDS = 5 * 60 # reject deliveries older than 5 minutes
def verify_repull_signature(raw_body: bytes, header: str, secret: str) -> bool:
# header: "t=1717243200,v1=5257a8..."
parts = dict(
kv.strip().split("=", 1) for kv in (header or "").split(",") if "=" in kv
)
t = parts.get("t")
v1 = parts.get("v1")
if not t or not v1:
return False
# Replay protection: reject timestamps that are too old (or in the future).
try:
age = int(time.time()) - int(t)
except ValueError:
return False
if abs(age) > TOLERANCE_SECONDS:
return False
signed_payload = f"{t}.".encode() + raw_body # "<t>.<raw_body>"
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
@app.route("/webhooks", methods=["POST"])
def handle_webhook():
if not verify_repull_signature(
request.get_data(), # raw bytes
request.headers.get("X-Repull-Signature", ""),
WEBHOOK_SECRET,
):
abort(401)
event_id = request.headers.get("X-Repull-Event-Id") # dedupe on this
event = request.get_json()
print(f"Verified event: {event['type']} {event_id}")
# Process the event...
return "", 200Using the SDK
The TypeScript SDK includes a built-in verification helper that parses the header, checks the timestamp tolerance, and does the constant-time compare for you:
import { verifyWebhookSignature } from '@repull/sdk'
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const isValid = verifyWebhookSignature(
req.body, // raw Buffer
req.headers['x-repull-signature'],
process.env.REPULL_WEBHOOK_SECRET,
{ toleranceSeconds: 300 }
)
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, a clock-skew problem, or a man-in-the-middle attempt.
- Check that you are using the correct signing secret for this subscription. Each subscription has its own secret, and rotating the secret changes it.
- Verify you are reading the raw request body, not a parsed JSON object.
- If only some deliveries fail, confirm your server clock is accurate — a large skew will trip the timestamp tolerance.
Timing-safe comparison
crypto.timingSafeEqual in Node, hmac.compare_digest in Python) to prevent timing attacks. A plain === comparison leaks how many leading bytes matched.