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 example

Below is a code example in Go:

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
}