Signature
Every webhook notification we send you (deposits and withdrawals) is signed. The signature lets your endpoint confirm two things before it acts on a notification:
- Authenticity — the request genuinely came from us.
- Integrity — the body was not altered in transit.
Validating the signature on every webhook is how you keep your endpoints safe from spoofed or tampered requests.
Where the signature is
The signature travels in the request header of every webhook call we make to your endpoints:
Authorization: Bearer <signature>
<signature> is the lowercase hex-encoded SHA256 described below.
The formula
signature = SHA256( affiliate_username + raw_json_body + affiliate_username )
affiliate_usernameis the Affiliate Username we provide with your credentials.raw_json_bodyis the exact raw body of the request, byte for byte.- The username is concatenated immediately before and immediately after the raw body, and the whole string is SHA256-hashed and hex-encoded.
To validate a notification, you rebuild this value from the request you received and compare it to the Bearer value in the header.
Always compute the hash from the raw request body exactly as received. Do not parse the JSON into an object and serialize it again before hashing.
Re-serializing changes the bytes — whitespace, key order, and character escaping (for example a / re-encoded as \/, or an accented character like é expanded into its Unicode escape sequence) — even though the data "looks" the same. Any of those differences produces a different hash, so validation fails. This is the most common cause of "signature doesn't match" problems. Read the raw body first, validate the signature, and only then parse the JSON.
Worked example
You can reproduce this end to end.
Affiliate Username (provided with your credentials):
AFFILIATE_TESTING
Raw request body (the exact bytes we send — a deposit notification):
{"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"}
String that gets hashed — affiliate_username + raw body + 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
Resulting signature — SHA256(...), hex-encoded:
a81846290426dfaa2225ed5c810e52f2acb8b23c1678044ae5d43d37b9b85829
This is the value you would receive as Authorization: Bearer a81846290426dfaa2225ed5c810e52f2acb8b23c1678044ae5d43d37b9b85829.
Validating a notification
On your side, for each webhook you receive:
- Read the raw body exactly as received, before any JSON parsing.
- Recompute
SHA256(affiliate_username + raw_body + affiliate_username), hex-encoded, using the Affiliate Username we provided. - Read the header value and strip the
Bearerprefix to get the received signature. - Compare the recomputed signature to the received one using a constant-time comparison (not
==), to avoid leaking timing information. - If they don't match, reject the request with an error response (for example
401 Unauthorized) and do not process it. Because you reject before parsing the body, there is notransaction_numberto echo — return the standard error response withtransaction_numberset tonull. If they match, parse the body and continue.
Receiver-side validation
Each snippet rebuilds the signature from the raw body and compares it in constant time. In every language, the critical line is the one that reads the raw body — if you hash a parsed-and-re-serialized object instead, validation will fail.
PHP
<?php
// Critical: read the RAW body — do not json_decode then re-encode.
$rawBody = file_get_contents('php://input');
$affiliateUsername = 'AFFILIATE_TESTING'; // provided with your credentials
$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;
}
// Signature is valid — safe to decode and process.
$payload = json_decode($rawBody, true);
Node.js (Express)
const crypto = require('crypto');
// Critical: capture the RAW body buffer, not JSON.stringify(req.body).
// Mount this route with: app.use(express.raw({ type: '*/*' }));
app.post('/webhooks/deposits', (req, res) => {
const rawBody = req.body; // Buffer of the raw request body
const affiliateUsername = 'AFFILIATE_TESTING'; // provided with your credentials
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'));
// ... process the notification
res.sendStatus(201);
});
Python (Flask)
import hashlib
import hmac
from flask import request, abort
AFFILIATE_USERNAME = "AFFILIATE_TESTING" # provided with your credentials
@app.post("/webhooks/deposits")
def deposits():
# Critical: read the RAW body bytes, not request.json re-serialized.
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()
# ... process the notification
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"; // provided with your credentials
// Critical: receive the RAW bytes (byte[]), not a parsed/re-serialized object.
@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();
}
// Signature is valid — safe to parse rawBody and process.
return ResponseEntity.status(201).build();
}
}
C# (ASP.NET Core)
using System.Security.Cryptography;
using System.Text;
const string affiliateUsername = "AFFILIATE_TESTING"; // provided with your credentials
app.MapPost("/webhooks/deposits", async (HttpRequest request) =>
{
// Critical: read the RAW body stream, not a re-serialized model.
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();
}
// Signature is valid — safe to parse rawBody and process.
return Results.StatusCode(201);
});