Verify webhook signatures

📘

Verifying webhook signatures is optional but recommended for security reasons.

Webhook signature

Webhooks sent by Numeral are signed. Verifying the signature helps ensure the authenticity and integrity of webhook payloads sent by Numeral. A valid signature means that the message comes from Numeral and that the content has not been tampered with or altered in transit.

The signature is included in the TX-Numeral-Signature-1 HTTP header of each webhook event. Each time Numeral rotates its public key, a new TX-Numeral-Signature-X HTTP header with an incremental version number will be provided to be retro-compatible until customers migrate their code.

You should verify this signature using the RSASSA-PKCS1-v1_5 signature verification algorithm, with the following public keys:

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoPYyfIykF1MH1A1G1qme
5b9z4U0ALKdY69gH9rual3ZmhX2+8WtGRrI3ND2NOp/VPOsOHLq/81Vl8om+y0OZ
vHHhaCi7yx+A7VS6dB+iy+5Uo4ILh4Srx58oDL2lfhuZPc+BsgP1bP3KZp5OAV29
eZFjKPqi+yIbZyOf2HgmxawXrRfhCZf3GNYUP2Ihb9z0URYzpswezoog0ql1V7b1
TzspGflPfBp0kXTsqk8bRkGbAYPOAM7w9/GJ8X/IaGhgjrikrzYqp1srKXCqHruW
Dr9VKwoG49AzFEptSQ6lqt6T9kImPiUkF0r9Xcq5h1YBgXGYr9EQHWG8146tQ+nd
CQIDAQAB
-----END PUBLIC KEY-----
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnxkk9/CVep8z9ZDq55VA
6tdPe/ODF7/SjB/MaFlGgsKMfZxBAKGvjGtK5FJriAq4i+k8aTULYkojOGHVQIYT
di5qKx9xaRs+c45c2sPudWOlLEzs3aJG/jQNolZAQ4aSx+qYwT54v8LcN61IxOkc
5ZlLXGsPfkL96DdPOstjv0fp/SfyCTibREmE+JpcEVPiGkfw+f5JYOQEVThMDCa4
uN/sJKvDO/NhF6cXTv3Hb8C0yYLfIO/vkzkXomUuY+Va47liL1pwik87yHeyTAAe
QOuknnV2K/sXeBU14b/96CP2v0H8h27w11IokpJwzOIMCQnh5zpaBeXAEjr+yr4f
aQIDAQAB
-----END PUBLIC KEY-----

In addition to the signature header, every webhook event holds a TX-Numeral-Request-Timestamp HTTP header that contains the UTC Unix timestamp of the time the event was triggered.

Signature verification process

Numeral’s webhook signature verification process involves the following variables:

  • The request body
  • The request timestamp (TX-Numeral-Request-Timestamp HTTP header)
  • The request signature (TX-Numeral-Signature-X HTTP header, "X" being the latest version)

Verification steps:

  • Start by concatenating the request raw body with the dot . character, followed by the request timestamp ({request_body}.{request_timestamp}). Here is an example:
{"id":"cf1ce879-f779-4b4b-bd9a-0747ef3dc18b","object":"event","topic":"file","type":"created","data":{"id":"6312697e-a11f-4f11-84cf-8e32a9cfc289","size":2605,"format":"pacs.008","object":"file","status":"created","summary":{},"category":"payment_file","filename":"211018231401AecA.1634551061.xml","bank_data":{"message_id":""},"direction":"outgoing","created_at":"2021-10-18T09:57:41.559011Z","status_details":"generated on 2021-10-18","connected_account":"22222222-0000-0000-0000-000000000001"},"status":"delivered","status_details":"","related_object_id":"6312697e-a11f-4f11-84cf-8e32a9cfc289","related_object_type":"file","created_at":"2021-10-18T09:57:41.586741Z"}.1666192986
  • Using the SHA 256 hashing function, produce the hash of the above concatenation
  • Base64 decode the request signature
  • Use the RSASSA-PKCS1-v1_5 algorithm to verify the signature using the produced hash, the decoded request signature and Numeral’s latest public key

Code samples

Below are a few code samples in various languages. Let us know if you'd like us to document additional languages.

package main

import (
	"crypto"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/x509"
	"encoding/base64"
	"encoding/pem"
	"errors"
	"log"
)

