Webhooks
Cuando un depósito llega a un estado final, Cashela envía un POST firmado a la notification_url del depósito con el resultado. Verifica la firma, actualiza tu pedido y responde 2xx.
Cuándo se envían notificaciones
Evento (cabecera X-Cashela-Event) | Cuándo |
|---|---|
partner.payin.transaction.updated | El depósito llega a su estado final: COMPLETED, DECLINED o CANCELLED (también un depósito vencido). |
partner.payin.transaction.voided | Se anuló un depósito con tarjeta. Su estado pasa a REVERSED. |
partner.payin.transaction.refunded | Se reembolsó un depósito con tarjeta: REVERSED si el reembolso es total, COMPLETED tras un reembolso parcial. |
partner.payin.dispute.opened, partner.payin.dispute.won, partner.payin.dispute.lost | Se abrió o se resolvió una disputa de tarjeta sobre el depósito. |
El evento viaja solo en la cabecera X-Cashela-Event, nunca en el cuerpo. Ignora los nombres de evento que no conozcas: se pueden añadir nuevos.
Entrega y reintentos
- Cashela envía un
POSTconContent-Type: application/jsona lanotification_urldel depósito (solo HTTPS). - Responde cualquier
2xxpara confirmar la recepción. Cualquier otro estado, un timeout o un error de conexión cuentan como entrega fallida, y Cashela reintenta. - Responde rápido y haz el trabajo pesado de forma asíncrona.
- La misma notificación puede llegar más de una vez. Haz tu handler idempotente: procesa cada
ref+status(+ evento) una sola vez. - Las notificaciones nunca se envían sin firma.
- Si perdiste una, pide que se reenvíe con Reenviar una notificación, o consulta el depósito con Consultar un depósito.
Cabeceras
| Cabecera | Descripción |
|---|---|
X-Cashela-Event | El evento que disparó esta notificación (tabla de arriba). |
X-Cashela-Timestamp | Hora Unix (segundos) en que se calculó la firma. |
X-Cashela-Nonce | Valor único por entrega (UUID). |
X-Cashela-Signature | HMAC-SHA256 de {timestamp}.{nonce}.{body} con tu api_secret, en hexadecimal en minúsculas (64 caracteres). |
X-Cashela-Trace-Id | Identificador de correlación del depósito. Cítalo a soporte. |
Verificar la firma
El secreto es el api_secret de la clave API que creó el depósito mientras esa clave siga activa; si ya no existe, el de la clave activa más reciente de tu cuenta. Nunca se envía en una notificación.
- Lee
X-Cashela-Timestamp,X-Cashela-NonceyX-Cashela-Signature. Rechaza la petición si falta alguna. - Calcula HMAC-SHA256 sobre el texto
{timestamp}.{nonce}.{body}con tuapi_secret, donde{body}es el cuerpo en bruto exactamente como llegó. Parsear y volver a serializar el JSON cambia los bytes, y la firma no coincidirá. - Compara tu resultado con
X-Cashela-Signaturecon una comparación de tiempo constante. - Rechaza timestamps a más de 300 segundos (5 minutos) de tu reloj, y nonces que ya hayas visto.
// Express: monta esta ruta con express.raw({ type: "application/json" })// para que req.body sea el Buffer en bruto.const crypto = require("crypto");
function isValidCashelaNotification(req, apiSecret) { const timestamp = req.get("X-Cashela-Timestamp"); const nonce = req.get("X-Cashela-Nonce"); const signature = req.get("X-Cashela-Signature"); if (!timestamp || !nonce || !signature) return false; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = crypto .createHmac("sha256", apiSecret) .update(`${timestamp}.${nonce}.${req.body.toString("utf8")}`) .digest("hex"); return expected.length === signature.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));}function isValidCashelaNotification(array $headers, string $rawBody, string $apiSecret): bool{ // $rawBody = file_get_contents('php://input'); $timestamp = $headers['X-Cashela-Timestamp'] ?? null; $nonce = $headers['X-Cashela-Nonce'] ?? null; $signature = $headers['X-Cashela-Signature'] ?? null; if ($timestamp === null || $nonce === null || $signature === null) { return false; } if (abs(time() - (int) $timestamp) > 300) { return false; } $expected = hash_hmac('sha256', $timestamp . '.' . $nonce . '.' . $rawBody, $apiSecret);
return hash_equals($expected, $signature);}import hashlibimport hmacimport time
def is_valid_cashela_notification(headers, raw_body: bytes, api_secret: str) -> bool: # Flask: raw_body = request.get_data(); headers = request.headers timestamp = headers.get("X-Cashela-Timestamp") nonce = headers.get("X-Cashela-Nonce") signature = headers.get("X-Cashela-Signature") if not timestamp or not nonce or not signature: return False if abs(time.time() - int(timestamp)) > 300: return False
message = f"{timestamp}.{nonce}.".encode("utf-8") + raw_body expected = hmac.new(api_secret.encode("utf-8"), message, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature)import java.nio.charset.StandardCharsets;import java.security.MessageDigest;import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;
public final class CashelaSignature {
public static boolean isValid(String timestamp, String nonce, String signature, byte[] rawBody, String apiSecret) throws Exception { if (timestamp == null || nonce == null || signature == null) { return false; } long now = System.currentTimeMillis() / 1000L; if (Math.abs(now - Long.parseLong(timestamp)) > 300) { return false; }
Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); mac.update((timestamp + "." + nonce + ".").getBytes(StandardCharsets.UTF_8)); byte[] digest = mac.doFinal(rawBody);
StringBuilder expected = new StringBuilder(digest.length * 2); for (byte b : digest) { expected.append(String.format("%02x", b)); } return MessageDigest.isEqual( expected.toString().getBytes(StandardCharsets.UTF_8), signature.getBytes(StandardCharsets.UTF_8)); }}Estos ejemplos comprueban el timestamp; además, guarda cada nonce al menos durante la ventana de tolerancia y rechaza los repetidos.
Vectores de prueba
Comprueba tu implementación con estos casos antes de salir a producción. Descárgalos en JSON: webhook-signatures.json.
| Caso | Secreto | Timestamp | Resultado esperado |
|---|---|---|---|
valid | whsec_test_0123456789abcdef | 1759838400 | Válida. Firma b2e80494a3da3d33cfb2c38b1c6c334efb46ed098b4f29f24760da6831de0f6c |
tampered-body | igual | igual | No válida: el cuerpo cambió (status es CANCELLED) pero la firma es la de valid. |
reserialized-body | igual | igual | No válida: el mismo JSON con formato. Firma los bytes en bruto. |
wrong-secret | whsec_test_wrong | igual | No válida. |
stale-timestamp | whsec_test_0123456789abcdef | 100 | No válida: la firma coincide, pero el timestamp está fuera de la tolerancia de 300 segundos. |
Todos los casos usan el nonce 8f14e45f-ceea-467d-9a0b-000000000001 y, salvo donde se indica, exactamente este cuerpo (sin espacios ni salto de línea final):
{"ref":"PI-TEST-0001","external_identifier":"partner-order-0001","status":"COMPLETED","date":"2025-10-07T12:00:00.000000Z","amount":4750.5,"currency":"CLP","settlement_currency":"USD","net_amount":5,"processing_fee_amount":0.25,"fx_spread_amount":0,"tax_amount":0,"card":null}Cuerpo de la notificación
El cuerpo tiene las mismas claves, en el mismo orden, en todos los estados y eventos.
{ "ref": "210000000001", "external_identifier": "order-2026-0002", "status": "COMPLETED", "date": "2026-10-20T15:04:05.000000Z", "amount": 5, "currency": "USD", "settlement_currency": "USD", "net_amount": 4.85, "processing_fee_amount": 0.15, "fx_spread_amount": 0, "tax_amount": 0, "card": { "network": "visa", "last4": "4242" }}| Campo | Tipo | Descripción |
|---|---|---|
ref | string | La reference del depósito. |
external_identifier | string | Tu identificador, tal como lo enviaste al crear el depósito. |
status | string | El estado del depósito después del evento: COMPLETED, DECLINED, CANCELLED o REVERSED. |
date | string | Fecha y hora ISO-8601 del evento. |
amount | number | Lo que se cobró a tu cliente, en currency. |
currency | string | Moneda de amount. |
settlement_currency | string | Moneda de los cuatro importes siguientes. |
net_amount | number | Lo que se te acredita, en settlement_currency. |
processing_fee_amount | number | Comisión de procesamiento, en settlement_currency. |
fx_spread_amount | number | Diferencial cambiario, en settlement_currency. 0 cuando no hubo conversión de moneda. |
tax_amount | number | Impuestos, en settlement_currency. |
card | object | null | network (visa, mastercard, amex, discover, o null si se desconoce) y last4 de la tarjeta que pagó. null en métodos sin tarjeta y antes del cobro. Nunca el número completo, el CVV, el vencimiento ni un token. |
Cómo leer los importes:
amount/currencyes lo que se cobró a tu cliente. En opciones de tarjeta que cobran en USD, es lo cobrado en USD (por ejemplo5.00 USD), no la factura en moneda local. La factura sigue disponible comodetails.invoice_*en Consultar un depósito, ydetails.charged_amount/details.charged_currencycoinciden conamount/currencyde aquí.- Cuando
currencyysettlement_currencyson distintas (por ejemplo, un método en moneda local acreditado en USD),net_amount + processing_fee_amount + fx_spread_amount + tax_amountno sumaamount: están en monedas distintas. - En
DECLINEDyCANCELLED, los importes son la cotización con la que se creó el depósito, no dinero que se movió. No se cobró ni se acredita nada. Concilia dinero solo enCOMPLETEDyREVERSED. - En
REVERSED, el cuerpo conserva los importes originales del depósito. El detalle de la anulación o el reembolso está enreversals.refundsde Consultar un depósito.
Reenviar una notificación
Pide a Cashela que envíe otra vez, ahora, la notificación de un depósito a su notification_url.
/api/v1/pay-in/transactions/{reference}/callback{reference}: lareferencedel depósito, tuexternal_identifiero eliddel depósito.- Solo para un depósito en estado final. Si no,
409(PARTNER_PAYIN_CALLBACK_NOT_FINAL). - Limitado a 6 peticiones por minuto.
- La respuesta es
200aunque tu endpoint no haya aceptado la entrega: revisadata.callback.
curl -X POST https://sandbox-api.cashela.com/api/v1/pay-in/transactions/210000000001/callback \ -u "$CASHELA_API_KEY:$CASHELA_API_SECRET" \ -H "Accept: application/json"{ "success": true, "data": { "transaction": { "reference": "210000000001", "status": "COMPLETED", "…": "…" }, "callback": { "status": "…", "success": true, "reference": "210000000001", "http_status": 200 } }, "message": "…"}Campo de data.callback | Descripción |
|---|---|
success | true si tu endpoint respondió 2xx. |
http_status | Lo que respondió tu endpoint, cuando respondió. |
reason | Si falló sin respuesta, un código estable como callback_url_not_configured o callback_url_blocked. |
Lista de control
- Tu
notification_urles HTTPS, pública y responde2xxen pocos segundos. - Verificas la firma sobre el cuerpo en bruto, el timestamp y el nonce.
- Tu handler es idempotente.
- Lees el evento de
X-Cashela-Event, no del cuerpo. - Concilias dinero solo en
COMPLETEDyREVERSED. - Puedes recuperar una notificación perdida con el endpoint de reenvío o consultando el depósito.