← Webhooks

Firma y verificación

Cada entrega de webhook incluye un encabezado X-Watsi-Signature. watsi calcula el valor aplicando un hash al cuerpo de la solicitud sin procesar con HMAC-SHA256 y el secreto de su suscripción.

Formato del encabezado

X-Watsi-Signature: 8a9f0d1c...

Trate el valor del encabezado como un digest codificado en hexadecimal. Para verificarlo, vuelva a calcular el mismo HMAC de su lado y compare los dos digests usando una comparación en tiempo constante.

Algoritmo de verificación

  1. Lea los bytes del cuerpo de la solicitud sin procesar exactamente como se recibieron.
  2. Lea X-Watsi-Signature de los encabezados de la solicitud.
  3. Calcule HMAC-SHA256(secret, rawBody).
  4. Codifique en hexadecimal el digest calculado.
  5. Compare el digest calculado y el valor del encabezado en tiempo constante.
  6. Rechace la solicitud con un 401 si los valores no coinciden.

Ejemplo en Node.js

import crypto from 'crypto'
import express from 'express'

const app = express()
app.use(
  express.json({
    verify: (req, _res, buf) => {
      req.rawBody = buf
    },
  })
)

function verifySignature(req) {
  const signature = req.headers['x-watsi-signature']
  if (!signature || !Buffer.isBuffer(req.rawBody)) return false

  const computed = crypto
    .createHmac('sha256', process.env.WATSI_WEBHOOK_SECRET ?? '')
    .update(req.rawBody)
    .digest('hex')

  const expectedBuf = Buffer.from(String(signature), 'utf8')
  const computedBuf = Buffer.from(computed, 'utf8')
  if (expectedBuf.length !== computedBuf.length) return false
  return crypto.timingSafeEqual(expectedBuf, computedBuf)
}

app.post('/webhooks/watsi', (req, res) => {
  if (!verifySignature(req)) {
    return res.status(401).json({ error: 'Invalid signature' })
  }

  console.log('Verified event', req.body.type)
  res.status(200).json({ received: true })
})

Ejemplo en Python

import hashlib
import hmac
import os
from flask import Flask, request, jsonify

app = Flask(__name__)


def verify_signature(req):
    signature = req.headers.get('X-Watsi-Signature')
    if not signature:
        return False

    raw_body = req.get_data()
    computed = hmac.new(
        os.environ['WATSI_WEBHOOK_SECRET'].encode(),
        raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(signature, computed)


@app.post('/webhooks/watsi')
def handle_webhook():
    if not verify_signature(request):
        return jsonify({'error': 'Invalid signature'}), 401

    event = request.get_json(force=True)
    print('Verified event', event['type'])
    return jsonify({'received': True}), 200

Ejemplo en Ruby

require 'openssl'
require 'rack/utils'

post '/webhooks/watsi' do
  raw_body = request.body.read
  signature = request.env['HTTP_X_WATSI_SIGNATURE']
  secret = ENV.fetch('WATSI_WEBHOOK_SECRET')
  computed = OpenSSL::HMAC.hexdigest('sha256', secret, raw_body)

  halt 401, 'Invalid signature' unless signature &&
    signature.bytesize == computed.bytesize &&
    Rack::Utils.secure_compare(signature, computed)

  status 200
  body 'OK'
end

Ejemplo en Go

func verifySignature(rawBody []byte, signature string) bool {
    mac := hmac.New(sha256.New, []byte(os.Getenv("WATSI_WEBHOOK_SECRET")))
    mac.Write(rawBody)
    computed := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(signature), []byte(computed))
}

Ejemplo en PHP

$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WATSI_SIGNATURE'] ?? '';
$computed = hash_hmac('sha256', $rawBody, getenv('WATSI_WEBHOOK_SECRET'));

if (!hash_equals($computed, $signature)) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid signature']);
    exit;
}

Consejos operativos

PrácticaPor qué ayuda
Use el cuerpo de la solicitud sin procesarCalcule el hash de los bytes exactos que llegaron por HTTP, antes de cualquier análisis JSON o reserialización.
Compare en tiempo constanteUse funciones de igualdad seguras frente a temporización para evitar filtrar información a través del tiempo de comparación de cadenas.
Almacene los secretos fuera del códigoMantenga su secreto de firma en una variable de entorno o en un gestor de secretos, nunca en el repositorio.
Registre el id de entregaPersista X-Watsi-Delivery-Id o event.id para que los reintentos puedan correlacionarse de forma limpia.