# Verificación de firma de webhook

Esta guía explica cómo Fluxis firma los webhooks salientes y cómo verificar que (1) la solicitud provino de Fluxis y (2) el cuerpo JSON no fue alterado.

Referencia de implementación: `src/pkg/utils/crypto_signature.go` (`GenerateRequestSignature`, `VerifyRequestSignature`, `toCanonicalJSON`).

Relacionado: [Resumen de webhooks](/es/docs/developers/webhooks) · [Guía de integración API](/es/docs/developers/tutorials/api-integration-guide)

::::note Webhooks a nivel de cuenta
Las URLs de webhook y los secretos de firma están vinculados a una **cuenta** (`POST /v1/account/{accountID}/webhook`), no a Puntos de Venta individuales. Los eventos de pago incluyen `point_of_sale_id` en el payload cuando corresponde.
::::

## Qué demuestra la verificación

| Propiedad    | Significado                                                                 |
| ----------- | ----------------------------------------------------------------------- |
| Autenticidad | Solo alguien con tu `secret` de webhook podría producir una firma válida |
| Integridad    | El cuerpo JSON no fue modificado en tránsito                               |

::::warning
Rechaza solicitudes que fallen la verificación. No proceses el cumplimiento de pagos hasta que pasen las comprobaciones de firma y marca de tiempo.
::::

## Requisitos previos

1. **Secreto de webhook** — Devuelto una vez en la respuesta a `POST /v1/account/{accountID}/webhook`. Guárdalo de forma segura; no puede recuperarse de nuevo.
2. **Endpoint HTTPS** — Tu servidor debe aceptar `POST` con `Content-Type: application/json`.
3. **Cuerpo parseado** — Parsea el JSON primero, luego re-canonicaliza para firmar. Nunca firmes bytes crudos de la solicitud.

## Algoritmo

Fluxis usa HMAC-SHA256 sobre una cadena determinista construida a partir de la marca de tiempo y el cuerpo del webhook:

```
signed_string = "<unix_timestamp_seconds>." + canonical_json(body)
signature     = hex( HMAC-SHA256( webhook_secret, signed_string ) )
```

**Encabezados de entrega:**

| Encabezado                | Valor                                      |
| --------------------- | ------------------------------------------ |
| `X-Request-Signature` | HMAC-SHA256 codificado en hexadecimal                    |
| `X-Request-Timestamp` | Segundos Unix (cadena, p. ej. `"1712345678"`) |
| `Content-Type`        | `application/json`                         |

**Validación de marca de tiempo:** Rechaza si `now - timestamp > 300` segundos (`WEBHOOK_MAX_AGE_SECONDS`). Las marcas de tiempo futuras **no** se rechazan en la implementación actual del servidor — puedes agregar comprobaciones más estrictas en tu manejador si lo deseas.

## Objeto firmado

Firma el **cuerpo completo del webhook** como objeto JSON, típicamente:

```json
{
  "data": {
    "notification_type": "payment_request_status_update",
    "payment_request_id": "aa5d6c80-0b0b-4a2a-bbc8-14a41e6245bf",
    "payment_request_type": "fixed",
    "external_reference_id": "order-123",
    "status": "completed",
    "point_of_sale_id": "3cc1f7ca-c017-4e7e-b00d-aca0da6e464e",
    "account_id": "c87464d1-c54a-48c3-80c9-bee4fddf593d"
  }
}
```

**No** firmes solo `data`, y **no** firmes la cadena del cuerpo HTTP crudo antes de parsear.

## Reglas de JSON canónico

Fluxis construye JSON canónico vía `toCanonicalJSON`:

1. Serializa el valor a JSON (las etiquetas de struct y `omitempty` aplican — campos nil/cero se omiten).
2. Deserializa en `map[string]any` / arrays para que objetos anidados se conviertan en maps.
3. Vuelve a serializar — `json.Marshal` de Go en maps produce **JSON compacto** (sin espacios) con claves **ordenadas alfabéticamente en cada nivel de objeto**.

Tu verificador debe reproducir este comportamiento:

| Regla | Detalle |
| ---- | ------ |
| Compacto | Sin espacios: `{"a":1}` no `{ "a": 1 }` |
| Claves ordenadas | En cada nivel de objeto, incluido `data` anidado |
| Tal como se recibió | Campos opcionales ausentes en el payload deben permanecer ausentes en forma canónica |
| No bytes crudos | Middleware que reformatea JSON antes de verificar romperá las firmas |

