> ## Documentation Index
> Fetch the complete documentation index at: https://help.ciarem.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Verificar firmas

> Comprueba que una solicitud de webhook viene de verdad de Ciarem y no fue alterada, con código que puedes copiar.

La URL de tu webhook es pública, así que cualquiera que la conozca puede enviarle solicitudes. Ciarem firma cada solicitud con el **secreto de firma** de tu endpoint. Comprobar esa firma es la forma en que tu servidor distingue un evento real de Ciarem de uno falsificado. **Verifica antes de confiar en el cuerpo.**

## El secreto de firma

Cada endpoint tiene su propio secreto. Empieza con `whsec_`.

* **En el panel:** se muestra cuando agregas el endpoint, y de nuevo en cualquier momento en la página del endpoint con **Mostrar secreto**. Solo los administradores pueden verlo.
* **Con la API:** se devuelve una sola vez, en la respuesta de `POST /v1/webhook-endpoints`.

Trátalo como una contraseña: guárdalo en tu gestor de secretos o en una variable de entorno, nunca en el código ni en un navegador.

## El encabezado de firma

Cada solicitud lleva un encabezado `Ciarem-Signature`:

```
Ciarem-Signature: t=1760022245,v1=5f8a0c…c2e1
```

| Parte | |
| - | - |
| `t` | Cuándo envió Ciarem esta solicitud, como marca de tiempo Unix en segundos. |
| `v1` | La firma: un HMAC-SHA256, escrito en hexadecimal en minúsculas. |

La firma se calcula sobre la marca de tiempo, un punto y el **cuerpo sin procesar de la solicitud**, usando tu secreto de firma como clave:

```
v1 = HMAC_SHA256(key = signing secret, message = t + "." + raw body)
```

## Cómo verificar

**1. Lee el cuerpo sin procesar**

Toma el cuerpo de la solicitud **exactamente como llegaron los bytes**, antes de parsear el JSON. Parsear y volver a serializar cambia los espacios y el escapado de caracteres, y la firma ya no coincidirá.

**2. Separa el encabezado**

Separa `Ciarem-Signature` por `,` y luego por `=` para obtener `t` y `v1`.

**3. Calcula la firma esperada**

Arma el mensaje `t + "." + raw body` y calcula su HMAC-SHA256 con tu secreto de firma. Usa el secreto completo, incluido el prefijo `whsec_`, tal como está.

**4. Compara de forma segura**

Compara tu resultado con `v1` usando una función de comparación en tiempo constante, no `==`.

**5. Revisa la marca de tiempo**

Rechaza la solicitud si `t` está a más de **5 minutos** del reloj de tu servidor. Esto evita que alguien capture una solicitud real y la reenvíe más tarde.

Si algún paso falla, responde `400` y no hagas nada con el cuerpo.

## Código que puedes copiar

```javascript Node.js (Express) theme={null}
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.CIAREM_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 300;

function verify(rawBody, header) {
  const parts = Object.fromEntries(
    (header ?? "").split(",").map((p) => p.split("=", 2)),
  );
  const timestamp = Number(parts.t);
  if (!timestamp || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw mantiene el cuerpo como bytes. No uses express.json() en esta ruta.
app.post("/hooks/ciarem", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body, req.get("ciarem-signature"))) {
    return res.status(400).send("invalid signature");
  }
  const event = JSON.parse(req.body);
  // Pon el evento en una cola para procesarlo y responde de inmediato.
  res.sendStatus(200);
});

app.listen(3000);
```

```python Python (FastAPI) theme={null}
import hashlib
import hmac
import json
import os
import time

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()
SECRET = os.environ["CIAREM_WEBHOOK_SECRET"]  # whsec_…
TOLERANCE_SECONDS = 300


def verify(raw_body: bytes, header: str | None) -> bool:
    parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
    try:
        timestamp = int(parts["t"])
        signature = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(
        SECRET.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)


@app.post("/hooks/ciarem")
async def ciarem_webhook(request: Request):
    raw_body = await request.body()  # los bytes, no request.json()
    if not verify(raw_body, request.headers.get("ciarem-signature")):
        raise HTTPException(status_code=400, detail="invalid signature")
    event = json.loads(raw_body)
    # Pon el evento en una cola para procesarlo y responde de inmediato.
    return {"ok": True}
```

```php PHP theme={null}
<?php
$secret = getenv('CIAREM_WEBHOOK_SECRET'); // whsec_…
$tolerance = 300;

$rawBody = file_get_contents('php://input'); // los bytes, antes de json_decode
$header = $_SERVER['HTTP_CIAREM_SIGNATURE'] ?? '';

$parts = [];
foreach (explode(',', $header) as $pair) {
    [$key, $value] = array_pad(explode('=', $pair, 2), 2, '');
    $parts[$key] = $value;
}
$timestamp = (int) ($parts['t'] ?? 0);
$signature = $parts['v1'] ?? '';

$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

if (
    $timestamp === 0
    || abs(time() - $timestamp) > $tolerance
    || !hash_equals($expected, $signature)
) {
    http_response_code(400);
    exit('invalid signature');
}

$event = json_decode($rawBody, true);
// Pon el evento en una cola para procesarlo y responde de inmediato.
http_response_code(200);
```

