Validating WebFlow Webhook Requests in AWS Lambda and Python

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&parameter1=value2&parameter2=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
}