var signature string = `Xt9B54lOqLCCkNrjLdSp1KuYKYO8zmm274koTNYtNjZEgWiGk3cHHod4KKSdYVt5OzrPNGz3HgJpc1cxUmLS11ng1IP7aXqM3pzTGJHycAUxbEqd4OhNNr/bjyScSAeiogesQmaBMWNcuUNa/7Up0isCmuySPlIV81jL6GRu9GXu88EeHwGaWd4Kzg7HMOciB48ueB3XLwUo9ez1WPoooJ9bfzDxrSfhPpAx9CoUuEH3aXYJpVuTUjtI8WnvhWuVIUGscUUzbAomEM+y9CImHDZP0QSEPfVYpWt/r8QcG/zukhvuWNSHtAPnqxaU8LOgdfsVSgQxBcMDlNgPCZn+cA==`
var publicKey string = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3KAvpLM4ng9ppG/Z3kQF
3fRWYUYpJ2Z2h+DIcGuXyP2Hn5PAxwHNTQj0nXzpmsOCO8C1TghKwfDaFcGCfURQ
t/o8E1LmS5/ckMWsQwxKNbiwLlrFZFo8opdAOA+OTORdqq6+J18YRTCJEMClKkvI
AsDmgFANWApLkYx+r9pE9Kdasu3MTvVs0DpQNPG1guFwXUoVEEYIX7nmZvfUdqgM
bo1NQRvmAVOwWz2HpQ6b2t478IKMX+PHRs9Tn00/owKtAAoGj470IERXMNIZqBQu
geo558phv+J2hmc+CWp4hgO9skeZD71iCA5rd8PdZmj+SU0u/1eyKfE9zAtVfj4H
awIDAQAB
-----END PUBLIC KEY-----`
var timestamp = "1666272169"
var body = "{webhook_body}"

func main() {
	numeralPublicKey, err := parseRSAPublicKeyFromPEM(publicKey)
	if err != nil {
		log.Fatal(err)
	}

	decodedSignature, err := base64.StdEncoding.DecodeString(signature)
	if err != nil {
		log.Fatal(err)
	}

	// Build the hash with the webhook body, the . character and the timestamp.
	hashed := sha256.Sum256([]byte(body + "." + timestamp))

	// Use the RSA PKCS #1 v1.5 algorithm to verify the signature.
	err = rsa.VerifyPKCS1v15(numeralPublicKey, crypto.SHA256, hashed[:], decodedSignature)
	if err != nil {
		log.Fatal(err)
	}

	log.Println("Signature is OK.")
}

func parseRSAPublicKeyFromPEM(pemString string) (*rsa.PublicKey, error) {
	block, _ := pem.Decode([]byte(pemString))
	if block == nil {
		return nil, errors.New("failed to parse public key from PEM block")
	}

	pub, err := x509.ParsePKIXPublicKey(block.Bytes)
	if err != nil {
		return nil, err
	}

	pkey, ok := pub.(*rsa.PublicKey)
	if !ok {
		return nil, errors.New("key is not of RSA type")
	}

	return pkey, nil
}
import base64
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding, utils
from cryptography.hazmat.primitives.serialization import load_pem_public_key
from cryptography.exceptions import InvalidSignature
import logging

signature = "Xt9B54lOqLCCkNrjLdSp1KuYKYO8zmm274koTNYtNjZEgWiGk3cHHod4KKSdYVt5OzrPNGz3HgJpc1cxUmLS11ng1IP7aXqM3pzTGJHycAUxbEqd4OhNNr/bjyScSAeiogesQmaBMWNcuUNa/7Up0isCmuySPlIV81jL6GRu9GXu88EeHwGaWd4Kzg7HMOciB48ueB3XLwUo9ez1WPoooJ9bfzDxrSfhPpAx9CoUuEH3aXYJpVuTUjtI8WnvhWuVIUGscUUzbAomEM+y9CImHDZP0QSEPfVYpWt/r8QcG/zukhvuWNSHtAPnqxaU8LOgdfsVSgQxBcMDlNgPCZn+cA=="
public_key = """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3KAvpLM4ng9ppG/Z3kQF
3fRWYUYpJ2Z2h+DIcGuXyP2Hn5PAxwHNTQj0nXzpmsOCO8C1TghKwfDaFcGCfURQ
t/o8E1LmS5/ckMWsQwxKNbiwLlrFZFo8opdAOA+OTORdqq6+J18YRTCJEMClKkvI
AsDmgFANWApLkYx+r9pE9Kdasu3MTvVs0DpQNPG1guFwXUoVEEYIX7nmZvfUdqgM
bo1NQRvmAVOwWz2HpQ6b2t478IKMX+PHRs9Tn00/owKtAAoGj470IERXMNIZqBQu
geo558phv+J2hmc+CWp4hgO9skeZD71iCA5rd8PdZmj+SU0u/1eyKfE9zAtVfj4H
awIDAQAB
-----END PUBLIC KEY-----"""
timestamp = "1666272169"
body = "{webhook_body}"

def main():
    try:
        numeral_public_key = load_pem_public_key(public_key.encode())

        decoded_signature = base64.b64decode(signature)

        # Build the hash with the webhook body, the '.' character, and the timestamp.
        hashed = hashes.Hash(hashes.SHA256())
        hashed.update((body + "." + timestamp).encode())
        digest = hashed.finalize()

        # Use the RSA PKCS #1 v1.5 algorithm to verify the signature.
        numeral_public_key.verify(
            decoded_signature,
            digest,
            padding.PKCS1v15(),
            utils.Prehashed(hashes.SHA256())
        )

        logging.info("Signature is OK.")
    except InvalidSignature:
        logging.error("Signature verification failed.")
    except Exception as e:
        logging.error(f"An error occurred: {str(e)}")

if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    main()

import * as crypto from 'crypto';

// Provided data
const signatureBase64: string = "Xt9B54lOqLCCkNrjLdSp1KuYKYO8zmm274koTNYtNjZEgWiGk3cHHod4KKSdYVt5OzrPNGz3HgJpc1cxUmLS11ng1IP7aXqM3pzTGJHycAUxbEqd4OhNNr/bjyScSAeiogesQmaBMWNcuUNa/7Up0isCmuySPlIV81jL6GRu9GXu88EeHwGaWd4Kzg7HMOciB48ueB3XLwUo9ez1WPoooJ9bfzDxrSfhPpAx9CoUuEH3aXYJpVuTUjtI8WnvhWuVIUGscUUzbAomEM+y9CImHDZP0QSEPfVYpWt/r8QcG/zukhvuWNSHtAPnqxaU8LOgdfsVSgQxBcMDlNgPCZn+cA=="
const publicKey: string = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3KAvpLM4ng9ppG/Z3kQF
3fRWYUYpJ2Z2h+DIcGuXyP2Hn5PAxwHNTQj0nXzpmsOCO8C1TghKwfDaFcGCfURQ
t/o8E1LmS5/ckMWsQwxKNbiwLlrFZFo8opdAOA+OTORdqq6+J18YRTCJEMClKkvI
AsDmgFANWApLkYx+r9pE9Kdasu3MTvVs0DpQNPG1guFwXUoVEEYIX7nmZvfUdqgM
bo1NQRvmAVOwWz2HpQ6b2t478IKMX+PHRs9Tn00/owKtAAoGj470IERXMNIZqBQu
geo558phv+J2hmc+CWp4hgO9skeZD71iCA5rd8PdZmj+SU0u/1eyKfE9zAtVfj4H
awIDAQAB
-----END PUBLIC KEY-----`;
const timestamp: string = "1666272169";
const body: string = `{webhook_body}`;

