Saltar al contenido principal

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_username es el Affiliate Username que te entregamos con tus credenciales.
  • raw_json_body es 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.

Hashea el cuerpo crudo, nunca una versión re-serializada

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 hasheaaffiliate_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 resultanteSHA256(...), 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:

  1. Lee el cuerpo crudo tal como lo recibiste, antes de cualquier parseo de JSON.
  2. Recalcula SHA256(affiliate_username + raw_body + affiliate_username), codificado en hexadecimal, usando el Affiliate Username que te entregamos.
  3. Lee el valor del header y quita el prefijo Bearer para obtener la firma recibida.
  4. Compara la firma recalculada con la recibida usando una comparación de tiempo constante (no ==), para no filtrar información de temporización.
  5. 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 hay transaction_number que devolver — responde con la respuesta de error estándar y transaction_number en null. 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);
});