Webhooks
Cashela notifica a tu servidor con un POST firmado cuando una transacción cambia de estado. Pay-In y Pay-Out usan las mismas cabeceras y el mismo esquema de firma; el cuerpo es propio de cada producto.
| Producto | De dónde sale la URL | Cuerpo y eventos |
|---|---|---|
| Pay-In | notification_url de cada depósito | Webhooks de Pay-In |
| Pay-Out | callback_url de cada transacción | Crear una transacción |
Cabeceras
| Cabecera | Descripción |
|---|---|
X-Cashela-Event | El evento, por ejemplo partner.payin.transaction.updated o partner.payout.transaction.updated. El cuerpo no lo lleva. |
X-Cashela-Timestamp | Hora Unix (segundos) de 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. |
X-Cashela-Trace-Id | Identificador de correlación; cítalo a soporte. |
Verificar la firma
- Lee las tres cabeceras de firma; rechaza la petición si falta alguna.
- Calcula HMAC-SHA256 sobre
{timestamp}.{nonce}.{cuerpo en bruto}con elapi_secretde la clave que creó la transacción mientras siga activa (o la clave activa más reciente de tu cuenta si fue eliminada). Usa el cuerpo exactamente como llegó. - Compara con
X-Cashela-Signatureen tiempo constante. - Rechaza timestamps de más de 300 segundos 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)); }}Prueba tu implementación con los vectores de prueba de la firma.
Entrega
- Responde cualquier
2xxrápido. Cualquier otro estado, un timeout o un error de conexión es una entrega fallida. Pay-In reintenta las entregas fallidas; en Pay-Out no cuentes con reintentos automáticos: consulta la transacción o pide un reenvío. - La misma notificación puede llegar más de una vez: haz tu handler idempotente.
- Las notificaciones nunca se envían sin firma.
- ¿Perdiste una? Pide que se reenvíe:
POST /api/v1/pay-in/transactions/{reference}/callback(Pay-In) oPOST /api/v1/pay-out/transactions/{reference}/callback(Pay-Out), o consulta la transacción. - Que tu cliente vuelva a tu sitio nunca confirma un pago; solo lo confirman la notificación o una consulta de la transacción.