# 13 · Otros lenguajes (PHP, C#, Java y cualquiera)

La API es HTTP + JSON normal; cualquier lenguaje con cliente HTTP sirve. Solo hay **dos cosas** que implementar a mano:

1. El envío con `Authorization: Bearer` e `Idempotency-Key` (y reintentos de `429/502/503`).
2. La **verificación de la firma** de los eventos.

> **Qué está verificado y qué no.** Los ejemplos de [Node](ejemplos/node), [Python](ejemplos/python) y [Ruby](ejemplos/ruby) se ejecutan contra el sistema real con `pnpm docs:verify`. **Los fragmentos de PHP, C# y Java de esta página NO se han compilado ni ejecutado** (esos entornos no estaban disponibles al escribirlos). Están escritos con la API estándar de cada lenguaje; **validad el verificador de firma con el [vector de prueba](ejemplos/vectores/firma-evento.json) antes de usarlo** (abajo se explica cómo).

## Firma de eventos: el algoritmo

Cabecera `X-Wa-Signature: t=<unix>,v1=<hex>`.

1. Tomar el **cuerpo crudo** exactamente como llegó (bytes, sin reserializar).
2. Calcular `HMAC-SHA256(secreto, "<t>." + cuerpo)` y pasarlo a **hexadecimal en minúsculas**.
3. Comparar con `v1` en **tiempo constante**.
4. Rechazar si `|ahora − t| > 300 s`.
5. Deduplicar por el `id` del evento.

Hoy la cabecera lleva un único `v1=`; los verificadores de abajo aceptan varios por si el formato admite una rotación de secretos en el futuro.

### Validar vuestra implementación con el vector

[`ejemplos/vectores/firma-evento.json`](ejemplos/vectores/firma-evento.json) trae un secreto, un cuerpo, la cabecera y varios casos con el resultado esperado. Vuestra función debe, con `verify_at` como «ahora»:

| Caso | Resultado esperado |
| --- | --- |
| `válido` | aceptar |
| cuerpo con un espacio de más | rechazar (firma inválida) |
| secreto equivocado | rechazar (firma inválida) |
| marca de tiempo antigua | rechazar (fuera de tolerancia) |
| cabecera mal formada | rechazar |

Si pasáis los cinco, la implementación es correcta. Para comprobar solo el HMAC desde una terminal:

```bash
printf '%s' "1760262067.$(jq -r .body docs/ejemplos/vectores/firma-evento.json)" \
  | openssl dgst -sha256 -hmac 'whs_vector_secret_0001'
# debe dar f8d434cdcdd7e091907823aa365aca20d7f1f5da594e4731cda0fbc2b3c8c972
```

## PHP (sin ejecutar)

```php
<?php
// Verificación de firma. Pasad el cuerpo CRUDO: file_get_contents('php://input').
function verificar_evento(string $cuerpo, string $cabecera, string $secreto, int $tolerancia = 300, ?int $ahora = null): array {
    $ahora ??= time();
    $t = null; $firmas = [];
    foreach (explode(',', $cabecera) as $parte) {
        [$k, $v] = array_pad(explode('=', trim($parte), 2), 2, '');
        if ($k === 't') $t = (int)$v;
        if ($k === 'v1') $firmas[] = $v;
    }
    if (!$t || !$firmas) throw new RuntimeException('Firma mal formada');
    if (abs($ahora - $t) > $tolerancia) throw new RuntimeException('Marca de tiempo fuera de tolerancia');
    $esperada = hash_hmac('sha256', $t . '.' . $cuerpo, $secreto);   // hex en minúsculas
    foreach ($firmas as $f) {
        if (hash_equals($esperada, $f)) return json_decode($cuerpo, true, 512, JSON_THROW_ON_ERROR);
    }
    throw new RuntimeException('Firma inválida');
}

// Receptor
$cuerpo = file_get_contents('php://input');
try {
    $evento = verificar_evento($cuerpo, $_SERVER['HTTP_X_WA_SIGNATURE'] ?? '', getenv('WA_WEBHOOK_SECRET'));
} catch (Throwable $e) { http_response_code(400); exit; }
// deduplicar por $evento['id'], encolar, responder rápido
http_response_code(204);
```

```php
<?php
// Envío de una plantilla con cURL
function enviar_plantilla(string $to): array {
    $clave = bin2hex(random_bytes(16));            // la MISMA en todos los reintentos
    $ch = curl_init(getenv('WA_BASE_URL') . '/v1/messages');
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . getenv('WA_API_KEY'),
            'Content-Type: application/json',
            'Idempotency-Key: ' . $clave,
        ],
        CURLOPT_POSTFIELDS => json_encode([
            'to' => $to, 'type' => 'template',
            'template' => ['name' => 'recordatorio_cita', 'language' => 'es', 'variables' => ['Ana', '15 de octubre']],
            'external_ref' => 'cita-48213',
        ]),
    ]);
    $resp = curl_exec($ch);
    $estado = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    $json = json_decode($resp, true);
    if ($estado >= 400) throw new RuntimeException(($json['error']['code'] ?? 'error') . ': ' . ($json['error']['message'] ?? $estado));
    return $json;   // status = "queued"
}
```

(Añadid vuestro bucle de reintentos para `429/502/503` reutilizando `$clave`.)

## C# / .NET (sin ejecutar)