// Convert the signature from Base64 to binary
const signature: Buffer = Buffer.from(signatureBase64, 'base64');

// Concatenate body and timestamp
const data: string = body + "." + timestamp;

// Verify the signature
const isVerified: boolean = crypto.verify(
  "sha256",
  Buffer.from(data),
  {
    key: publicKey,
    padding: crypto.constants.RSA_PKCS1_PADDING,
  },
  signature
);

if (isVerified) {
  console.log("Signature is OK.");
} else {
  console.log("Signature verification failed.");
}

const crypto = require('crypto');

// Your provided data
const signatureBase64 = "Xt9B54lOqLCCkNrjLdSp1KuYKYO8zmm274koTNYtNjZEgWiGk3cHHod4KKSdYVt5OzrPNGz3HgJpc1cxUmLS11ng1IP7aXqM3pzTGJHycAUxbEqd4OhNNr/bjyScSAeiogesQmaBMWNcuUNa/7Up0isCmuySPlIV81jL6GRu9GXu88EeHwGaWd4Kzg7HMOciB48ueB3XLwUo9ez1WPoooJ9bfzDxrSfhPpAx9CoUuEH3aXYJpVuTUjtI8WnvhWuVIUGscUUzbAomEM+y9CImHDZP0QSEPfVYpWt/r8QcG/zukhvuWNSHtAPnqxaU8LOgdfsVSgQxBcMDlNgPCZn+cA=="
const publicKey = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3KAvpLM4ng9ppG/Z3kQF
3fRWYUYpJ2Z2h+DIcGuXyP2Hn5PAxwHNTQj0nXzpmsOCO8C1TghKwfDaFcGCfURQ
t/o8E1LmS5/ckMWsQwxKNbiwLlrFZFo8opdAOA+OTORdqq6+J18YRTCJEMClKkvI
AsDmgFANWApLkYx+r9pE9Kdasu3MTvVs0DpQNPG1guFwXUoVEEYIX7nmZvfUdqgM
bo1NQRvmAVOwWz2HpQ6b2t478IKMX+PHRs9Tn00/owKtAAoGj470IERXMNIZqBQu
geo558phv+J2hmc+CWp4hgO9skeZD71iCA5rd8PdZmj+SU0u/1eyKfE9zAtVfj4H
awIDAQAB
-----END PUBLIC KEY-----`;
const timestamp = "1666272169";
const body = `{webhook_body}`;

// Convert the signature from Base64 to binary
const signature = Buffer.from(signatureBase64, 'base64');

// Concatenate body and timestamp
const data = body + "." + timestamp;

// Verify the signature
const isVerified = crypto.verify(
    "sha256",
    Buffer.from(data),
    {
        key: publicKey,
        padding: crypto.constants.RSA_PKCS1_PADDING,
    },
    signature
);

if (isVerified) {
    console.log("Signature is OK.");
} else {
    console.log("Signature verification failed.");
}

require 'openssl'
require 'base64'

# Provided data
signature_base64 = "Xt9B54lOqLCCkNrjLdSp1KuYKYO8zmm274koTNYtNjZEgWiGk3cHHod4KKSdYVt5OzrPNGz3HgJpc1cxUmLS11ng1IP7aXqM3pzTGJHycAUxbEqd4OhNNr/bjyScSAeiogesQmaBMWNcuUNa/7Up0isCmuySPlIV81jL6GRu9GXu88EeHwGaWd4Kzg7HMOciB48ueB3XLwUo9ez1WPoooJ9bfzDxrSfhPpAx9CoUuEH3aXYJpVuTUjtI8WnvhWuVIUGscUUzbAomEM+y9CImHDZP0QSEPfVYpWt/r8QcG/zukhvuWNSHtAPnqxaU8LOgdfsVSgQxBcMDlNgPCZn+cA=="
public_key_pem = <<-PEM
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3KAvpLM4ng9ppG/Z3kQF
3fRWYUYpJ2Z2h+DIcGuXyP2Hn5PAxwHNTQj0nXzpmsOCO8C1TghKwfDaFcGCfURQ
t/o8E1LmS5/ckMWsQwxKNbiwLlrFZFo8opdAOA+OTORdqq6+J18YRTCJEMClKkvI
AsDmgFANWApLkYx+r9pE9Kdasu3MTvVs0DpQNPG1guFwXUoVEEYIX7nmZvfUdqgM
bo1NQRvmAVOwWz2HpQ6b2t478IKMX+PHRs9Tn00/owKtAAoGj470IERXMNIZqBQu
geo558phv+J2hmc+CWp4hgO9skeZD71iCA5rd8PdZmj+SU0u/1eyKfE9zAtVfj4H
awIDAQAB
-----END PUBLIC KEY-----
PEM
timestamp = "1666272169"
body = '{webhook_body}'

# Decode the signature from Base64
signature = Base64.decode64(signature_base64)

# Concatenate body and timestamp, then create a SHA256 digest
data = body + "." + timestamp
digest = OpenSSL::Digest::SHA256.new
digest.update(data)

# Load the public key
public_key = OpenSSL::PKey::RSA.new(public_key_pem)

# Verify the signature
verified = public_key.verify(OpenSSL::Digest::SHA256.new, signature, data)

if verified
  puts "Signature is OK."
else
  puts "Signature verification failed."
end

import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

public class main {

    // User-provided information
    private static final String publicKeyPEM = """
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3KAvpLM4ng9ppG/Z3kQF
3fRWYUYpJ2Z2h+DIcGuXyP2Hn5PAxwHNTQj0nXzpmsOCO8C1TghKwfDaFcGCfURQ
t/o8E1LmS5/ckMWsQwxKNbiwLlrFZFo8opdAOA+OTORdqq6+J18YRTCJEMClKkvI
AsDmgFANWApLkYx+r9pE9Kdasu3MTvVs0DpQNPG1guFwXUoVEEYIX7nmZvfUdqgM
bo1NQRvmAVOwWz2HpQ6b2t478IKMX+PHRs9Tn00/owKtAAoGj470IERXMNIZqBQu
geo558phv+J2hmc+CWp4hgO9skeZD71iCA5rd8PdZmj+SU0u/1eyKfE9zAtVfj4H
awIDAQAB
-----END PUBLIC KEY-----""";

    private static final String signatureBase64 = "Xt9B54lOqLCCkNrjLdSp1KuYKYO8zmm274koTNYtNjZEgWiGk3cHHod4KKSdYVt5OzrPNGz3HgJpc1cxUmLS11ng1IP7aXqM3pzTGJHycAUxbEqd4OhNNr/bjyScSAeiogesQmaBMWNcuUNa/7Up0isCmuySPlIV81jL6GRu9GXu88EeHwGaWd4Kzg7HMOciB48ueB3XLwUo9ez1WPoooJ9bfzDxrSfhPpAx9CoUuEH3aXYJpVuTUjtI8WnvhWuVIUGscUUzbAomEM+y9CImHDZP0QSEPfVYpWt/r8QcG/zukhvuWNSHtAPnqxaU8LOgdfsVSgQxBcMDlNgPCZn+cA==";

    private static final String timestamp = "1666272169";

    private static final String body = "{webhook_body}";

    public static void main(String[] args) {
        try {
            // Remove the first and last lines of the public key and decode it
            String publicKeyContent = publicKeyPEM.replaceAll("\\n", "").replace("-----BEGIN PUBLIC KEY-----", "").replace("-----END PUBLIC KEY-----", "");
            byte[] publicKeyDecoded = Base64.getDecoder().decode(publicKeyContent);

            // Generate public key
            X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicKeyDecoded);
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            PublicKey publicKey = keyFactory.generatePublic(keySpec);

            // Decode the signature
            byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64);

            // Prepare the data
            String data = body + "." + timestamp;
            byte[] dataBytes = data.getBytes();

            // Initialize signature verification
            Signature signature = Signature.getInstance("SHA256withRSA");
            signature.initVerify(publicKey);
            signature.update(dataBytes);

            // Verify the signature
            boolean isVerified = signature.verify(signatureBytes);
            if (isVerified) {
                System.out.println("Signature is OK.");
            } else {
                System.out.println("Signature verification failed.");
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
<?php

$signature = "Xt9B54lOqLCCkNrjLdSp1KuYKYO8zmm274koTNYtNjZEgWiGk3cHHod4KKSdYVt5OzrPNGz3HgJpc1cxUmLS11ng1IP7aXqM3pzTGJHycAUxbEqd4OhNNr/bjyScSAeiogesQmaBMWNcuUNa/7Up0isCmuySPlIV81jL6GRu9GXu88EeHwGaWd4Kzg7HMOciB48ueB3XLwUo9ez1WPoooJ9bfzDxrSfhPpAx9CoUuEH3aXYJpVuTUjtI8WnvhWuVIUGscUUzbAomEM+y9CImHDZP0QSEPfVYpWt/r8QcG/zukhvuWNSHtAPnqxaU8LOgdfsVSgQxBcMDlNgPCZn+cA==";
$publicKey = "-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3KAvpLM4ng9ppG/Z3kQF
3fRWYUYpJ2Z2h+DIcGuXyP2Hn5PAxwHNTQj0nXzpmsOCO8C1TghKwfDaFcGCfURQ
t/o8E1LmS5/ckMWsQwxKNbiwLlrFZFo8opdAOA+OTORdqq6+J18YRTCJEMClKkvI
AsDmgFANWApLkYx+r9pE9Kdasu3MTvVs0DpQNPG1guFwXUoVEEYIX7nmZvfUdqgM
bo1NQRvmAVOwWz2HpQ6b2t478IKMX+PHRs9Tn00/owKtAAoGj470IERXMNIZqBQu
geo558phv+J2hmc+CWp4hgO9skeZD71iCA5rd8PdZmj+SU0u/1eyKfE9zAtVfj4H
awIDAQAB
-----END PUBLIC KEY-----";
$timestamp = "1666272169";
$body = '{webhook_body}';

// Decode the base64 signature
$decodedSignature = base64_decode($signature);

// Create the SHA256 hash of the body and timestamp
$data = $body . "." . $timestamp;
$hash = hash('sha256', $data, true);

// Load the public key
$publicKeyResource = openssl_pkey_get_public($publicKey);
if (!$publicKeyResource) {
    die("Public key not valid");
}

// Verify the signature
$verificationResult = openssl_verify($data, $decodedSignature, $publicKeyResource, OPENSSL_ALGO_SHA256);
if ($verificationResult === 1) {
    echo "Signature is OK.\n";
} elseif ($verificationResult === 0) {
    echo "Signature verification failed.\n";
} else {
    echo "Error during signature verification.\n";
}

?>