Signature verification
Every E-comm callback from miaPOS carries an RSA signature over the payment result. Verify it before you fulfil the order. This page is the authoritative algorithm — Java, Python, PHP and Node.js reference implementations.
Overview
After a payment reaches a terminal state (SUCCESS, FAILED, refunded), the miaPOS E-comm service POSTs a JSON body to the callbackUrl the merchant supplied on POST /ecomm/api/v1/pay. The body has two top-level fields:
result— the payment outcome object (terminal id, order id, payment id, status, amount, currency, payment date, etc.).signature— an SHA256withRSA signature, produced by miaPOS with its private key over a canonical string built from theresultobject. Base64-encoded.
Verification is a merchant-side responsibility. Anyone can POST to your callbackUrl; the signature is the only proof the payload came from miaPOS. Do not rely on IP allow-listing — rely on this verification.
Algorithm
- Parse the JSON body. Extract the
resultobject and thesignaturestring. - Sort the fields of
resultalphabetically by key. - Concatenate the values (in that sorted order) using
;as separator. This is the canonical string. - Base64-decode
signature. Fetch the public key fromGET /api/v1/public-key, decode it from Base64 into an RSA key. - Verify:
SHA256withRSA(a.k.a. RSASSA-PKCS1-v1_5 with SHA-256) with the public key, over the UTF-8 bytes of the canonical string, against the decoded signature bytes.
The canonical string is deterministic. Given the example payload below, the string prepared for verification is:
145.25;MDL;2024-05-20T16:32:28+03:00;order123;bc340d13-7411-4785-a083-b594b1384eb5;SUCCESS;swift123;SomeBank;123456
result values only — keys are used only for sorting and are not included. Fields with numeric or boolean values are stringified with their native toString(). If miaPOS adds a new field to the result object in the future, it automatically joins the canonical string in its alphabetical position — treat every field of result as signed, whether you use it or not.Callback payload envelope
{
"result": {
"terminalId": "123456",
"orderId": "order123",
"paymentId": "bc340d13-7411-4785-a083-b594b1384eb5",
"status": "SUCCESS",
"amount": 145.25,
"currency": "MDL",
"paymentDate": "2024-05-20T16:32:28+03:00",
"swiftMessageId": "swift123",
"swiftPayerBank": "SomeBank"
},
"signature": "base64_encoded_signature"
}
Fetch the public key
{
"publicKey": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr1f/yhw+UI//z3KdpnJz..."
}
The publicKey field is a Base64-encoded X.509 SubjectPublicKeyInfo (the DER inside the standard PEM -----BEGIN PUBLIC KEY----- block). Cache it on your side; re-fetch after any signature verification failure — the key may have rotated.
Verify (Java)
import com.fasterxml.jackson.databind.ObjectMapper;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
public class SignatureVerification {
/**
* @param jsonString raw callback body — {"result": {...}, "signature": "..."}
* @param publicKeyPEM Base64-encoded RSA public key from GET /api/v1/public-key
*/
public static boolean verifySignature(String jsonString, String publicKeyPEM) {
try {
ObjectMapper objectMapper = new ObjectMapper();
Map<String, Object> jsonMap = objectMapper.readValue(jsonString, Map.class);
Map<String, Object> resultMap = (Map<String, Object>) jsonMap.get("result");
String base64Signature = (String) jsonMap.get("signature");
// Sort keys alphabetically, join VALUES with ";"
Map<String, Object> sortedMap = new TreeMap<>(resultMap);
String signatureString = sortedMap.values().stream()
.map(Object::toString)
.collect(Collectors.joining(";"));
byte[] decodedKey = Base64.getDecoder().decode(publicKeyPEM);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(decodedKey);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PublicKey publicKey = keyFactory.generatePublic(keySpec);
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initVerify(publicKey);
signature.update(signatureString.getBytes());
byte[] signatureBytes = Base64.getDecoder().decode(base64Signature);
return signature.verify(signatureBytes);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
}
Verify (Python)
import json
import base64
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
def verify_signature(json_string: str, public_key_base64: str) -> bool:
try:
json_data = json.loads(json_string)
result = json_data["result"]
signature_base64 = json_data["signature"]
# Sort by keys, join VALUES with ";"
sorted_items = sorted(result.items())
signature_string = ";".join(str(value) for _, value in sorted_items)
pem = (
"-----BEGIN PUBLIC KEY-----\n"
f"{public_key_base64}\n"
"-----END PUBLIC KEY-----"
).encode()
public_key = serialization.load_pem_public_key(pem)
signature = base64.b64decode(signature_base64)
public_key.verify(
signature,
signature_string.encode(),
padding.PKCS1v15(),
hashes.SHA256(),
)
return True
except Exception as e:
print(f"Signature verification failed: {e}")
return False
Verify (PHP)
<?php
function verifyMiaPosSignature(string $jsonBody, string $publicKeyBase64): bool
{
$payload = json_decode($jsonBody, true);
if (!is_array($payload) || !isset($payload['result'], $payload['signature'])) {
return false;
}
// Sort by keys, join VALUES with ";"
ksort($payload['result']);
$signatureString = implode(';', array_map(
static fn($v) => is_bool($v) ? ($v ? 'true' : 'false') : (string)$v,
$payload['result']
));
$pem = "-----BEGIN PUBLIC KEY-----\n"
. chunk_split($publicKeyBase64, 64, "\n")
. "-----END PUBLIC KEY-----\n";
$publicKey = openssl_pkey_get_public($pem);
if ($publicKey === false) {
return false;
}
$signatureBytes = base64_decode($payload['signature'], true);
if ($signatureBytes === false) {
return false;
}
return openssl_verify(
$signatureString,
$signatureBytes,
$publicKey,
OPENSSL_ALGO_SHA256
) === 1;
}
Verify (Node.js)
import crypto from 'node:crypto';
/**
* @param {string} jsonBody Raw callback body
* @param {string} publicKeyBase64 Value of publicKey from GET /api/v1/public-key
*/
export function verifyMiaPosSignature(jsonBody, publicKeyBase64) {
const payload = JSON.parse(jsonBody);
const result = payload.result;
const signatureB64 = payload.signature;
if (!result || !signatureB64) return false;
// Sort by keys, join VALUES with ";"
const signatureString = Object.keys(result)
.sort()
.map((k) => String(result[k]))
.join(';');
const pem =
'-----BEGIN PUBLIC KEY-----\n' +
publicKeyBase64.match(/.{1,64}/g).join('\n') +
'\n-----END PUBLIC KEY-----\n';
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(signatureString, 'utf8');
verifier.end();
return verifier.verify(pem, signatureB64, 'base64');
}
Common pitfalls
- Wrong input. The signed message is not the raw request body — it is the semicolon-joined values of the sorted
resultmap. Signing the raw body will always fail. - Number formatting. Values are stringified with the runtime's default number formatting. Java
Object::toString, Pythonstr(), PHP(string)and JavaScriptString()all produce the same result for the canonicalresultfields; still, if you re-serialize the JSON first, do not force locale-dependent formatting on floats. - Encoding. The bytes passed to the verifier must be UTF-8. Every value in
resultis currently ASCII, but do not assume it — treat the canonical string as UTF-8. - Signature transport. The signature is in the JSON body, not in an HTTP header. Do not look for
X-Signatureor similar headers. - Failed verification → refetch key, retry once. A verification failure usually means the signing key rotated. Re-fetch
GET /api/v1/public-key, verify again with the fresh key; only reject after the second failure.
Key rotation
miaPOS may rotate the signing key at any time — routinely, and immediately on any suspected exposure. Cache the public key on your side, and re-fetch on the first verification failure before deciding the callback is invalid. There is no scheduled rotation calendar; treat rotation as always possible.