Always verify webhook signatures before processing any event. Skipping this
step opens your application to spoofed requests.
How Signatures Work
HOST Pay signs every outgoing webhook using HMAC-SHA256 . The signing input is:
timestamp + "." + compact_json_body
Where:
timestamp is the Unix epoch value from the X-Webhook-Timestamp header (as a string).
compact_json_body is the raw request body serialized with no extra whitespace.
The resulting signature is hex-encoded and placed in the X-HostPay-Signature header with a v1= prefix (e.g., v1=abc123...).
Verification Steps
Extract the raw timestamp from X-Webhook-Timestamp.
Read the raw request body before parsing it as JSON.
Construct the signed string: timestamp + "." + raw_body.
Compute HMAC-SHA256(secret, signed_string) and hex-encode it.
Compare your result to the value after the v1= prefix using a constant-time comparison.
Optional but recommended : Reject requests where the timestamp is more than 5 minutes old to prevent replay attacks.
Verifying with the SDK (recommended)
The official SDKs implement all of the steps above — constant-time
comparison and replay protection included. Pass the raw request body and
headers straight from your framework:
Python (FastAPI)
TypeScript (Express)
from fastapi import FastAPI, HTTPException, Request
from hostpay import HostPay, SignatureVerificationError
app = FastAPI()
client = HostPay( api_key = "YOUR_API_KEY" , secret_key = "YOUR_SECRET_KEY" )
@app.post ( "/webhooks/hostpay" )
async def hostpay_webhook ( request : Request):
try :
event = client.webhooks.construct_event(
payload = await request.body(), # raw bytes, not request.json()
headers = request.headers,
secret = WEBHOOK_SIGNING_SECRET ,
)
except SignatureVerificationError:
raise HTTPException( status_code = 400 , detail = "Invalid signature" )
if event.event == "deposit.completed" :
... # handle it
return { "ok" : True }
Deliveries older than 5 minutes are rejected by default (pass a custom
tolerance in seconds to change this).
Verifying Manually
Implementing the check yourself in another language:
Python (FastAPI)
JavaScript (Express)
PHP
from fastapi import FastAPI, HTTPException, Request
import hmac
import hashlib
import time
import json
app = FastAPI()
WEBHOOK_SECRET = "your_webhook_secret"
@app.post ( "/webhooks/hostpay" )
async def handle_webhook ( request : Request):
# Read raw body before any parsing
raw_body = ( await request.body()).decode( "utf-8" )
# Extract headers
signature_header = request.headers.get( "X-HostPay-Signature" , "" )
timestamp = request.headers.get( "X-Webhook-Timestamp" , "" )
# Reject stale webhooks (optional but recommended)
if abs (time.time() - int (timestamp)) > 300 :
raise HTTPException( status_code = 400 , detail = "Webhook timestamp too old" )
# Verify the signature
if not verify_signature(raw_body, timestamp, signature_header):
raise HTTPException( status_code = 401 , detail = "Invalid signature" )
# Parse and handle the event
event = json.loads(raw_body)
data = event.get( "data" , {})
# Route by status in the data payload
status = data.get( "status" )
if status == "succeeded" :
handle_success(data)
elif status == "failed" :
handle_failure(data)
return { "ok" : True }
def verify_signature ( raw_body : str , timestamp : str , signature_header : str ) -> bool :
# Signature header format: "v1=<hex_digest>"
if not signature_header.startswith( "v1=" ):
return False
provided_sig = signature_header[ 3 :]
signed_string = f " { timestamp } . { raw_body } "
expected_sig = hmac.new(
WEBHOOK_SECRET .encode( "utf-8" ),
signed_string.encode( "utf-8" ),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(provided_sig, expected_sig)
def handle_success ( data ):
print ( f "Transaction { data[ 'id' ] } succeeded: { data[ 'amount' ] } { data[ 'currency' ] } " )
def handle_failure ( data ):
print ( f "Transaction { data[ 'id' ] } failed" )
Read the raw request body before any JSON parsing — re-serializing the parsed body changes whitespace and breaks signature verification.