Skip to main content

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
string
Event type - always "request.completed", "request.timeout", or "request.cancelled"
request_id
string
Unique identifier for the request
loop_id
string
ID of the loop that processed this request
status
string
Final status: "completed", "timeout", or "cancelled"
response_data
object
The actual response from the reviewer (format varies by response_type). Null if timed out or cancelled.
response_by
object
Information about the reviewer who responded. Null if timed out or cancelled.
response_at
string
ISO 8601 timestamp when the response was submitted. Null if timed out or cancelled.
response_time_seconds
number
Time taken from creation to response in seconds. Null if timed out or cancelled.
created_at
string
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

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