```csharp
using System.Security.Cryptography;
using System.Text;

public static class WaFirma
{
    // cuerpo: los bytes/cadena CRUDOS recibidos (en ASP.NET Core, leed Request.Body sin deserializar).
    public static void Verificar(string cuerpo, string cabecera, string secreto, int toleranciaSeg = 300, long? ahora = null)
    {
        long now = ahora ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        long? t = null; var firmas = new List<string>();
        foreach (var parte in cabecera.Split(','))
        {
            var kv = parte.Trim().Split('=', 2);
            if (kv.Length != 2) continue;
            if (kv[0] == "t" && long.TryParse(kv[1], out var tt)) t = tt;
            if (kv[0] == "v1") firmas.Add(kv[1]);
        }
        if (t is null || firmas.Count == 0) throw new InvalidOperationException("Firma mal formada");
        if (Math.Abs(now - t.Value) > toleranciaSeg) throw new InvalidOperationException("Marca de tiempo fuera de tolerancia");

        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secreto));
        var esperada = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{cuerpo}"));
        foreach (var f in firmas)
        {
            byte[] recibida;
            try { recibida = Convert.FromHexString(f); } catch (FormatException) { continue; }
            if (CryptographicOperations.FixedTimeEquals(esperada, recibida)) return;
        }
        throw new InvalidOperationException("Firma inválida");
    }
}
```

```csharp
// Envío
using var http = new HttpClient { BaseAddress = new Uri(Environment.GetEnvironmentVariable("WA_BASE_URL")!) };
http.DefaultRequestHeaders.Authorization = new("Bearer", Environment.GetEnvironmentVariable("WA_API_KEY"));
var clave = Guid.NewGuid().ToString();                       // la MISMA en los reintentos
var req = new HttpRequestMessage(HttpMethod.Post, "/v1/messages")
{
    Content = JsonContent.Create(new {
        to = "+34600111222", type = "template",
        template = new { name = "recordatorio_cita", language = "es", variables = new[] { "Ana", "15 de octubre" } },
        external_ref = "cita-48213" })
};
req.Headers.Add("Idempotency-Key", clave);
var resp = await http.SendAsync(req);
var cuerpo = await resp.Content.ReadAsStringAsync();         // si !IsSuccessStatusCode: JSON { error: { code, message, request_id } }
```

## Java 17+ (sin ejecutar)

```java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;

public final class WaFirma {
    public static void verificar(String cuerpo, String cabecera, String secreto, long toleranciaSeg, long ahora) throws Exception {
        Long t = null; List<String> firmas = new ArrayList<>();
        for (String parte : cabecera.split(",")) {
            String[] kv = parte.trim().split("=", 2);
            if (kv.length != 2) continue;
            if (kv[0].equals("t")) t = Long.parseLong(kv[1]);
            if (kv[0].equals("v1")) firmas.add(kv[1]);
        }
        if (t == null || firmas.isEmpty()) throw new SecurityException("Firma mal formada");
        if (Math.abs(ahora - t) > toleranciaSeg) throw new SecurityException("Marca de tiempo fuera de tolerancia");

        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secreto.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        byte[] esperada = mac.doFinal((t + "." + cuerpo).getBytes(StandardCharsets.UTF_8));
        String esperadaHex = HexFormat.of().formatHex(esperada);   // minúsculas
        for (String f : firmas) {
            if (MessageDigest.isEqual(esperadaHex.getBytes(StandardCharsets.UTF_8), f.toLowerCase().getBytes(StandardCharsets.UTF_8))) return;
        }
        throw new SecurityException("Firma inválida");
    }
}
```

```java
// Envío con java.net.http
var cliente = java.net.http.HttpClient.newHttpClient();
var cuerpo = """
  {"to":"+34600111222","type":"template",
   "template":{"name":"recordatorio_cita","language":"es","variables":["Ana","15 de octubre"]},
   "external_ref":"cita-48213"}""";
var req = java.net.http.HttpRequest.newBuilder(java.net.URI.create(System.getenv("WA_BASE_URL") + "/v1/messages"))
    .header("Authorization", "Bearer " + System.getenv("WA_API_KEY"))
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", java.util.UUID.randomUUID().toString())   // la MISMA en los reintentos
    .POST(java.net.http.HttpRequest.BodyPublishers.ofString(cuerpo)).build();
var resp = cliente.send(req, java.net.http.HttpResponse.BodyHandlers.ofString());
```

## Ruby, Python, curl

Ejecutables y verificados: [`ruby/verificar_firma.rb`](ejemplos/ruby/verificar_firma.rb), [`python/`](ejemplos/python), y los `curl` de [01 · Inicio rápido](01-inicio-rapido.md).

## Generar un cliente completo desde el contrato

El contrato OpenAPI 3.1 sirve para generar clientes tipados en otros lenguajes (por ejemplo con `openapi-generator` o `openapi-typescript`):

```bash
npx @openapitools/openapi-generator-cli generate -i openapi/openapi.yaml -g php -o ./wa-client-php
```

Dos advertencias: el generador **no** añade por sí solo la `Idempotency-Key` ni los reintentos (ponedlos en un envoltorio), y los clientes generados no se han probado con este contrato; revisad el resultado con el [playground](10-pruebas-y-produccion.md).

## Errores típicos al portar la verificación

| Síntoma | Causa |
| --- | --- |
| Siempre «firma inválida» | Se verifica sobre el JSON **reserializado** en lugar del cuerpo crudo; el framework ya lo parseó (usad el modo «raw») |
| Falla solo con caracteres no ASCII | Codificación distinta de UTF-8 al calcular el HMAC |
| Falla a ratos | Secreto de otra suscripción (cada una tiene el suyo), o reloj del servidor desfasado más de 5 min |
| Comparación `==` en lugar de tiempo constante | Funciona, pero abre un canal lateral: usad `hash_equals`, `FixedTimeEquals`, `MessageDigest.isEqual` o `hmac.compare_digest` |
