Webhooks
Instead of polling the API to check request status, you can provide a callback_url when creating a request. HITL.sh will send an HTTP POST to your callback URL when the request is completed, timed out, or cancelled.
How Callback URLs Work
When creating a request, include the callback_url parameter:
import requests
request_data = {
"processing_type" : "time-sensitive" ,
"type" : "markdown" ,
"priority" : "high" ,
"request_text" : "Please review this user comment" ,
"response_type" : "single_select" ,
"response_config" : {
"options" : [ "Approve" , "Reject" ]
},
"default_response" : "Reject" ,
"timeout_seconds" : 3600 ,
"callback_url" : "https://your-app.com/webhook/hitl/completed" ,
"platform" : "api"
}
response = requests.post(
f "https://api.hitl.sh/v1/api/loops/ { loop_id } /requests" ,
headers = { "Authorization" : f "Bearer { api_key } " },
json = request_data
)
Webhook Payload
When a request is completed, HITL.sh sends a POST request to your callback_url with the following JSON payload:
{
"event" : "request.completed" ,
"request_id" : "65f1234567890abcdef12348" ,
"loop_id" : "65f1234567890abcdef12345" ,
"status" : "completed" ,
"response_data" : {
"selected_value" : "Approve"
},
"response_by" : {
"user_id" : "65f1234567890abcdef12346" ,
"name" : "John Doe" ,
"email" : "[email protected] "
},
"response_at" : "2024-03-15T10:45:00Z" ,
"response_time_seconds" : 245.5 ,
"created_at" : "2024-03-15T10:41:00Z"
}
Payload Fields
Event type - always "request.completed", "request.timeout", or "request.cancelled"
Unique identifier for the request
ID of the loop that processed this request
Final status: "completed", "timeout", or "cancelled"
The actual response from the reviewer (format varies by response_type). Null if timed out or cancelled.
Information about the reviewer who responded. Null if timed out or cancelled.
ISO 8601 timestamp when the response was submitted. Null if timed out or cancelled.
Time taken from creation to response in seconds. Null if timed out or cancelled.
ISO 8601 timestamp when the request was created
Event Types
Sent when a reviewer successfully completes the request. {
"event" : "request.completed" ,
"status" : "completed" ,
"response_data" : { /* reviewer's response */ },
"response_by" : { /* reviewer info */ },
"response_at" : "2024-03-15T10:45:00Z"
}
Sent when no reviewer responds within the timeout period. {
"event" : "request.timeout" ,
"status" : "timeout" ,
"response_data" : null ,
"response_by" : null ,
"response_at" : null
}
Your application should use the default_response value you specified when creating the request.
Sent when the request is cancelled via the API before completion. {
"event" : "request.cancelled" ,
"status" : "cancelled" ,
"response_data" : null ,
"response_by" : null ,
"response_at" : null
}
Implementing a Webhook Endpoint
Basic Endpoint
Python (Flask)
Node.js (Express)
from flask import Flask, request, jsonify
import hmac
import hashlib
app = Flask( __name__ )
@app.route ( '/webhook/hitl/completed' , methods = [ 'POST' ])
def hitl_webhook ():
# Get the webhook payload
payload = request.get_json()
request_id = payload[ 'request_id' ]
event = payload[ 'event' ]
status = payload[ 'status' ]
if event == 'request.completed' :
# Handle completed request
response_data = payload[ 'response_data' ]
reviewer = payload[ 'response_by' ]
print ( f "Request { request_id } completed by { reviewer[ 'name' ] } " )
print ( f "Response: { response_data } " )
# Update your database, trigger workflows, etc.
process_completed_request(request_id, response_data)
elif event == 'request.timeout' :
# Handle timeout
print ( f "Request { request_id } timed out" )
use_default_response(request_id)
elif event == 'request.cancelled' :
# Handle cancellation
print ( f "Request { request_id } was cancelled" )
mark_request_cancelled(request_id)
# Return 200 OK to acknowledge receipt
return jsonify({ "status" : "received" }), 200
if __name__ == '__main__' :
app.run( port = 5000 )
Best Practices
1. Return 200 OK Quickly
Always return a 200 OK response immediately after receiving the webhook. Process the payload asynchronously to avoid timeouts:
@app.route ( '/webhook/hitl/completed' , methods = [ 'POST' ])
def hitl_webhook ():
payload = request.get_json()
# Queue the payload for async processing
task_queue.enqueue(process_webhook, payload)
# Return immediately
return jsonify({ "status" : "received" }), 200
2. Handle Retries Gracefully
HITL.sh will retry failed webhook deliveries up to 3 times with exponential backoff. Make your endpoint idempotent to handle duplicate deliveries:
def process_webhook ( payload ):
request_id = payload[ 'request_id' ]
# Check if already processed
if is_already_processed(request_id):
print ( f "Webhook for { request_id } already processed, skipping" )
return
# Process the webhook
handle_request_completion(payload)
# Mark as processed
mark_as_processed(request_id)
3. Validate Webhook Authenticity
While HITL.sh callback URLs are set per-request and only known to you, you should still validate incoming webhooks:
def validate_webhook_source ( request ):
# Check source IP (optional)
allowed_ips = [ '52.25.180.123' , '54.245.23.45' ] # HITL.sh IPs
source_ip = request.remote_addr
if source_ip not in allowed_ips:
return False
# Verify required fields are present
payload = request.get_json()
required_fields = [ 'event' , 'request_id' , 'status' ]
return all (field in payload for field in required_fields)
4. Use HTTPS
Always use HTTPS endpoints for your callback_url to ensure webhook payloads are encrypted in transit:
# Good
callback_url = "https://your-app.com/webhook/hitl"
# Bad - don't use HTTP
callback_url = "http://your-app.com/webhook/hitl" # ❌ Insecure
5. Handle Errors Gracefully
If your webhook endpoint fails, HITL.sh will retry. Log errors for debugging:
@app.route ( '/webhook/hitl/completed' , methods = [ 'POST' ])
def hitl_webhook ():
try :
payload = request.get_json()
process_webhook(payload)
return jsonify({ "status" : "received" }), 200
except Exception as e:
logger.error( f "Webhook processing failed: { str (e) } " , exc_info = True )
# Return 500 to trigger retry
return jsonify({ "error" : str (e)}), 500
Testing Webhooks
Local Development with ngrok
Use ngrok to expose your local server for webhook testing:
# Start your local server
python app.py # Running on localhost:5000
# In another terminal, start ngrok
ngrok http 5000
# Use the ngrok URL as your callback_url
# Example: https://abc123.ngrok.io/webhook/hitl/completed
Manual Testing
Create a test request with your callback URL:
import requests
test_request = {
"processing_type" : "time-sensitive" ,
"type" : "markdown" ,
"priority" : "low" ,
"request_text" : "Test webhook - please select any option" ,
"response_type" : "single_select" ,
"response_config" : {
"options" : [ "Option A" , "Option B" ]
},
"default_response" : "Option A" ,
"timeout_seconds" : 300 , # 5 minutes
"callback_url" : "https://your-ngrok-url.ngrok.io/webhook/hitl/completed" ,
"platform" : "api"
}
response = requests.post(
f "https://api.hitl.sh/v1/api/loops/ { loop_id } /requests" ,
headers = { "Authorization" : f "Bearer { api_key } " },
json = test_request
)
print ( f "Test request created: { response.json()[ 'data' ][ 'request_id' ] } " )
print ( "Respond to it in the HITL mobile app to trigger the webhook" )
Troubleshooting
Possible causes:
Callback URL is not publicly accessible
Using HTTP instead of HTTPS
Firewall blocking incoming requests
Webhook endpoint returned error status
Solutions:
Test your endpoint with curl or Postman
Ensure HTTPS is used
Check firewall rules
Return 200 OK status code
Cause: Webhook delivery retries after temporary failuresSolution: Implement idempotency by tracking processed request IDs
Cause: Your endpoint takes too long to respondSolution: Return 200 OK immediately and process payload asynchronously
Next Steps