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 actually 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
}