Signing and verifying webhooks

How to decrypt and verify the encrypted webhook payload Scan to Pay sends to your endpoint — including working code samples in five languages.

Scan to Pay encrypts every webhook payload with a symmetric key unique to your merchant account. The encryption serves as both confidentiality and authenticity. If your handler can decrypt the body to a well-formed JSON object, you know the message came from Scan to Pay and hasn't been tampered with. If decryption fails, the payload is fake (or the key is wrong) — never trust it.

This page covers everything you need to verify and decrypt webhooks: the scheme, getting your key, working code samples in five languages, and security recommendations.

📘

The big-picture flow lives on the Webhooks page. This page is the cryptographic deep-dive — read Webhooks first if you haven't.


The encryption scheme

PropertyValue
AlgorithmAES (Advanced Encryption Standard)
ModeCBC (Cipher Block Chaining)
PaddingPKCS5
Key length128-bit (16 bytes)
Key encodingHex-encoded string in the Portal — decode to raw bytes before use
IV (initialisation vector)16 zero bytes (new byte[16])
Body encodingThe encrypted ciphertext is base64-encoded before being sent in the HTTP POST body

Put differently: the HTTP POST body is a base64 string. Decode it to ciphertext bytes. Decrypt those bytes with AES-128-CBC using your hex-decoded key and a 16-byte zero IV. The result is a UTF-8 JSON string that matches the payload structure documented on Webhooks.


Get your notification key

  1. Log in to the Scan to Pay Portal — sandbox or production — as your merchant administrator.
  2. Open the email dropdown (top right) → Notifications.
  3. Click Generate notification key (or Renew if you already have one).
  4. Copy the hex string and store it in your secrets manager.

The key looks like:

0123456789abcdef0123456789abcdef

That's 32 hex characters = 16 raw bytes = 128 bits. Never embed the key in source control or client-side code.

⚠️

Renewing the key invalidates the previous one immediately. Coordinate the rotation with a deploy — any webhooks delivered between the rotation and your deploy will be encrypted with the new key and decryption will fail.


Decryption walkthrough

For every webhook your handler receives:

  1. Read the raw request body. Don't try to JSON-parse it — it's not JSON yet.
  2. Base64-decode the body to ciphertext bytes.
  3. Hex-decode your notification key to raw key bytes (16 bytes).
  4. Initialise an AES-128-CBC cipher with the key and a 16-byte zero IV.
  5. Decrypt the ciphertext to plaintext bytes.
  6. UTF-8-decode the plaintext to a JSON string and parse it.
  7. Validate that the result contains required fields like transactionId and status. If any are missing, treat the payload as fake.
  8. Return HTTP 200 to acknowledge.

If any step fails, return a non-200 and log the failure.


Code samples

The samples below all decrypt a Scan to Pay webhook body to a JSON string. Each takes two inputs: the raw POST body and your hex-encoded notification key.

Java

import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
import org.apache.commons.codec.binary.Hex;

public class ScanToPayWebhook {

    public static String decrypt(byte[] requestBody, String hexKey) throws Exception {
        byte[] ciphertext = Base64.getDecoder().decode(requestBody);
        byte[] keyBytes   = Hex.decodeHex(hexKey.toCharArray());
        byte[] iv         = new byte[16]; // 16 zero bytes

        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        cipher.init(
            Cipher.DECRYPT_MODE,
            new SecretKeySpec(keyBytes, "AES"),
            new IvParameterSpec(iv));

        byte[] plaintext = cipher.doFinal(ciphertext);
        return new String(plaintext, "UTF-8");
    }
}

Node.js

const crypto = require('crypto');

function decryptScanToPayWebhook(requestBody, hexKey) {
  const ciphertext = Buffer.from(requestBody.toString(), 'base64');
  const key        = Buffer.from(hexKey, 'hex');
  const iv         = Buffer.alloc(16); // 16 zero bytes

  const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
  decipher.setAutoPadding(true); // PKCS5 == PKCS7 in this length

  const plaintext = Buffer.concat([
    decipher.update(ciphertext),
    decipher.final(),
  ]);

  return plaintext.toString('utf8');
}