```ruby Ruby (Sinatra) theme={null}
require "json"
require "openssl"
require "sinatra"

SECRET = ENV.fetch("CIAREM_WEBHOOK_SECRET") # whsec_…
TOLERANCE_SECONDS = 300

def verify(raw_body, header)
  parts = header.to_s.scan(/(\w+)=([^,]+)/).to_h
  timestamp = parts["t"].to_i
  signature = parts["v1"].to_s
  return false if timestamp.zero? || signature.empty?
  return false if (Time.now.to_i - timestamp).abs > TOLERANCE_SECONDS

  expected = OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{timestamp}.#{raw_body}")
  Rack::Utils.secure_compare(expected, signature)
end

post "/hooks/ciarem" do
  raw_body = request.body.read # los bytes, antes de JSON.parse
  halt 400, "invalid signature" unless verify(raw_body, request.env["HTTP_CIAREM_SIGNATURE"])
  event = JSON.parse(raw_body)
  # Pon el evento en una cola para procesarlo y responde de inmediato.
  status 200
end
```

## Prueba tu código con un ejemplo conocido

Usa estos valores para confirmar que tu código de verificación calcula la firma correcta, sin esperar un evento real. Sáltate la revisión de la marca de tiempo mientras ejecutas esto, porque la marca de tiempo del ejemplo está en el pasado.

| | |
| - | - |
| Secreto de firma | `whsec_test` |
| Marca de tiempo `t` | `1760000000` |
| Cuerpo sin procesar | `{"id":"evt_test_sample00000000000","type":"test","api_version":"v1","created_at":"2026-01-15T14:30:00Z","data":{}}` |
| `v1` esperado | `057e6f478035e5f43cc6c85e722bb8871eff8baa2852afdf23a52ef6a7d739c3` |

Puedes producir el mismo valor desde una terminal:

```bash theme={null}
printf '%s' '1760000000.{"id":"evt_test_sample00000000000","type":"test","api_version":"v1","created_at":"2026-01-15T14:30:00Z","data":{}}' \
  | openssl dgst -sha256 -hmac 'whsec_test'
```

## Si la firma no coincide

| Qué ves | Causa probable |
| - | - |
| La firma nunca coincide | Estás calculando el hash de un cuerpo vuelto a serializar. Calcula el hash de los bytes tal como llegaron. En Express, un `express.json()` global se ejecuta antes de tu ruta y reemplaza el cuerpo; monta `express.raw()` en la ruta del webhook en su lugar. |
| Coincide en algunos eventos y en otros no | El cuerpo contiene caracteres acentuados o emoji y tu framework cambió su codificación. Ciarem envía el cuerpo en UTF-8 con esos caracteres escritos tal cual, no como escapes `\u`. De nuevo, calcula el hash de los bytes sin procesar. |
| Nunca coincide, y calculas el hash del cuerpo sin procesar | El secreto equivocado. Cada endpoint tiene el suyo, y debe incluir el prefijo `whsec_`. Cópialo de nuevo desde la página del endpoint. |
| Dejó de coincidir después de que volviste a crear el endpoint | Un endpoint nuevo recibe un secreto nuevo. Actualiza tu servidor. |
| Las solicitudes reales se rechazan por ser demasiado antiguas | El reloj de tu servidor está desfasado. Sincronízalo con NTP, o amplía un poco la tolerancia. |

## Buenas prácticas

* **Un secreto por endpoint.** Si tienes servidores de staging y de producción, dale a cada uno su propio endpoint, para que un secreto de staging filtrado no pueda falsificar eventos de producción.
* **Rota reemplazando el endpoint.** Un secreto no se puede cambiar en el mismo endpoint. Para rotar, agrega un endpoint nuevo con la misma URL y los mismos eventos, actualiza tu servidor para que acepte el secreto nuevo y luego elimina el endpoint antiguo. Mientras existan los dos, recibes cada evento dos veces, una por endpoint, con el mismo `id` de evento, así que [deduplicar por `id`](https://help.ciarem.ai/es/webhooks/deliveries#duplicados-y-orden) hace que esto no cause problemas.
* **Cada reintento se firma de nuevo.** Una entrega reintentada tiene el mismo cuerpo y el mismo `id` de evento, pero un `t` nuevo y una firma nueva, así que la revisión de 5 minutos también funciona para los reintentos.
* **La verificación prueba el origen, no la unicidad.** Un evento verificado todavía puede ser un duplicado. Deduplica también por `id`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.