Firma
Cada notificación de webhook que te enviamos (depósitos y retiros) va firmada. La firma permite que tu endpoint confirme dos cosas antes de actuar sobre una notificación:
- Autenticidad — la solicitud realmente provino de nosotros.
- Integridad — el cuerpo no fue alterado en tránsito.
Validar la firma en cada webhook es la forma de mantener tus endpoints a salvo de solicitudes falsificadas o manipuladas.
Dónde está la firma
La firma viaja en el header de cada llamada de webhook que hacemos a tus endpoints:
Authorization: Bearer <signature>
<signature> es el SHA256 codificado en hexadecimal (minúsculas) que se describe abajo.
La fórmula
signature = SHA256( affiliate_username + raw_json_body + affiliate_username )
affiliate_usernamees el Affiliate Username que te entregamos con tus credenciales.raw_json_bodyes el cuerpo crudo exacto de la solicitud, byte por byte.- El username se concatena inmediatamente antes e inmediatamente después del cuerpo crudo, y toda la cadena se hashea con SHA256 y se codifica en hexadecimal.
Para validar una notificación, reconstruyes este valor a partir de la solicitud que recibiste y lo comparas con el valor Bearer del header.
Calcula siempre el hash a partir del cuerpo crudo de la solicitud tal como lo recibiste. No parsees el JSON a un objeto y lo serialices de nuevo antes de hashear.
Re-serializar cambia los bytes — espacios en blanco, orden de las claves y el escape de caracteres (por ejemplo una / re-codificada como \/, o un carácter acentuado como é expandido a su secuencia de escape Unicode) — aunque los datos "se vean" iguales. Cualquiera de esas diferencias produce un hash distinto, por lo que la validación falla. Esta es la causa más común de los problemas de "la firma no coincide". Lee primero el cuerpo crudo, valida la firma y solo entonces parsea el JSON.
Ejemplo resuelto
Puedes reproducirlo de principio a fin.
Affiliate Username (entregado con tus credenciales):
AFFILIATE_TESTING
Cuerpo crudo de la solicitud (los bytes exactos que enviamos — una notificación de depósito):
{"status":"success","code":"0000","point_of_sale":{"id":124,"name":"Sucursal de Pruebas","currency_code":"MXN"},"deposit":{"username":"5555555555","description":"Deposito en Cuenta - 4345FF2XB7F323CD","transaction_number":"4345FF2XB7F323CD","amount":100,"currency_code":"MXN"},"created_at":"2019-05-18 13:18:37"}
Cadena que se hashea — affiliate_username + cuerpo crudo + affiliate_username:
AFFILIATE_TESTING{"status":"success","code":"0000","point_of_sale":{"id":124,"name":"Sucursal de Pruebas","currency_code":"MXN"},"deposit":{"username":"5555555555","description":"Deposito en Cuenta - 4345FF2XB7F323CD","transaction_number":"4345FF2XB7F323CD","amount":100,"currency_code":"MXN"},"created_at":"2019-05-18 13:18:37"}AFFILIATE_TESTING
Firma resultante — SHA256(...), codificada en hexadecimal:
a81846290426dfaa2225ed5c810e52f2acb8b23c1678044ae5d43d37b9b85829
Este es el valor que recibirías como Authorization: Bearer a81846290426dfaa2225ed5c810e52f2acb8b23c1678044ae5d43d37b9b85829.
Validar una notificación
De tu lado, para cada webhook que recibas:
- Lee el cuerpo crudo tal como lo recibiste, antes de cualquier parseo de JSON.
- Recalcula
SHA256(affiliate_username + raw_body + affiliate_username), codificado en hexadecimal, usando el Affiliate Username que te entregamos. - Lee el valor del header y quita el prefijo
Bearerpara obtener la firma recibida. - Compara la firma recalculada con la recibida usando una comparación de tiempo constante (no
==), para no filtrar información de temporización. - Si no coinciden, rechaza la solicitud con una respuesta de error (por ejemplo
401 Unauthorized) y no la proceses. Como rechazas antes de parsear el cuerpo, no haytransaction_numberque devolver — responde con la respuesta de error estándar ytransaction_numberennull. Si coinciden, parsea el cuerpo y continúa.
Validación del lado del receptor
Cada fragmento reconstruye la firma a partir del cuerpo crudo y la compara en tiempo constante. En todos los lenguajes, la línea crítica es la que lee el cuerpo crudo — si en su lugar hasheas un objeto parseado y re-serializado, la validación fallará.
PHP
<?php
// Crítico: lee el cuerpo CRUDO — no hagas json_decode y luego re-encode.
$rawBody = file_get_contents('php://input');
$affiliateUsername = 'AFFILIATE_TESTING'; // entregado con tus credenciales
$expected = hash('sha256', $affiliateUsername . $rawBody . $affiliateUsername);
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
$received = preg_replace('/^Bearer\s+/i', '', $header);
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
// La firma es válida — seguro para decodificar y procesar.
$payload = json_decode($rawBody, true);
Node.js (Express)
const crypto = require('crypto');
// Crítico: captura el buffer del cuerpo CRUDO, no JSON.stringify(req.body).
// Monta esta ruta con: app.use(express.raw({ type: '*/*' }));
app.post('/webhooks/deposits', (req, res) => {
const rawBody = req.body; // Buffer del cuerpo crudo de la solicitud
const affiliateUsername = 'AFFILIATE_TESTING'; // entregado con tus credenciales
const expected = crypto
.createHash('sha256')
.update(affiliateUsername + rawBody.toString('utf8') + affiliateUsername)
.digest('hex');
const received = (req.get('authorization') || '').replace(/^Bearer\s+/i, '');
const a = Buffer.from(expected);
const b = Buffer.from(received);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
const payload = JSON.parse(rawBody.toString('utf8'));
// ... procesa la notificación
res.sendStatus(201);
});
Python (Flask)
import hashlib
import hmac
from flask import request, abort
AFFILIATE_USERNAME = "AFFILIATE_TESTING" # entregado con tus credenciales
@app.post("/webhooks/deposits")
def deposits():
# Crítico: lee los bytes del cuerpo CRUDO, no request.json re-serializado.
raw_body = request.get_data()
expected = hashlib.sha256(
AFFILIATE_USERNAME.encode() + raw_body + AFFILIATE_USERNAME.encode()
).hexdigest()
received = request.headers.get("Authorization", "")
received = received.removeprefix("Bearer ").strip()
if not hmac.compare_digest(expected, received):
abort(401)
payload = request.get_json()
# ... procesa la notificación
return "", 201
Java (Spring)
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
public class WebhookController {
private static final String AFFILIATE_USERNAME = "AFFILIATE_TESTING"; // entregado con tus credenciales
// Crítico: recibe los bytes CRUDOS (byte[]), no un objeto parseado/re-serializado.
@PostMapping("/webhooks/deposits")
public ResponseEntity<Void> deposits(
@RequestBody byte[] rawBody,
@RequestHeader("Authorization") String authHeader) throws Exception {
byte[] user = AFFILIATE_USERNAME.getBytes(StandardCharsets.UTF_8);
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(user);
md.update(rawBody);
md.update(user);
String expected = HexFormat.of().formatHex(md.digest());
String received = authHeader.replaceFirst("(?i)^Bearer\\s+", "");
boolean ok = MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
received.getBytes(StandardCharsets.UTF_8));
if (!ok) {
return ResponseEntity.status(401).build();
}
// La firma es válida — seguro para parsear rawBody y procesar.
return ResponseEntity.status(201).build();
}
}
C# (ASP.NET Core)
using System.Security.Cryptography;
using System.Text;
const string affiliateUsername = "AFFILIATE_TESTING"; // entregado con tus credenciales
app.MapPost("/webhooks/deposits", async (HttpRequest request) =>
{
// Crítico: lee el stream del cuerpo CRUDO, no un modelo re-serializado.
request.EnableBuffering();
using var reader = new StreamReader(request.Body, Encoding.UTF8, leaveOpen: true);
string rawBody = await reader.ReadToEndAsync();
request.Body.Position = 0;
byte[] expected = SHA256.HashData(
Encoding.UTF8.GetBytes(affiliateUsername + rawBody + affiliateUsername));
string header = request.Headers.Authorization.ToString();
string receivedHex = header.Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase).Trim();
byte[] received = Convert.FromHexString(receivedHex);
if (!CryptographicOperations.FixedTimeEquals(expected, received))
{
return Results.Unauthorized();
}
// La firma es válida — seguro para parsear rawBody y procesar.
return Results.StatusCode(201);
});