Webhook Signature Verification
To guarantee authenticity and prevent man-in-the-middle tampering, Amwal signs every outgoing webhook dispatch using RSA-PSS with SHA-256. Merchants must verify the X-Signature header against the raw incoming request bytes before trusting or executing any payment events.
Cryptographic Specification
| Parameter | Specification | Description |
|---|---|---|
| Algorithm | RSA-PSS | Probabilistic Signature Scheme (PKCS#1 v2.1). |
| Hashing Algorithm | SHA-256 | 256-bit secure hash function. |
| Padding Mode | RSA_PKCS1_PSS_PADDING | PSS padding mode with MGF1 mask generation. |
| Salt Length | 32 bytes (Digest length) | Matches the SHA-256 output digest size (32 bytes). |
| Signature Encoding | Base64 | Standard Base64 ASCII string passed in the X-Signature header. |
| Verification Key | api_key.public_key | RSA Public Key (PEM format) received during Webhook Setup. |
Webhook Delivery Headers
Every incoming webhook request contains the following authentication headers:
| Header | Description |
|---|---|
X-Signature | Base64-encoded RSA-PSS SHA-256 cryptographic signature. |
X-Api-Key | Webhook API Key identifier prefix. |
X-Amwal-Key | Delivery tracking key for idempotency checking. |
Content-Type | application/json |
Critical Rule: Verify Against Raw Body Bytes
Never Re-Serialize JSON Before Verification
Cryptographic hashing operates on the exact byte sequence sent over the wire. Re-serializing a parsed JavaScript object (JSON.stringify(req.body)) will alter whitespace, key ordering, and floating-point representations, causing verification to fail.
Always capture and verify against the raw unparsed request Buffer / byte array.
Inputs
Verification requires three values: the RSA public key, the webhook payload, and the Base64-encoded signature. Replace the placeholders below with your actual values:
const publicKeyPem = `-----BEGIN PUBLIC KEY-----
YOUR_AMWAL_PUBLIC_KEY_PEM_HERE
-----END PUBLIC KEY-----`;
const payload = {
"amount": "29.00000",
"client_first_name": "fname ",
"client_last_name": "lname",
"payment_link_id": "9493559e-yyyyyyb",
"payment_option": "Pay In Full",
"status": "success",
"transaction_id": "ccdaaba6-xxxxx5"
};
const signatureBase64 = "amwal - signature";Verification Implementations
Node.js / Express
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// 1. Capture the raw body Buffer before JSON parsing
app.use(express.json({
verify: (req, _res, buf) => {
req.rawBody = buf;
}
}));
const AMWAL_PUBLIC_KEY = process.env.AMWAL_PUBLIC_KEY || `-----BEGIN PUBLIC KEY-----
YOUR_AMWAL_PUBLIC_KEY_PEM_HERE
-----END PUBLIC KEY-----`;
function verifyAmwalSignature(rawBodyBuffer, signatureBase64) {
try {
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(rawBodyBuffer);
return verifier.verify(
{
key: AMWAL_PUBLIC_KEY,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
},
signatureBase64,
'base64'
);
} catch (err) {
console.error('Signature verification threw an exception:', err);
return false;
}
}
app.post('/webhook', (req, res) => {
const signature = req.headers['x-signature'];
if (!signature || !req.rawBody) {
return res.status(400).send('Missing X-Signature header or request body');
}
// 2. Perform verification
const isValid = verifyAmwalSignature(req.rawBody, signature);
if (!isValid) {
console.warn('Rejected incoming webhook: Invalid RSA-PSS signature');
return res.status(403).send('Invalid signature');
}
// 3. Process verified event
const payload = req.body;
console.log(`Verified webhook event: ${payload.event_type} (ID: ${payload.data?.id})`);
res.status(200).send('OK');
});Python (Cryptography)
import base64
import os
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.serialization import load_pem_public_key
PUBLIC_KEY_PEM = os.getenv("AMWAL_PUBLIC_KEY").encode("utf-8")
public_key = load_pem_public_key(PUBLIC_KEY_PEM)
def verify_amwal_signature(raw_body_bytes: bytes, signature_base64: str) -> bool:
try:
signature_bytes = base64.b64decode(signature_base64)
public_key.verify(
signature_bytes,
raw_body_bytes,
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.DIGEST_LENGTH
),
hashes.SHA256()
)
return True
except Exception as e:
print(f"Signature mismatch: {e}")
return FalseCLI / OpenSSL Verification
For manual testing, debugging, or incident response, you can verify a captured raw webhook payload using the OpenSSL CLI:
# 1. Save signature to base64-decoded binary file
echo "BASE64_SIGNATURE_STRING_HERE" | base64 --decode > signature.bin
# 2. Verify signature against the raw payload file using RSA-PSS SHA-256
openssl dgst -sha256 \
-sigopt rsa_padding_mode:pss \
-sigopt rsa_pss_saltlen:digest \
-verify amwal_public_key.pem \
-signature signature.bin \
raw_payload.jsonOutput on success:
Verified OKTroubleshooting Common Verification Errors
| Symptom | Cause | Solution |
|---|---|---|
| Verification fails on all valid payloads | Using req.body parsed JSON instead of raw Buffer. | Capture raw body buffer before JSON middleware runs. |
| Signature decoding error | Whitespace or newlines inside X-Signature header string. | Trim whitespace (signature.trim()) before Base64 decoding. |
| Invalid public key format | Missing -----BEGIN PUBLIC KEY----- and -----END PUBLIC KEY----- headers. | Ensure public key PEM format includes exact standard delimiters. |
| Mismatch between environments | Sandbox webhooks verified using Production Public Key (or vice-versa). | Isolate AMWAL_PUBLIC_KEY_SANDBOX and AMWAL_PUBLIC_KEY_PROD environment variables. |
Related Documentation
Webhook Setup
Register your webhook endpoint and obtain your RSA public verification key via the Amwal Registration API.
Webhook Implementation Examples
Complete, production-ready backend webhook receiver examples with RSA-PSS SHA-256 signature verification and event routing in Node.js, Python, PHP, and Go.
