Docs/Webhooks

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:

  1. Repull computes HMAC-SHA256(signing_secret, raw_request_body)
  2. The hex-encoded result is sent in the X-Repull-Signature header
  3. 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 "", 200

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