Ir al contenido

Los webhooks de pago llegan tarde, dos veces y desordenados

La vuelta desde la página de pago no es una confirmación. Un único webhook tampoco. Qué promete de verdad un proveedor de pagos sobre la entrega, y cómo construir algo correcto encima.

6 min de lectura

Un proveedor de pagos le dirá que entrega los eventos de forma fiable. Es cierto, y es una promesa más estrecha de lo que la mayoría de equipos lee en ella. Lo que significa es que el evento llegará en algún momento, probablemente más de una vez, posiblemente horas después de lo que describe, y no necesariamente en el orden en que ocurrieron las cosas.

Todas las decisiones de diseño de abajo salen de tomarse esa frase al pie de la letra.

El redirect es interfaz, no resultado

Cuando el cliente vuelve de la página de pago, su aplicación recibe una petición que dice, en el fondo, que el cliente está aquí otra vez. No dice que el pago funcionó, y no la envía el proveedor: la envía el navegador del cliente, que es algo que se puede cerrar, recargar o sustituir por un móvil que se queda sin batería en un ascensor.

Use el redirect para exactamente una cosa: mostrar una pantalla. Lea el estado actual del pago en el proveedor, muestre lo que sabe, y si aún no lo sabe, dígalo. Lo que el redirect no puede hacer nunca es escribir "pagado" en su base de datos.

El pago lo confirma el evento, y el evento llega por un canal que el cliente no puede alterar.

Verifique los bytes, no el objeto

La firma es un hash sobre el cuerpo en bruto con un secreto compartido, más una marca de tiempo para impedir que se reinyecte un envío antiguo. Dos cosas se tuercen y las dos parecen una clave equivocada:

public function handle(Request $request): Response
{
    $payload = $request->getContent();   // en bruto, no $request->all()
 
    try {
        $event = Webhook::constructEvent(
            $payload,
            $request->header('Stripe-Signature'),
            config('services.stripe.webhook_secret'),
        );
    } catch (SignatureVerificationException) {
        return response()->noContent(400);
    }
 
    ProcessPaymentEvent::dispatch($event->id, $payload);
 
    return response()->noContent(200);
}

La primera es interpretar antes de calcular el hash. $request->all() le da un array, y volver a codificarlo produce bytes distintos de los que llegaron: otro orden de claves, otro escapado unicode, otro formato de números. Calcule el hash de la cadena.

La segunda es el middleware CSRF, que rechazará la petición antes de que nada de esto se ejecute, porque un proveedor de pagos no tiene sesión ni token. La ruta va fuera del grupo de middleware web, y si está dentro, el fallo es un 419 que el proveedor reintenta durante tres días.

Fíjese en lo que hace el manejador después de verificar: casi nada. Pasa el trabajo a una cola y devuelve. Los proveedores esperan un reconocimiento rápido, y hacer el trabajo en línea convierte una consulta lenta en una tormenta de reintentos.

Desordenado es el caso normal

Dos eventos sobre un mismo pago pueden llegar en la secuencia equivocada. Un cargo funciona y se devuelve noventa segundos después; el evento de devolución adelanta al de éxito; su manejador procesa la devolución contra un pedido que todavía no está pagado, decide que eso no tiene sentido, y no hace nada. Ahora el pedido está pagado para siempre.

No lo arregle con orden. Arréglelo haciendo que cada manejador describa el mundo en lugar de una transición:

  • Mal: "al devolver, pasa el estado de capturado a devuelto."
  • Bien: "ante cualquier evento de este pago, consulta su estado actual en el proveedor y deja mi fila igual."

El evento pasa a ser una señal para ir a mirar, y su orden de llegada deja de importar. Cuesta una llamada por evento y elimina una clase entera de error que de otro modo la encuentra en producción un contable desconcertado.

Donde de verdad no pueda volver a consultar, guarde en la fila la marca de tiempo del evento del proveedor e ignore todo lo más antiguo que lo ya aplicado.

Dos veces también es el caso normal

Todos los proveedores reintentan ante cualquier cosa que no sea un 2xx, y un envío que expiró después de que su código funcionara también se reintenta. El mismo evento aterrizará dos veces.

Guarde el identificador de evento del proveedor con un índice único, insértelo antes de procesar, y deje que la base de datos rechace el duplicado. Ese es todo el mecanismo, y es más fiable que comprobar si la fila ya existe, porque una comprobación seguida de una inserción es una carrera entre dos workers, y en una tormenta de reintentos los dos workers son reales.

Y después deje de fiarse del canal

Todo lo anterior hace correcto el camino del webhook. No lo hace completo, porque un endpoint que estuvo caído cuatro horas durante un despliegue es un endpoint que perdió eventos, y cuando se cierra la ventana de reintentos ya no están.

Así que ejecute un trabajo de conciliación. Una vez al día, pida al proveedor todos los pagos que cambiaron desde su última ejecución correcta y compárelos con sus filas. Informe de las diferencias en lugar de corregirlas en silencio: un desajuste suele significar un error, y un trabajo que arregla sus datos calladamente esconderá ese error durante un año.

Es la misma forma que las integraciones contables que construimos: un flujo de eventos para la frescura, una consulta periódica para la corrección, y la consulta es aquello en lo que de verdad puede confiar. Con pagos hay una segunda razón: alguien de finanzas va a preguntar por qué el total mensual del proveedor y su base de datos no coinciden, y "sí coinciden" es mucho mejor respuesta que una investigación.

Si está montando esto junto con el modelo de pedido, cómo se modela el pago decide la mitad de lo que estos manejadores tienen que hacer, y conviene resolverlo antes.

Preguntas relacionadas

¿De verdad no basta con el redirect?
No basta, y la razón es que depende del navegador del cliente. Cierra la pestaña, el móvil pierde cobertura en el tren, la página de autenticación del banco le lleva a otro sitio, o simplemente no espera. El pago sale bien igualmente. Si el manejador del redirect es lo que marca el pedido como pagado, cada uno de esos casos es dinero cobrado sin pedido que enseñar.
¿Por qué falla la comprobación de firma si la carga parece correcta?
Casi siempre porque algo interpretó la petición antes de calcular el hash. La firma se calcula sobre los bytes exactos que se enviaron, así que un framework que decodifica JSON y lo vuelve a codificar, o un proxy que reformatea el cuerpo, produce una cadena distinta y por tanto un hash distinto. Lea el cuerpo en bruto, verifique eso, e interprete después.
¿Hay que manejar todos los tipos de evento?
No, y suscribirse a todo es la forma de volver el endpoint lento y ruidoso. Suscríbase a los pocos que cambian su estado - correcto, fallido, devuelto, disputado, y lo que necesite el ciclo de suscripción - e ignore el resto de forma explícita en lugar de por accidente. Reconozca también los ignorados con un 200.
¿Cuánto tiempo guardamos los eventos en bruto?
Más de lo que parece razonable. Son pequeños y son el único registro de qué le dijo el proveedor y cuándo. La primera vez que un cliente dispute un cargo de hace ocho meses, o el equipo financiero no pueda cuadrar un mes, esa tabla es lo que responde. Guardar un año cuesta casi nada.

← Volver a todos los artículos

Llamar+1 848 272 7583WhatsApp+90 850 308 5436Correoinfo@codefacture.comPágina de contacto