Check webhook signatures
Every webhook request Moov sends includes an HMAC-SHA512 signature in the
X-Signature header. Recompute it with your signing secret before you process the event, and discard the request if it doesn't match.
Before you start: set up a webhook and copy its signing secret from Developers > Webhooks in the Dashboard. Each webhook, and each mode (test or production), has its own secret.
Verify a request
- Read the
X-Timestamp,X-Nonce,X-Webhook-ID, andX-Signatureheaders from the request. Most frameworks lowercase header names. - Build the signed payload by joining the first three values with a pipe:
{X-Timestamp}|{X-Nonce}|{X-Webhook-ID}. - Compute an HMAC-SHA512 of that payload using your signing secret, and hex-encode the result.
- Compare your value to
X-Signature, preferably with a constant-time comparison. If they match, the request came from Moov. If not, return a4xxand don't process the event.
const hmacSHA512 = require("crypto-js/hmac-sha512");
// Get your signing secret from dashboard.moov.io
const webhookSecret = process.env.WEBHOOK_SECRET;
// Check if the hash of headers match the signature
const isSigned = (timestamp, nonce, webhookId, signature) => {
const concatHeaders = `${timestamp}|${nonce}|${webhookId}`;
const checkHash = hmacSHA512(concatHeaders, webhookSecret);
return signature === checkHash.toString();
}
// Serverless function
exports.handler = async (event, context, callback) => {
if (!event.body) {
console.log("Invalid request");
callback(null, {
statusCode: 400,
body: "Invalid request"
});
}
// Headers are lowercased
if (!isSigned(
event.headers["x-timestamp"],
event.headers["x-nonce"],
event.headers["x-webhook-id"],
event.headers["x-signature"])
) {
console.log("Signature is invalid");
callback(null, {
statusCode: 400,
body: "Signature is invalid"
});
}
let webhook;
try {
webhook = JSON.parse(event.body);
} catch (err) {
console.log("Invalid JSON");
callback(null, {
statusCode: 400,
body: "Invalid JSON"
});
}
// Logs the event message payload
console.log(event.body);
callback(null, {
statusCode: 200
});
};
package main
import (
"crypto/hmac"
"crypto/sha512"
"encoding/hex"
"fmt"
"os"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
)
func main() {
lambda.Start(handler)
}
func handler(r events.APIGatewayProxyRequest) (*events.APIGatewayProxyResponse, error) {
var (
webhookSecret = os.Getenv("WEBHOOK_SECRET")
timestamp = r.Headers["x-timestamp"]
nonce = r.Headers["x-nonce"]
webhookID = r.Headers["x-webhook-id"]
gotHash = r.Headers["x-signature"]
)
concatHeaders := timestamp + "|" + nonce + "|" + webhookID
wantHash, err := hash([]byte(concatHeaders), []byte(webhookSecret))
if err != nil {
return nil, err
}
if *wantHash == gotHash {
fmt.Println("Webhook received!")
return &events.APIGatewayProxyResponse{
StatusCode: 200,
}, nil
} else {
msg := "Signature is invalid"
fmt.Println(msg)
return &events.APIGatewayProxyResponse{
StatusCode: 400,
Body: msg,
}, nil
}
}
// hash generates a SHA512 HMAC hash of p using the secret provided.
func hash(p []byte, secret []byte) (*string, error) {
h := hmac.New(sha512.New, secret)
_, err := h.Write(p)
if err != nil {
return nil, err
}
hash := hex.EncodeToString(h.Sum(nil))
return &hash, nil
}
Next steps
- Handle deliveries: respond quickly and process each event once
- Webhook events for event types and payloads