Webhooks
When a payment completes, fails, is refunded or charged back, e-Payouts sends a JSON POST to the module's notification URL (your merchant panel → Sites → module). It is the only valid confirmation of a payment.
When it is sent
A notification is sent automatically in these cases, always with the same format:
| event / status | When | What to do |
|---|---|---|
| payment.completed completed | The customer paid and the provider confirmed it. | If sandbox is false and amount and currency match your order, mark it paid and deliver it. |
| payment.failed failed | The payment was declined or not completed (e.g. the bank declined it or the customer did not confirm in the app). | Do not deliver the order. If you want, offer to pay again: create a new payment (you may reuse the ucode; it will get another code). |
| payment.refunded refunded · completed | A refund of the payment was made (requested by you or by support). status is refunded when the full amount was returned, or completed when partial. Details in adjustment. | Record the refund on your order (adjustment.amount). The amount has already been deducted from your balance. |
| payment.charged_back charged_back · completed | The customer disputed the charge with their bank and the chargeback was processed. status is charged_back when it covered the full amount, or completed when partial. | Treat it as an unrequested refund: if you have not delivered the order yet, do not deliver it. |
No notifications are sent for intermediate statuses (created, pending), nor when a refund is requested: only once it is done. Refunds and chargebacks can also be queried with GET /v2/adjustments, and we let you know by email.
Manual resend (your merchant panel → Payments → details): repeats the payment's last notification exactly as it was sent (same event and status). If the payment never had a notification, it sends one with its current status, which can be any of the API statuses (e.g. payment.pending or payment.refunded).
Fields
The body is a JSON object (Content-Type: application/json) with these fields, always in this order:
| Field | Type | Description |
|---|---|---|
| event | string | What happened: payment.completed, payment.failed, payment.refunded or payment.charged_back. Also sent in the X-Epayouts-Event header. |
| sandbox | boolean | true for test payments: never deliver a real order for a notification with sandbox true. |
| code | string | Payment code (UUID): the same one the API returns. Use it to get the details: GET /v2/payments/{code}. |
| ucode | string | null | Your order code, as received by the paymentbox; null if you did not send one. |
| method | string | Method the payment was started with (the code your integration uses): process the notification with this one. |
| effective_method | string | Method that actually charged it. It differs from method only when that method is routed (e.g. started as ppal and charged with 16cards); informational. |
| amount | string | Amount you requested, with two decimals and a dot ("49.90"). Compare it with your order. |
| currency | string | Currency you requested (ISO 4217). |
| status | string | Payment status at the time of the notification: completed, failed, refunded or charged_back (on partial refunds and chargebacks it stays completed). |
| created_at | string | When the payment was created (ISO 8601, UTC). It is not the notification time. |
| charged_amount | string | Only when the chosen method does not accept your currency (e.g. MB WAY and Multibanco charge in EUR): what the customer was charged, at the rate of the moment. Do not use it to validate the order. |
| charged_currency | string | Currency of charged_amount (only together with charged_amount). |
| adjustment | object | Only in payment.refunded and payment.charged_back: id (the one in GET /v2/adjustments), type (refund | chargeback), amount and currency (what was refunded or disputed, in the charged currency) and reason (or null). |
Decide what to do based on event, not status: on a partial refund or chargeback, status stays completed. The notification does not include customer data, commission or settlement: if you need them, fetch the payment by code.
Examples
Completed payment
{
"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"
}Failed payment
{
"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"
}Completed payment charged in another currency
Order in USD paid with MB WAY, which charges in EUR: amount and currency are your order's; charged_amount and charged_currency, what the customer paid.
{
"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"
}Payment with a routed method
Your integration uses ppal, but that method is routed and the payment was charged with 16cards: process it as 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"
}Full refund
{
"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"
}
}Partial chargeback
The customer's bank disputed €30.00 of an €80.00 payment: the payment stays 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
}
}Test payment notifications have the same format, with "sandbox": true: see the example in Test mode →
Headers
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 (test payments only)X-Epayouts-Delivery— notification number; it is the same on every retry of the same notification.X-Epayouts-Signature— t = Unix seconds of the delivery; v1 = signature (see below). It changes on every attempt.
Verify the signature
v1 = HMAC-SHA256("<t>.<raw body>", webhook signing key). The key is in your merchant panel → API and is different from the API secret. Reject notifications whose t is older than 5 minutes.
<?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 "", 200Retries
Answer HTTP 2xx within 10 seconds. Otherwise we retry with increasing delays (by default 1, 2, 4, 8… minutes, up to 6 hours between attempts) up to a maximum number of attempts; when exhausted, a support case is opened. The same notification can arrive more than once: check by code and status whether you already handled it, so the same order is never processed twice.
The notification URL must be public https. You can request a resend from your merchant panel → Payments → details.