Webhooks
Cuando un pago se completa, falla, se devuelve o recibe un contracargo, e-Payouts hace un POST JSON a la URL de notificación del módulo (tu panel de comercio → Sitios → módulo). Es la única confirmación válida de un pago.
Cuándo se envía
Se envía un aviso automáticamente en estos casos, siempre con el mismo formato:
| event / status | Cuándo | Qué hacer |
|---|---|---|
| payment.completed completed | El cliente pagó y el procesador lo confirmó. | Si sandbox es false y amount y currency coinciden con tu pedido, marcalo como pagado y entregalo. |
| payment.failed failed | El pago fue rechazado o no se completó (p. ej. el banco lo rechazó o el cliente no confirmó en la app). | No entregues el pedido. Si querés, ofrecele volver a pagar: creá un pago nuevo (podés usar el mismo ucode; tendrá otro code). |
| payment.refunded refunded · completed | Se hizo una devolución del pago (pedida por vos o por soporte). status es refunded si se devolvió el total, o completed si fue parcial. El detalle va en adjustment. | Registrá la devolución en tu pedido (adjustment.amount). El monto ya se descontó de tu saldo. |
| payment.charged_back charged_back · completed | El cliente reclamó el cobro a su banco y el contracargo quedó procesado. status es charged_back si cubrió el total, o completed si fue parcial. | Tratalo como una devolución no pedida: si todavía no entregaste el pedido, no lo entregues. |
No se envían avisos por los estados intermedios (created, pending), ni cuando se pide una devolución: solo cuando queda hecha. Las devoluciones y los contracargos también se consultan con GET /v2/adjustments, y te avisamos por correo.
Reenvío manual (tu panel de comercio → Pagos → detalle): repite el último aviso del pago tal cual se envió (mismo event y status). Si el pago nunca tuvo un aviso, envía uno con su estado actual, que puede ser cualquiera de los de la API (p. ej. payment.pending o payment.refunded).
Campos
El cuerpo es un objeto JSON (Content-Type: application/json) con estos campos, siempre en este orden:
| Campo | Tipo | Descripción |
|---|---|---|
| event | string | Qué pasó: payment.completed, payment.failed, payment.refunded o payment.charged_back. También llega en la cabecera X-Epayouts-Event. |
| sandbox | boolean | true en pagos de prueba: nunca entregues un pedido real por un aviso con sandbox true. |
| code | string | Código del pago (UUID): el mismo que devuelve la API. Con él podés consultar el detalle: GET /v2/payments/{code}. |
| ucode | string | null | Tu código de pedido, tal como llegó al paymentbox; null si no mandaste ninguno. |
| method | string | Método con que se inició el pago (el código que usa tu integración): procesá el aviso con este. |
| effective_method | string | Método que efectivamente lo cobró. Distinto de method solo si ese método tiene un ruteo (p. ej. se inició como ppal y se cobró con 16cards); es informativo. |
| amount | string | Monto que pediste, con dos decimales y punto ("49.90"). Comparalo con tu pedido. |
| currency | string | Moneda que pediste (ISO 4217). |
| status | string | Estado del pago en el momento del aviso: completed, failed, refunded o charged_back (en devoluciones y contracargos parciales sigue completed). |
| created_at | string | Cuándo se creó el pago (ISO 8601, UTC). No es la hora del aviso. |
| charged_amount | string | Solo si el método elegido no acepta tu moneda (p. ej. MB WAY y Multibanco cobran en EUR): lo que se le cobró al cliente, con la tasa del momento. No lo uses para validar el pedido. |
| charged_currency | string | Moneda de charged_amount (solo junto con charged_amount). |
| adjustment | object | Solo en payment.refunded y payment.charged_back: id (el de GET /v2/adjustments), type (refund | chargeback), amount y currency (lo devuelto o reclamado, en la moneda en que se cobró) y reason (motivo o null). |
Decidí qué hacer según event, no según status: en una devolución o un contracargo parcial, status sigue en completed. El aviso no trae datos del cliente, comisión ni liquidación: si los necesitás, consultá el pago por code.
Ejemplos
Pago completado
{
"event": "payment.completed",
"sandbox": false,
"code": "3f6b2d10-9a4c-4e21-8b7f-5c0d1e2f3a4b",
"ucode": "PEDIDO-1042",
"method": "sofort",
"effective_method": "sofort",
"amount": "49.90",
"currency": "EUR",
"status": "completed",
"created_at": "2026-09-18T14:33:22+00:00"
}Pago fallido
{
"event": "payment.failed",
"sandbox": false,
"code": "5e7a9c1b-3d5f-4a2c-8e4b-6d8f0a2c4e6a",
"ucode": "PEDIDO-1044",
"method": "ppal",
"effective_method": "ppal",
"amount": "35.00",
"currency": "EUR",
"status": "failed",
"created_at": "2026-09-29T09:12:05+00:00"
}Pago completado cobrado en otra moneda
Pedido en USD pagado con MB WAY, que cobra en EUR: amount y currency son los de tu pedido; charged_amount y charged_currency, lo que pagó el cliente.
{
"event": "payment.completed",
"sandbox": false,
"code": "8a2c4e6f-1b3d-4f5a-9c7e-0d2f4a6b8c1e",
"ucode": "PEDIDO-1043",
"method": "mbway",
"effective_method": "mbway",
"amount": "12.00",
"currency": "USD",
"status": "completed",
"created_at": "2026-09-30T14:35:10+00:00",
"charged_amount": "10.65",
"charged_currency": "EUR"
}Pago de un método con ruteo
Tu integración usa ppal, pero ese método está ruteado y el pago se cobró con 16cards: procesalo como ppal.
{
"event": "payment.completed",
"sandbox": false,
"code": "c2e4a6b8-0d1f-4e3a-9b5c-7d9e1f3a5b7c",
"ucode": "PEDIDO-1045",
"method": "ppal",
"effective_method": "16cards",
"amount": "20.00",
"currency": "EUR",
"status": "completed",
"created_at": "2026-09-30T11:02:48+00:00"
}Devolución total
{
"event": "payment.refunded",
"sandbox": false,
"code": "3f6b2d10-9a4c-4e21-8b7f-5c0d1e2f3a4b",
"ucode": "PEDIDO-1042",
"method": "sofort",
"effective_method": "sofort",
"amount": "49.90",
"currency": "EUR",
"status": "refunded",
"created_at": "2026-09-18T14:33:22+00:00",
"adjustment": {
"id": "c4d5e6f7-0a1b-4c2d-8e3f-4a5b6c7d8e9f",
"type": "refund",
"amount": "49.90",
"currency": "EUR",
"reason": "El cliente canceló el pedido"
}
}Contracargo parcial
El banco del cliente reclamó 30,00 € de un pago de 80,00 €: el pago sigue completed.
{
"event": "payment.charged_back",
"sandbox": false,
"code": "9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"ucode": "PEDIDO-0988",
"method": "16cards",
"effective_method": "16cards",
"amount": "80.00",
"currency": "EUR",
"status": "completed",
"created_at": "2026-09-02T18:20:11+00:00",
"adjustment": {
"id": "d1e2f3a4-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
"type": "chargeback",
"amount": "30.00",
"currency": "EUR",
"reason": null
}
}Los avisos de pagos de prueba tienen el mismo formato, con "sandbox": true: ver ejemplo en Modo prueba →
Cabeceras
POST /tu-url-de-notificacion HTTP/1.1
Content-Type: application/json
X-Epayouts-Event: payment.completed
X-Epayouts-Delivery: 1234
X-Epayouts-Signature: t=1790380000,v1=5d41402abc4b2a76b9719d911017c592…
X-Epayouts-Sandbox: 1 (solo pagos de prueba)X-Epayouts-Delivery— número del aviso; es el mismo en cada reintento del mismo aviso.X-Epayouts-Signature— t = segundos Unix del envío; v1 = firma (ver abajo). Cambia en cada intento.
Verificar la firma
v1 = HMAC-SHA256("<t>.<cuerpo crudo>", clave de firma de webhooks). La clave está en tu panel de comercio → API y es distinta del secreto de la API. Rechazá avisos con t de más de 5 minutos.
<?php
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_EPAYOUTS_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $header), $parts); // t=…&v1=…
$expected = hash_hmac('sha256', ($parts['t'] ?? '') . '.' . $raw, getenv('EPAYOUTS_WEBHOOK_KEY'));
if (!hash_equals($expected, $parts['v1'] ?? '') || abs(time() - (int) ($parts['t'] ?? 0)) > 300) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
if ($event['sandbox'] === false) {
switch ($event['event']) { // usá event, no status: en devoluciones parciales status sigue en completed
case 'payment.completed': // marcar el pedido $event['ucode'] como pagado (si ya lo estaba, no hagas nada)
break;
case 'payment.failed': // dejar el pedido sin pagar
break;
case 'payment.refunded': // registrar la devolución: $event['adjustment']['amount']
case 'payment.charged_back': // registrar el contracargo: $event['adjustment']['amount']
break;
}
}
http_response_code(200);import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/webhooks/epayouts', express.raw({ type: 'application/json' }), (req, res) => {
const parts = Object.fromEntries(String(req.get('X-Epayouts-Signature') || '').split(',').map(p => p.split('=')));
const expected = crypto.createHmac('sha256', process.env.EPAYOUTS_WEBHOOK_KEY)
.update(`${parts.t}.${req.body}`).digest('hex');
const ok = parts.v1 && expected.length === parts.v1.length
&& crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
&& Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body);
// … actualizar el pedido event.ucode
res.sendStatus(200);
});import hashlib, hmac, json, os, time
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/webhooks/epayouts")
def epayouts_webhook():
raw = request.get_data()
parts = dict(p.split("=", 1) for p in request.headers.get("X-Epayouts-Signature", "").split(",") if "=" in p)
expected = hmac.new(os.environ["EPAYOUTS_WEBHOOK_KEY"].encode(), parts.get("t", "").encode() + b"." + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")) or abs(time.time() - int(parts.get("t", 0))) > 300:
abort(400)
event = json.loads(raw)
# … actualizar el pedido event["ucode"]
return "", 200Reintentos
Respondé HTTP 2xx en menos de 10 segundos. Si no, reintentamos con espera creciente (por defecto 1, 2, 4, 8… minutos, hasta 6 horas entre intentos) hasta un máximo de intentos; al agotarse se abre un caso con soporte. Un mismo aviso puede llegar más de una vez: antes de procesarlo, fijate por code y status si ya lo procesaste, así no marcás dos veces el mismo pedido.
La URL de notificación tiene que ser https y pública. Podés pedir un reenvío desde tu panel de comercio → Pagos → detalle.