Recibir el aviso

Cuando el pago se aprueba o falla, EvePay llama a una dirección tuya. Es la única señal con la que debes despachar un pedido.

Registra tu endpoint

En el portal, en Ajustes → Webhook endpoint, o por API con POST /v1/webhook-endpoint. Al registrarlo te damos un secreto que se muestra una sola vez: con él vas a verificar que el aviso es nuestro.

Lo que te llega

{
  "id": "0b0e...",
  "created": 1791068547,
  "type": "payment.completed",
  "data": {
    "paymentId": "44444444-4444-4444-8444-444444444444",
    "reference": "pedido-4417",
    "amountMinor": 89000,
    "currency": "COP",
    "estado": "aprobado"
  }
}

type es payment.completed o payment.failed. created es epoch en segundos.

Con estos headers:

Header Qué trae
evepay-signature t=<epoch>,v1=<hmac-sha256 en hex>
evepay-event El tipo, repetido, para enrutar sin abrir el cuerpo.
evepay-delivery-id Id de la entrega. Dos entregas del mismo evento lo comparten.

Verifica la firma antes de hacer nada

Se firma <timestamp>.<cuerpo crudo> con HMAC-SHA256 y tu secreto. El cuerpo tiene que ser el texto tal como llegó: si lo parseas a objeto y lo vuelves a serializar, la firma no va a coincidir.

<?php
$crudo    = file_get_contents("php://input");
$cabecera = $_SERVER["HTTP_EVEPAY_SIGNATURE"] ?? "";

parse_str(str_replace(",", "&", $cabecera), $partes);
$esperada = hash_hmac("sha256", $partes["t"] . "." . $crudo, getenv("EVEPAY_SECRETO"));

if (!hash_equals($esperada, $partes["v1"] ?? "")) {
    http_response_code(400);
    exit("Firma inválida");
}

// Rechaza avisos viejos: un atacante no puede reusar uno capturado.
if (abs(time() - (int) $partes["t"]) > 300) {
    http_response_code(400);
    exit("Aviso vencido");
}

$evento = json_decode($crudo, true);
marcarPedidoPagado($evento["data"]["reference"]);
http_response_code(200);
import crypto from "node:crypto";

// Ojo: necesitas el cuerpo CRUDO, no el ya parseado.
app.post("/webhooks/evepay", express.raw({ type: "application/json" }), (req, res) => {
  const cabecera = req.headers["evepay-signature"] ?? "";
  const partes = Object.fromEntries(
    String(cabecera)
      .split(",")
      .map((p) => p.split("="))
  );

  const esperada = crypto
    .createHmac("sha256", process.env.EVEPAY_SECRETO)
    .update(`${partes.t}.${req.body.toString()}`)
    .digest("hex");

  const ok = partes.v1 && crypto.timingSafeEqual(Buffer.from(esperada), Buffer.from(partes.v1));

  if (!ok) return res.status(400).send("Firma inválida");
  if (Math.abs(Date.now() / 1000 - Number(partes.t)) > 300) {
    return res.status(400).send("Aviso vencido");
  }

  const evento = JSON.parse(req.body.toString());
  marcarPedidoPagado(evento.data.reference);
  res.sendStatus(200);
});

Compara con una función de tiempo constante —hash_equals en PHP, timingSafeEqual en Node—. Comparar con == deja medir cuánto tarda en fallar, y con eso se adivina la firma byte a byte.

Responde rápido y sé idempotente

Responde 200 en cuanto hayas guardado el evento, y haz el trabajo pesado después. Si no respondes 2xx, reintentamos hasta 3 veces con esperas de 1, 5 y 25 segundos — así que el mismo pago puede llegarte más de una vez. Guarda el paymentId que ya procesaste y descarta los repetidos.

Si el aviso no llega

Puede pasar: tu servidor estaba caído más de 31 segundos, o cambiaste de dominio. El estado real siempre se puede consultar con GET /v1/pagos/:id. Lo sensato es un repaso cada tanto de los pedidos que llevan horas en pendiente, no preguntar en bucle.

No despaches cuando el comprador vuelve a tu página. El regreso del comprador es cortesía, no prueba: pudo cerrar la pestaña, o abrir esa dirección a mano. Además viaja vacío — lo comprobamos pagando de verdad en el ambiente de pruebas de la pasarela: la redirección llega sin un solo parámetro, así que esa página ni siquiera sabe qué se pagó.