// Example with Express
app.post('/webhooks/scantopay', express.raw({ type: '*/*' }), (req, res) => {
  try {
    const json = decryptScanToPayWebhook(req.body, process.env.STP_NOTIFICATION_KEY);
    const payload = JSON.parse(json);
    // ... persist payload to your queue, then ...
    res.status(200).send();
  } catch (err) {
    console.error('Webhook decrypt failed', err);
    res.status(400).send();
  }
});

Python

import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

def decrypt_scantopay_webhook(request_body: bytes, hex_key: str) -> str:
    ciphertext = base64.b64decode(request_body)
    key        = bytes.fromhex(hex_key)
    iv         = bytes(16)  # 16 zero bytes

    cipher = AES.new(key, AES.MODE_CBC, iv)
    plaintext = unpad(cipher.decrypt(ciphertext), AES.block_size)
    return plaintext.decode('utf-8')


# Example with Flask
from flask import Flask, request
import os, json

app = Flask(__name__)

@app.route('/webhooks/scantopay', methods=['POST'])
def webhook():
    try:
        json_str = decrypt_scantopay_webhook(
            request.get_data(),
            os.environ['STP_NOTIFICATION_KEY'])
        payload = json.loads(json_str)
        # ... persist payload to your queue, then ...
        return '', 200
    except Exception as e:
        app.logger.error(f'Webhook decrypt failed: {e}')
        return '', 400

Go

package webhook

import (
    "bytes"
    "crypto/aes"
    "crypto/cipher"
    "encoding/base64"
    "encoding/hex"
    "errors"
)

func DecryptScanToPayWebhook(body []byte, hexKey string) (string, error) {
    ciphertext, err := base64.StdEncoding.DecodeString(string(body))
    if err != nil {
        return "", err
    }
    key, err := hex.DecodeString(hexKey)
    if err != nil {
        return "", err
    }
    block, err := aes.NewCipher(key)
    if err != nil {
        return "", err
    }
    if len(ciphertext)%aes.BlockSize != 0 {
        return "", errors.New("ciphertext is not a multiple of the block size")
    }
    iv := make([]byte, 16) // 16 zero bytes
    cbc := cipher.NewCBCDecrypter(block, iv)
    plaintext := make([]byte, len(ciphertext))
    cbc.CryptBlocks(plaintext, ciphertext)
    // strip PKCS5/PKCS7 padding
    padLen := int(plaintext[len(plaintext)-1])
    if padLen < 1 || padLen > aes.BlockSize {
        return "", errors.New("invalid padding")
    }
    plaintext = bytes.TrimRight(plaintext, string(plaintext[len(plaintext)-1]))
    return string(plaintext[:len(plaintext)-padLen+1]), nil
}

C# / .NET

using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;

public static class ScanToPayWebhook
{
    public static string Decrypt(byte[] requestBody, string hexKey)
    {
        var bodyText   = Encoding.UTF8.GetString(requestBody);
        var ciphertext = Convert.FromBase64String(bodyText);
        var key        = Convert.FromHexString(hexKey);
        var iv         = new byte[16]; // 16 zero bytes

        using var aes = Aes.Create();
        aes.Key     = key;
        aes.IV      = iv;
        aes.Mode    = CipherMode.CBC;
        aes.Padding = PaddingMode.PKCS7; // PKCS5 == PKCS7 for AES

        using var decryptor = aes.CreateDecryptor();
        using var ms        = new MemoryStream(ciphertext);
        using var cs        = new CryptoStream(ms, decryptor, CryptoStreamMode.Read);
        using var sr        = new StreamReader(cs, Encoding.UTF8);
        return sr.ReadToEnd();
    }
}

Validate the result

Decryption succeeds is necessary but not sufficient. The decrypted JSON must contain the fields you expect. Minimum sanity checks:

const payload = JSON.parse(json);

// 1. Required fields present
if (!payload.transactionId || !payload.status || !payload.reference) {
  throw new Error('Webhook missing required fields');
}

// 2. Reference matches one of your orders
const order = await orders.findByReference(payload.reference);
if (!order) {
  throw new Error('Unknown merchant reference');
}

