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

# Implementar un receptor de webhooks

> Crea un endpoint, verifica firmas, acepta eventos de forma duradera y gestiona los reintentos y la suspensión de webhooks.

Recibe eventos de YCloud en un endpoint público y procésalos sin perder ni
repetir acciones comerciales. Utiliza HTTPS para producción, conserva el cuerpo
crudo de la solicitud y verifica cada firma antes de aceptar el evento.

## Registrar tu endpoint

En la [Consola de YCloud](https://www.ycloud.com/console), abre **Desarrolladores >
Webhook**, selecciona **Añadir Endpoints**, introduce la **URL de Endpoints**, selecciona
**Eventos** y guarda con **Confirmar**. También puedes usar
`POST /v2/webhookEndpoints`; consulta [Configurar webhooks](/es/api-reference/guides/api-fundamentals/configure-webhooks)
para la solicitud y la respuesta.

* Puedes configurar hasta 20 endpoints por cuenta.
* La URL debe ser accesible públicamente y no debe resolver a una dirección privada.
* La URL admite hasta 500 caracteres; la descripción opcional admite hasta 400.
* Guarda de forma segura el `secret` devuelto para la firma.

## Leer la solicitud del evento

```http theme={"theme":{"light":"github-light","dark":"github-dark"}}
Content-Type: application/json
YCloud-Signature: t=1762224357,s=SIGNATURE_HEX
X-Webhook-Endpoint-ID: WEBHOOK_ENDPOINT_ID
```

| Campo | Significado |
| - | - |
| `id` | ID del evento. Utilízalo para desduplicar la entrega. |
| `type` | Tipo de evento, como `whatsapp.message.updated`. |
| `apiVersion` | Versión de la API utilizada por el evento. |
| `createTime` | Marca de tiempo de creación del evento. |
| Objeto específico del evento | Carga útil (payload) como `whatsappMessage`, `whatsappInboundMessage` o `contactCreated`. |

Para ver ejemplos completos, consulta [Cargas útiles de Webhook](/es/api-reference/guides/examples/webhook-examples/webhook-payload-examples).

## Verificar la firma

El encabezado `YCloud-Signature` tiene el formato `t=TIMESTAMP,s=SIGNATURE`.
La marca de tiempo es la hora Unix en segundos.

1. Extrae `t` y `s` del encabezado.
2. Une la marca de tiempo, un punto y los bytes exactos del cuerpo crudo de la solicitud.
3. Calcula HMAC-SHA256 con el secreto de firma del endpoint.
4. Compara el resultado hexadecimal utilizando una comparación de tiempo constante.

No serialices JSON parseado para reconstruir el cuerpo. Los espacios en blanco, el orden
de las claves o el escape de Unicode alteran la entrada de la firma.

El siguiente ejemplo también utiliza una tolerancia de marca de tiempo configurable de cinco minutos para
reducir el riesgo de ataques de reproducción (replay). Esta tolerancia es una política de la aplicación, no un límite
de reintento de YCloud. Mantén sincronizado el reloj de tu servidor.

```javascript theme={"theme":{"light":"github-light","dark":"github-dark"}}
import crypto from "node:crypto";

export function verifySignature(rawBody, header, secret, now = Date.now()) {
  if (!Buffer.isBuffer(rawBody) || typeof header !== "string" || !secret) return false;
  const fields = header.split(",").map(part => part.trim().split("="));
  const timestamps = fields.filter(([key]) => key === "t").map(([, value]) => value);
  const signatures = fields.filter(([key]) => key === "s").map(([, value]) => value);
  if (timestamps.length !== 1 || !/^\d+$/.test(timestamps[0])) return false;
  const timestamp = timestamps[0];
  if (Math.abs(Math.floor(now / 1000) - Number(timestamp)) > 300) return false;
  const expected = crypto.createHmac("sha256", secret)
    .update(timestamp + ".")
    .update(rawBody)
    .digest();
  return signatures.some(signature =>
    typeof signature === "string" && /^[a-fA-F0-9]{64}$/.test(signature) &&
    crypto.timingSafeEqual(expected, Buffer.from(signature, "hex"))
  );
}
```

## Aceptar antes de confirmar la recepción

Persiste el evento validado en una cola duradera o en una bandeja de entrada transaccional antes
de devolver `2xx`. Si el almacenamiento no está disponible, devuelve un error para que la entrega pueda
reintentarse. Tras la aceptación duradera, permite que tu worker gestione los fallos del procesamiento
comercial con sus propios reintentos.

Este controlador de Express utiliza una operación `persistEvent` proporcionada por la aplicación.
Impleméntala como una inserción atómica indexada por el `id` del evento; un evento ya almacenado
debe considerarse como un éxito. No marques un evento como procesado antes de que su transacción
comercial se confirme.

```javascript theme={"theme":{"light":"github-light","dark":"github-dark"}}
import express from "express";
import { verifySignature } from "./signature.js";
import { persistEvent } from "./event-inbox.js";

const app = express();
const secret = process.env.YCLOUD_WEBHOOK_SECRET;
if (!secret) throw new Error("YCLOUD_WEBHOOK_SECRET is required");

// Register the raw-body route before any JSON-parsing middleware.
app.post("/webhook", express.raw({ type: "application/json", limit: "1mb" }), async (req, res) => {
  if (!verifySignature(req.body, req.get("YCloud-Signature"), secret)) {
    return res.sendStatus(401);
  }
  let event;
  try {
    event = JSON.parse(req.body.toString("utf8"));
  } catch {
    return res.sendStatus(400);
  }
  if (!event || typeof event.id !== "string" || typeof event.type !== "string") {
    return res.sendStatus(400);
  }
  try {
    await persistEvent(event.id, event);
    return res.sendStatus(204);
  } catch {
    return res.sendStatus(503);
  }
});

app.listen(3000);
```

### Ejemplo con Java y Spring

Este ejemplo de Java 17 aplica el mismo orden de verificación y aceptación duradera.
Proporciona un bean `EventInbox` respaldado por un almacenamiento transaccional con una restricción
única en el ID del evento. `insertIfAbsent` debe confirmar el evento completo antes de que
retorne; los ID duplicados retornan con éxito. Tu worker podrá entonces procesar y
marcar los eventos almacenados en su propia transacción.

```java theme={"theme":{"light":"github-light","dark":"github-dark"}}
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;

@RestController
public class WebhookController {
    private final String secret;
    private final ObjectMapper mapper;
    private final EventInbox inbox;

    public WebhookController(@Value("${YCLOUD_WEBHOOK_SECRET}") String secret,
                             ObjectMapper mapper, EventInbox inbox) {
        if (secret.isBlank()) throw new IllegalArgumentException("Missing webhook secret");
        this.secret = secret;
        this.mapper = mapper;
        this.inbox = inbox;
    }

    @PostMapping("/webhook")
    public ResponseEntity<Void> receive(
            @RequestHeader(value = "YCloud-Signature", required = false) String header,
            @RequestBody byte[] body) {
        if (!verify(body, header)) return ResponseEntity.status(401).build();
        JsonNode event;
        try {
            event = mapper.readTree(body);
            if (event == null || !event.path("id").isTextual() || !event.path("type").isTextual()) {
                return ResponseEntity.badRequest().build();
            }
        } catch (Exception invalidJson) {
            return ResponseEntity.badRequest().build();
        }
        try {
            inbox.insertIfAbsent(event.path("id").asText(), body);
            return ResponseEntity.noContent().build();
        } catch (Exception storageFailure) {
            return ResponseEntity.status(503).build();
        }
    }

    private boolean verify(byte[] body, String header) {
        if (header == null) return false;
        String timestamp = null;
        java.util.List<String> signatures = new java.util.ArrayList<>();
        for (String part : header.split(",")) {
            String[] pair = part.trim().split("=", 2);
            if (pair.length != 2) continue;
            if (pair[0].equals("t")) {
                if (timestamp != null) return false;
                timestamp = pair[1];
            }
            if (pair[0].equals("s")) signatures.add(pair[1]);
        }
        if (timestamp == null || !timestamp.matches("[0-9]{1,12}")) return false;
        try {
            long age = Instant.now().getEpochSecond() - Long.parseLong(timestamp);
            if (Math.abs(age) > 300) return false;
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
            byte[] expected = mac.doFinal(body);
            for (String signature : signatures) {
                if (signature.matches("[a-fA-F0-9]{64}") &&
                    MessageDigest.isEqual(expected, HexFormat.of().parseHex(signature))) return true;
            }
            return false;
        } catch (Exception invalidSignature) {
            return false;
        }
    }

    public interface EventInbox {
        void insertIfAbsent(String eventId, byte[] eventBody) throws Exception;
    }
}
```

No uses una escritura independiente de “ya procesado” en Redis antes de encolar el evento:
si la puesta en cola falla después de esa escritura, se podría descartar un reintento. Utiliza una bandeja de entrada atómica
y duradera o una cola cuya aceptación y gestión de duplicados sean atómicas.

## Tiempos, reintentos y suspensión

Devuelve una respuesta `2xx` con rapidez; procura que sea en menos de 6 segundos. Las respuestas lentas
de más de 10 segundos pueden reducir la prioridad de entrega. No ejecutes tareas comerciales lentas
dentro del controlador HTTP.

Para una respuesta distinta de `2xx` o la ausencia de respuesta, los intervalos de reintento predeterminados son:

| Reintento | Demora |
| - | - |
| 1 | 10 segundos |
| 2 | 30 segundos |
| 3 | 5 minutos |
| 4 | 30 minutos |
| 5 | 1 hora |
| 6 | 2 horas |
| 7 | 2 horas |

YCloud deja de reintentar ese evento tras alcanzar el límite de reintentos configurado. Con la
configuración predeterminada, una URL puede suspenderse
durante 3 minutos si alcanza 200 fallos por minuto o 10 minutos de tiempo de fallo
acumulado en un minuto entre solicitudes concurrentes. Las solicitudes se pausan
durante la suspensión y se reanudan después.

Supervisa también el `status` del endpoint. Un endpoint `pending` no recibe
eventos; consulta [configuración del endpoint](/es/api-reference/guides/api-fundamentals/configure-webhooks).

## Verificar tu receptor

* Una firma válida y un evento almacenado de forma duradera devuelven `2xx`.
* Los cuerpos modificados, las firmas mal formadas y las marcas de tiempo caducadas se rechazan.
* Un evento duplicado se acepta sin repetir su acción comercial.
* Una interrupción del almacenamiento devuelve un error y permite el reenvío.
* Los tipos de eventos desconocidos no provocan el bloqueo del receptor.
* Su worker reintenta los fallos de procesamiento después de la aceptación.
* Los secretos y las cargas útiles completas de los clientes no se escriben en los registros de la aplicación.


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