Routefusion signs all webhook payloads using Ed25519, allowing you to verify that a webhook was sent by Routefusion and that the body was not tampered with in transit.
Signature Headers
Every signed webhook request includes two headers:
X-Signature-Ed25519— Hex-encoded Ed25519 signatureX-Signature-Timestamp— Unix timestamp (seconds) of when the webhook was signed
Verification Steps
- Extract the
X-Signature-Ed25519andX-Signature-Timestampheaders from the request. - Concatenate the timestamp, a period (
.), and the raw request body to form the signed message:{timestamp}.{raw_body} - Verify the signature against Routefusion's public key using Ed25519.
- (Recommended) Reject requests where the timestamp is more than 5 minutes old to prevent replay attacks.
Public Key
Use the following public key to verify webhook signatures:
Production
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAbKnJoUaBDFT7ZsM3Df/tO6YDL5dmp+aBEstnMg7J+O0=
-----END PUBLIC KEY-----
Sandbox
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAgzAetDWJ6EzDjuq+zfOvotNlmBQhvgxwXxykb+xYdas=
-----END PUBLIC KEY-----
Examples
import crypto from 'crypto';
const PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAHrRp4HERTD+4o9jD8DZfxdGosOMhfdL/afGgLsZf2Ik=
-----END PUBLIC KEY-----`;
const publicKey = crypto.createPublicKey(PUBLIC_KEY_PEM);
function verifyWebhook(rawBody, signature, timestamp) {
// Reject requests older than 5 minutes
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
return false;
}
const message = Buffer.from(`${timestamp}.${rawBody}`);
const sig = Buffer.from(signature, 'hex');
return crypto.verify(null, message, publicKey, sig);
}
// Express middleware example
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-signature-ed25519'];
const timestamp = req.headers['x-signature-timestamp'];
if (!verifyWebhook(req.body, signature, timestamp)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
// Process webhook event...
res.sendStatus(200);
});Install the PyNaCl library: pip install pynacl
from nacl.signing import VerifyKey
from nacl.exceptions import BadSignatureError
import time
PUBLIC_KEY_HEX = "1eb469e074c74c3fb8a3d8c3f0365fc5d1a8b0e3217dd2ff69f1a02ec65fd889"
def verify_webhook(raw_body: bytes, signature: str, timestamp: str) -> bool:
# Reject requests older than 5 minutes
if abs(time.time() - int(timestamp)) > 300:
return False
verify_key = VerifyKey(bytes.fromhex(PUBLIC_KEY_HEX))
message = f"{timestamp}.{raw_body.decode()}".encode()
try:
verify_key.verify(message, bytes.fromhex(signature))
return True
except BadSignatureError:
return Falsepackage main
import (
"crypto/ed25519"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"fmt"
"math"
"strconv"
"time"
)
const publicKeyPEM = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAHrRp4HERTD+4o9jD8DZfxdGosOMhfdL/afGgLsZf2Ik=
-----END PUBLIC KEY-----`
func VerifyWebhook(rawBody []byte, signature, timestamp string) bool {
// Reject requests older than 5 minutes
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false
}
block, _ := pem.Decode([]byte(publicKeyPEM))
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return false
}
sig, err := hex.DecodeString(signature)
if err != nil {
return false
}
message := fmt.Sprintf("%s.%s", timestamp, string(rawBody))
return ed25519.Verify(pub.(ed25519.PublicKey), []byte(message), sig)
}Install the ed25519 gem: gem install ed25519
require 'ed25519'
PUBLIC_KEY_HEX = "1eb469e074c74c3fb8a3d8c3f0365fc5d1a8b0e3217dd2ff69f1a02ec65fd889"
def verify_webhook(raw_body, signature, timestamp)
# Reject requests older than 5 minutes
return false if (Time.now.to_i - timestamp.to_i).abs > 300
verify_key = Ed25519::VerifyKey.new([PUBLIC_KEY_HEX].pack('H*'))
message = "#{timestamp}.#{raw_body}"
begin
verify_key.verify([signature].pack('H*'), message)
true
rescue Ed25519::VerifyError
false
end
end