This guide explains how to verify and decrypt the webhook payloads sent by our system.
Overview
We send webhook notifications as HTTP POST requests. To ensure security, the payload is:
AES-GCM Encrypted
Payload is encrypted using AES-256-GCM to ensure confidentiality of the
data in transit.
HMAC-SHA256 Signed
Each request is signed using HMAC-SHA256 so you can verify the payload
came from Vozzo.
🔑 Shared Secret:
You will need the Shared Secret provided to you to verify
and decrypt the messages.
1. Validating the Signature
Before decrypting, you should verify the request came from us.
Header Description X-SignatureContains the HMAC-SHA256 signature of the request body (format: sha256=<hex_digest>)
Validation Steps
Get the raw request body
Read the raw body of the HTTP request as bytes or a string — before any
JSON parsing.
Retrieve your Shared Secret
Use the Shared Secret provided to you during onboarding.
Compute the HMAC-SHA256 signature
Compute the HMAC-SHA256 signature of the raw request body using the Shared
Secret (UTF-8 encoded).
Compare signatures
Compare your computed signature with the hex string in the
X-Signature header (after removing the sha256=
prefix).
⚠️ Security Note: Use a
constant-time comparison function to prevent timing
attacks.
2. Decrypting the Payload
Once the signature is verified, you can proceed to decrypt the content.
Payload Structure
The JSON body looks like this:
{
"data" : "<Base64 Encrypted String>"
}
Decryption Parameters
Parameter Value / Description Algorithm AES-256-GCM Key Take your Shared Secret string → Encode as UTF-8 → Use exactly the first 32 bytes Nonce / IV 12 bytes — prepended to the ciphertext Tag 16 bytes — appended to the ciphertext (standard for GCM mode)
📝 Key Note: If
your secret is a hex string like 8725..., the key consists of
the byte values of the first 32 characters , not the
hex-decoded bytes.
Decryption Steps
Extract the data field
Parse the JSON body and read the value of the data key.
Base64 Decode
Base64-decode the data string to get the raw bytes.
Extract components
Split the decoded bytes: • Nonce : first 12 bytes
• Ciphertext + Tag : remaining bytes (index 12
onwards)
Decrypt using AES-GCM
Use your derived key, extracted nonce, and the remaining ciphertext bytes
to decrypt with AES-256-GCM.
Example Implementation (Python FastAPI)
from fastapi import FastAPI, Request, Header
from fastapi.responses import JSONResponse
import hmac
import hashlib
import json
from fastapi import HTTPException
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import base64
import os
app = FastAPI()
WEBHOOK_SECRET = "your_shared_secret_string_here"
def decrypt_body (secret: str , encrypted_body: str ) -> dict :
"""
Decrypt Base64 encoded AES-GCM payload
"""
key = secret.encode( 'utf-8' ).ljust( 32 , b ' \0 ' )[: 32 ]
data = base64.b64decode(encrypted_body)
nonce = data[: 12 ]
ct = data[ 12 :]
aesgcm = AESGCM(key)
decrypted = aesgcm.decrypt(nonce, ct, None )
return json.loads(decrypted)
def verify_webhook_signature (secret: str , raw_body: bytes , received_signature: str ) -> bool :
"""
Verifies HMAC SHA256 webhook signature.
:param secret: Shared webhook secret
:param raw_body: Raw request body as bytes
:param received_signature: Signature from 'X-Signature' header (format: 'sha256=...')
:return: True if signature is valid, False otherwise
"""
if not received_signature:
return False
# Remove "sha256=" prefix if present
received_signature = received_signature.replace( "sha256=" , "" )
# Generate expected signature
expected_signature = hmac.new(
key = secret.encode( "utf-8" ),
msg = raw_body,
digestmod = hashlib.sha256
).hexdigest()
# Use constant-time comparison to prevent timing attacks
return hmac.compare_digest(expected_signature, received_signature)
@app.post ( "/callback" )
async def callback (
request: Request,
x_signature: str | None = Header( None )
):
# 1️⃣ Read raw body
body_bytes = await request.body()
body_text = body_bytes.decode( "utf-8" )
print ( "Raw payload:" , body_text)
print ( "Signature header:" , x_signature)
# 2️⃣ Verify signature first
success = verify_webhook_signature( WEBHOOK_SECRET , body_bytes, x_signature)
print ( "Signature valid:" , success)
if not success:
raise HTTPException( status_code = 401 , detail = "Invalid signature" )
# 3️⃣ Parse JSON to get encrypted data
try :
body_json = json.loads(body_text)
encrypted_data = body_json.get( "data" )
if not encrypted_data:
raise HTTPException( status_code = 400 , detail = "No data field in payload" )
except json.JSONDecodeError:
raise HTTPException( status_code = 400 , detail = "Invalid JSON" )
# 4️⃣ Decrypt the payload
try :
decrypted_payload = decrypt_body( WEBHOOK_SECRET , encrypted_data)
print ( "Decrypted payload:" , decrypted_payload)
except Exception as e:
print ( "Decryption failed:" , e)
raise HTTPException( status_code = 400 , detail = "Decryption failed" )
# 5️⃣ Return response
return JSONResponse(
content = { "message" : "Callback received" , "data" : decrypted_payload},
status_code = 200
)
# To run this server:
# uvicorn callback_server:app --reload --port 9000
# Endpoint:
# http://localhost:9000/callback
Sample Request & Response
Encrypted Request Payload
Incoming Webhook (Encrypted)
{
"data" : "y4gGFdAe0Yov/+6+2OAqdH0Z3phw++uEvOLAN2k3LZXcj1lAozzTsh3cI+cyrhym..."
}
Decrypted Payload
{
"call_uuid" : "03a9fef3-0c43-4d3f-a570-76b26671af16" ,
"agent_uuid" : "eb0450dc-b08e-47d7-82cb-e7b0614ba00e" ,
"direction" : "inbound" ,
"agent_number" : "+918035736716" ,
"user_number" : "+919527522658" ,
"input_payload" : null ,
"start_time" : "2026-02-04 16:53:52.362918" ,
"end_time" : "2026-02-04 16:55:05.145481" ,
"duration" : "73" ,
"transcript" : [
{ "transcript" : "नमस्ते! कैसे मदद कर सकता हूँ?" , "speaker_label" : "spk_0" },
{ "transcript" : "Hello?" , "speaker_label" : "spk_1" }
],
"analytics" : {
"call_summary" : "The user requested assistance in Hindi and confirmed their status as a college student seeking full-time work within a 5-kilometer radius. The agent verified the user's details and activated their Blue Dot profile." ,
"category" : "Profile Created – Blue Dot Activated" ,
"tags" : [],
"goals" : [],
"success_criteria" : [],
"agent_questions" : [
{
"question" : "Which category best describes the candidate?" ,
"answer" : "College Student"
},
{
"question" : "Did the candidate activate and publish their Blue Dot profile?" ,
"answer" : "Yes"
},
{
"question" : "What type of work is the candidate looking for?" ,
"answer" : "Full Time"
},
{
"question" : "What is the candidate's preferred travel radius for work?" ,
"answer" : "Within five kilometres"
},
{
"question" : "Is the candidate comfortable with field work?" ,
"answer" : "Yes"
}
]
}
}
Decrypted Payload Fields
Field Type Description call_uuidstringUnique identifier for the call agent_uuidstringUnique identifier for the agent directionstringCall direction — inbound or outbound agent_numberstringPhone number assigned to the agent user_numberstringCaller's phone number input_payloadanyCustom payload passed when initiating the call start_timestringCall start timestamp (UTC) end_timestringCall end timestamp (UTC) durationstringCall duration in seconds transcriptarrayTurn-by-turn transcript with speaker_label analyticsobjectAI-generated summary, category, tags, and Q&A
Last modified on July 24, 2026