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_equalsen PHP,timingSafeEqualen 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ó.