// 3. Amount matches what you expected
if (Math.abs(payload.amount - order.amount) > 0.001) {
  throw new Error('Amount mismatch — possible replay or tampering');
}

// All good — process the notification (async if possible, then return 200)

Test-mode probe

When you press the Check button on the Portal Notifications page, Scan to Pay sends an unencrypted test payload to your URL:

{ "result": "TEST" }

This is useful for testing connectivity (your endpoint is reachable, returns 200, etc.) but it doesn't exercise your decryption logic. To test that, run a real sandbox transaction — the resulting webhook is encrypted exactly as production webhooks are. See Quickstart.

📘

Your handler should distinguish probes from real webhooks. A safe pattern: try to decrypt first; if decryption fails and the raw body starts with {, treat it as a probe and log it. If decryption fails and the body doesn't look like JSON either, treat it as suspicious traffic.


Security considerations

The encryption scheme has been operational for years and is supported by every major SA bank's integration. A few things to know:

Key handling

  • Store the key in a secrets manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault). Never check it into git, never put it in client-side code, never put it in an env var that's logged by your platform.
  • Rotate keys on whatever cadence your security policy specifies. The recommended industry baseline is every 90 days.
  • Different key per environment. Sandbox and production each have their own key.

Defense in depth

The encryption alone proves authenticity, but layer additional protections:

  • IP allow-list the Scan to Pay outbound IPs at your firewall — see Network whitelisting.
  • Require HTTPS on your endpoint. Production allows only ports 443 and 8443.
  • Rate-limit your webhook endpoint at your edge to absorb DoS attempts.
  • Reject before decrypt — if the body isn't valid base64 or is implausibly large (> 50 KB), short-circuit and return 400 before attempting decryption.

What the all-zero IV means in practice

Standard AES-CBC uses a per-message random IV to prevent equal plaintexts from producing equal ciphertexts. Scan to Pay's webhooks use a fixed zero IV, which means two webhooks with identical plaintext would produce identical ciphertext.

In practice this is mostly theoretical for webhook payloads because:

  • Every payload contains a unique transactionId, RRN, auth code, and timestamp.
  • The first few bytes of the JSON ({"transactionId": and then a unique integer) vary per transaction.
  • The risk is information leakage about predictable plaintext patterns, not key recovery.

That said, if your security review flags the null IV and you need a more modern scheme (authenticated encryption with associated data, or HMAC-signed payloads), contact [email protected]. The platform team can advise on a migration path.

Never log decrypted payloads

The decrypted payload contains the cardholder's MSISDN, BIN+last4, cardholder name, and sometimes contact and shipping info. Treat the JSON as PII:

  • Don't write it to general application logs.
  • Don't include it in error tracking systems (Sentry, Bugsnag, etc.) without scrubbing.
  • Don't echo it in HTTP response bodies — you only need to return 200, not the payload.

Troubleshooting

SymptomLikely cause
BadPaddingException (Java) / ValueError: Padding is incorrect. (Python) / crypto: bad padding (Go)Wrong key. Verify you're using the hex-decoded form of the key, not the hex string itself.
IllegalBlockSizeException (Java) / similar errorsCiphertext length isn't a multiple of 16 bytes. Confirm you base64-decoded the raw body, not a string containing extra whitespace or headers.
Decryption succeeds, JSON parse failsYou may be receiving the test-mode probe ({ "result": "TEST" }) which is sent unencrypted. Handle that case specifically.
Decryption succeeds but transactionId is missingCould be an external notification (refund / reversal originated outside the merchant flow). Inspect the full payload — required fields may differ.
Sporadic decrypt failures, mostly succeedingLikely a key rotation that hasn't propagated to your deploy. Re-fetch the current key from the portal and redeploy.
Every request fails decryptionVerify base64 decoding is the first step, not last. Verify the IV is 16 zero bytes, not 16 of some other byte.

If you can decrypt some payloads but not others, capture two raw bodies — one that works and one that fails — and send them (along with your hex-encoded key sanitised) to [email protected].


What's next