::::note omitempty
Si `external_reference_id` o `account_id` no está definido, esas claves se **omiten** del JSON que Fluxis firma. Reconstruir el cuerpo desde un esquema fijo que siempre incluye esas claves producirá una firma diferente.
::::

## Lista de verificación

1. Lee `X-Request-Signature` y `X-Request-Timestamp` de los encabezados.
2. Parsea el cuerpo de la solicitud en un objeto genérico (`map` / `dict`).
3. Rechaza si `now - timestamp > 300`.
4. Serializa el objeto parseado a JSON canónico (claves ordenadas, compacto).
5. Construye `signed_string = timestamp + "." + canonical_json`.
6. Calcula `HMAC-SHA256(secret, signed_string)`, codifica en hex y compara con igualdad de tiempo constante (tras verificar longitud).

## Ejemplo trabajado

Usa estos valores fijos para probar tu implementación:

| Entrada | Valor |
| ----- | ----- |
| Secret | `whsec_example_for_documentation_only` |
| Timestamp | `1712345678` |
| Body | Ver JSON abajo |

**Cuerpo (objeto parseado):**

```json
{
  "data": {
    "account_id": "c87464d1-c54a-48c3-80c9-bee4fddf593d",
    "external_reference_id": "order-123",
    "notification_type": "payment_request_status_update",
    "payment_request_id": "aa5d6c80-0b0b-4a2a-bbc8-14a41e6245bf",
    "payment_request_type": "fixed",
    "point_of_sale_id": "3cc1f7ca-c017-4e7e-b00d-aca0da6e464e",
    "status": "completed"
  }
}
```

**JSON canónico** (una línea):

```
{"data":{"account_id":"c87464d1-c54a-48c3-80c9-bee4fddf593d","external_reference_id":"order-123","notification_type":"payment_request_status_update","payment_request_id":"aa5d6c80-0b0b-4a2a-bbc8-14a41e6245bf","payment_request_type":"fixed","point_of_sale_id":"3cc1f7ca-c017-4e7e-b00d-aca0da6e464e","status":"completed"}}
```

**Cadena firmada:**

```
1712345678.{"data":{"account_id":"c87464d1-c54a-48c3-80c9-bee4fddf593d","external_reference_id":"order-123","notification_type":"payment_request_status_update","payment_request_id":"aa5d6c80-0b0b-4a2a-bbc8-14a41e6245bf","payment_request_type":"fixed","point_of_sale_id":"3cc1f7ca-c017-4e7e-b00d-aca0da6e464e","status":"completed"}}
```

**Firma esperada (hex):**

```
174b7c02c84d9112e8013336ea632ae056b1c468387eca1815d3b1c24f5c0c49
```

Si tu implementación produce esta firma para las entradas anteriores, coincide con la firma de producción de Fluxis.

---

## Ejemplos de código

### Node.js / TypeScript

```typescript
import crypto from "crypto";

function sortKeys(value: unknown): unknown {
  if (Array.isArray(value)) return value.map(sortKeys);
  if (value !== null && typeof value === "object") {
    return Object.keys(value as object)
      .sort()
      .reduce((acc, key) => {
        acc[key] = sortKeys((value as Record<string, unknown>)[key]);
        return acc;
      }, {} as Record<string, unknown>);
  }
  return value;
}

export function verifyWebhookSignature(
  payload: unknown,
  signature: string,
  timestamp: string,
  secret: string,
): boolean {
  const MAX_AGE_SECONDS = 300;
  const requestTimestamp = parseInt(timestamp, 10);
  if (Math.floor(Date.now() / 1000) - requestTimestamp > MAX_AGE_SECONDS) {
    return false;
  }

  const canonicalJson = JSON.stringify(sortKeys(payload));
  const signedString = `${timestamp}.${canonicalJson}`;
  const expectedSignature = crypto
    .createHmac("sha256", secret)
    .update(signedString)
    .digest("hex");

  if (signature.length !== expectedSignature.length) return false;
  return crypto.timingSafeEqual(
    Buffer.from(signature, "utf8"),
    Buffer.from(expectedSignature, "utf8"),
  );
}
```

