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:

HeaderDescription
X-Repull-Signaturet=<unix_ts>,v1=<hex> — the timestamp and HMAC to verify (details below).
X-Repull-Event-IdStable UUID for the logical event. Dedupe on this — it stays constant across retries and replays.
X-Repull-Delivery-IdUUID unique to this HTTP attempt. Changes on every retry and replay — useful for delivery-level logging, not for dedupe.
X-Repull-Attempt1-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:

  1. Takes the current Unix timestamp t (seconds).
  2. Builds the signed payload string `${t}.${raw_body}` — the timestamp, a literal ., then the exact raw request body.
  3. Computes v1 = HMAC-SHA256(signing_secret, signed_payload), hex-encoded.
  4. 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

You must sign the raw request body exactly as received — not a parsed and re-serialized version. JSON parsing and re-stringifying can change key order or whitespace, which will invalidate the signature.

Reject stale timestamps

Compare 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 "", 200

Using 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 401 status 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

Always compare the HMAC with a constant-time function (crypto.timingSafeEqual in Node, hmac.compare_digest in Python) to prevent timing attacks. A plain === comparison leaks how many leading bytes matched.
AI