WebFlow is an outstanding website design, development, and hosting platform. The WebFlow API provides webhooks for a variety of important events, and it signs its webhook requests to allow users to validate webhook requests, and therefore confirm that requests actually come from WebFlow.
The below code sample shows how to verify WebFlow webhook requests in AWS Lambda functions using Python invoked via a Function URL, although the code will show the principles for validating requests in any language or platform.
Sample Code
Without further ado, here is the code:
import hmac
import time
# Or actually load this secret securely...
WEBFLOW_CLIENT_SECRET = getenv("WEBFLOW_CLIENT_SECRET")
def is_valid_webflow_webhook_request(request):
""" Is this a valid WebFlow webhook request? """
# https://docs.developers.webflow.com/reference/request-signatures
now = time.time_ns() // 1_000_000
# Check the structure of our request
if "headers" not in request:
return False
request_headers = request["headers"]
if "body" not in request:
return False
request_body = request["body"]
# If we don't have a secret, we can't validate anyway. Just let it go.
# Webhooks that don't come from on OAuth App can't have a client
# secret, so this is not just laziness. There are valid, real-world
# cases when request simply validation can't be done.
if WEBFLOW_CLIENT_SECRET is None:
return True
# Check the signature of our request
if "x-webflow-timestamp" not in request_headers:
# This should never happen. Suspicious. Log and reject.
print("Rejecting request without x-webflow-signature header")
return False
request_timestamp = int(request_headers["x-webflow-timestamp"])
if "x-webflow-signature" not in request_headers:
# No signature, but expected one. Suspicious. Log and reject.
print("Rejecting request without x-webflow-signature header")
return False
request_signature = request_headers["x-webflow-signature"]
# Docs say reject anything that diverges by more than 5min
if now - request_timestamp >= 300000:
return False
key = WEBFLOW_CLIENT_SECRET.encode("utf-8")
message = f"{request_timestamp}:{request_body}".encode("utf-8")
server_signature = hmac.new(key, message, "sha256").hexdigest()
# Use compare_digest instead of == to avoid timing attacks, per docs
return hmac.compare_digest(request_signature, server_signature)
Readers curious about the mechanics of the validation process may find the WebFlow docs about webhook request validation interesting.
Request Payload Format
For reference, AWS Lambda functions invoked via a Function URL receive a request payload like this:
{
"version": "2.0",
"routeKey": "$default",
"rawPath": "/my/path",
"rawQueryString": "parameter1=value1¶meter1=value2¶meter2=value",
"cookies": [
"cookie1",
"cookie2"
],
"headers": {
"header1": "value1",
"header2": "value1,value2"
},
"queryStringParameters": {
"parameter1": "value1,value2",
"parameter2": "value"
},
"requestContext": {
"accountId": "123456789012",
"apiId": "<urlid>",
"authentication": null,
"authorizer": {
"iam": {
"accessKey": "AKIA...",
"accountId": "111122223333",
"callerId": "AIDA...",
"cognitoIdentity": null,
"principalOrgId": null,
"userArn": "arn:aws:iam::111122223333:user/example-user",
"userId": "AIDA..."
}
},
"domainName": "<url-id>.lambda-url.us-west-2.on.aws",
"domainPrefix": "<url-id>",
"http": {
"method": "POST",
"path": "/my/path",
"protocol": "HTTP/1.1",
"sourceIp": "123.123.123.123",
"userAgent": "agent"
},
"requestId": "id",
"routeKey": "$default",
"stage": "$default",
"time": "12/Mar/2020:19:03:58 +0000",
"timeEpoch": 1583348638390
},
"body": "Hello from client!",
"pathParameters": null,
"isBase64Encoded": false,
"stageVariables": null
}