::::warning timingSafeEqual en Node.js
`crypto.timingSafeEqual` lanza error si las longitudes de buffer difieren. Compara longitudes primero (como arriba), o usa `hmac.compare_digest` en Python.
::::

### Python

```python
import hmac
import hashlib
import json
import time

def verify_webhook_signature(
    payload: dict,
    signature: str,
    timestamp: str,
    secret: str,
) -> bool:
    MAX_AGE_SECONDS = 300
    request_timestamp = int(timestamp)
    if int(time.time()) - request_timestamp > MAX_AGE_SECONDS:
        return False

    canonical_json = json.dumps(payload, sort_keys=True, separators=(",", ":"))
    signed_string = f"{timestamp}.{canonical_json}"
    expected_signature = hmac.new(
        secret.encode("utf-8"),
        signed_string.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(signature, expected_signature)
```

### Go

Coincide con `toCanonicalJSON` y `VerifyRequestSignature` de producción:

```go
func canonicalJSON(v any) ([]byte, error) {
    raw, err := json.Marshal(v)
    if err != nil {
        return nil, err
    }
    var generic any
    if err := json.Unmarshal(raw, &generic); err != nil {
        return nil, err
    }
    return json.Marshal(generic)
}

func verifyWebhookSignature(
    payload map[string]any,
    signature, timestamp, secret string,
) (bool, error) {
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return false, err
    }
    if time.Now().Unix()-ts > 300 {
        return false, fmt.Errorf("timestamp too old")
    }

    jsonBytes, err := canonicalJSON(payload)
    if err != nil {
        return false, err
    }
    signedString := fmt.Sprintf("%s.%s", timestamp, jsonBytes)

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(signedString))
    expected := hex.EncodeToString(mac.Sum(nil))

    return hmac.Equal([]byte(signature), []byte(expected)), nil
}
```

---

## Probar tu manejador

1. Registra un webhook: `POST /v1/account/{accountID}/webhook` con tu URL pública y `event_type: "payment_request"`.
2. Guarda el `secret` de la respuesta de creación.
3. Llama **`POST /v1/account/{accountID}/webhook/{webhookID}/test`** — Fluxis hace POST de un payload de muestra firmado a tu URL usando la misma ruta de código que producción (`GenerateRequestSignature` en `{ "data": … }`).
4. Verifica la firma en tu manejador y devuelve HTTP `200`.
5. Opcionalmente compara con el cálculo manual usando el [ejemplo trabajado](#ejemplo-trabajado) arriba.

Consulta los intentos de entrega en **`GET /v1/account/{accountID}/webhook/logs?page=1&page_size=20`**.

---

## Fallos comunes

| Síntoma | Causa probable |
| ------- | ------------ |
| Firma siempre inválida | Secreto incorrecto (no el de la creación); firmar bytes crudos en lugar de JSON parseado |
| Inválida intermitente | Campos opcionales agregados/eliminados vs lo firmado; middleware mutando `req.body` |
| Firma válida, rechazada igual | Desfase de reloj mayor a 300 s; usar marca de tiempo incorrecta (debe coincidir con el encabezado `X-Request-Timestamp`) |
| El manejador Node falla al comparar | `timingSafeEqual` llamado con cadenas hex de longitud distinta — agrega verificación de longitud primero |
| Desajuste en Go con structs anidados | Firmar struct sin round-trip por `any` — usa el patrón `canonicalJSON` arriba |
| La prueba funciona, producción falla | Payload de producción sin campos que tu vector de prueba siempre incluye |

## Notas de seguridad

- Usa comparación de **tiempo constante** (`hmac.Equal`, `hmac.compare_digest`, `timingSafeEqual` con buffers de igual longitud).
- **Rechaza marcas de tiempo obsoletas** (mayores a 300 segundos).
- **Procesa la lógica de negocio solo después** de que la verificación de firma tenga éxito.
- Guarda el secreto de webhook en un gestor de secretos, no en el control de versiones.
- Usa HTTPS para tu endpoint; trata el secreto como una credencial API.

---

## ¿Necesitas ayuda?

Contacta [support@fluxis.us](mailto:support@fluxis.us) con una copia redactada del cuerpo recibido, encabezados (omite el secreto) y tu JSON canónico calculado si la verificación falla.
