Skip to main content

Webhook Integration

Webhooks allow you to receive job results automatically when they complete, eliminating the need to poll for status. This guide explains how to set up and handle webhooks.

Why Use Webhooks?

ApproachProsCons
PollingSimple to implementWastes API quota, adds latency
WebhooksReal-time, efficientRequires endpoint setup

Webhooks are the recommended approach because:

  • No wasted requests: Receive exactly one notification per job
  • Real-time: Get results immediately when ready
  • Quota-friendly: Webhook deliveries don't count against rate limits
  • Scalable: Handle thousands of jobs without polling overhead

Setting Up Webhooks

1. Specify Webhook URL

Include the webhook_url parameter when submitting a job:

curl -X POST https://api.cmfy.cloud/v1/jobs \
-H "Authorization: Bearer sk_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"prompt": {...},
"webhook_url": "https://your-server.com/webhooks/cmfy"
}'

2. Requirements

Your webhook endpoint must:

RequirementDetails
Use HTTPSHTTP URLs are rejected
Be publicly accessibleNo localhost or private IPs
Respond with 2xxWithin 30 seconds
Accept POST requestsWith JSON body
Private Networks

Webhooks cannot be sent to private IP addresses (10.x.x.x, 192.168.x.x, 127.x.x.x) or localhost. Your endpoint must be publicly accessible.

Webhook Payload

When a job completes, you receive a POST request with this payload:

Successful Job

{
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "completed",
"created_at": "2024-01-15T10:30:00Z",
"started_at": "2024-01-15T10:30:05Z",
"completed_at": "2024-01-15T10:30:20Z",
"execution_time_ms": 12450,
"outputs": {
"images": [
"https://cdn.cmfy.cloud/outputs/550e8400/image_0.png"
]
}
}

Failed Job

{
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "failed",
"created_at": "2024-01-15T10:30:00Z",
"started_at": "2024-01-15T10:30:05Z",
"completed_at": "2024-01-15T10:30:08Z",
"error": {
"code": "execution_failed",
"message": "Node 'KSampler' failed: Out of memory",
"node_id": "5"
}
}

Cancelled Job

{
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "cancelled",
"created_at": "2024-01-15T10:30:00Z",
"cancelled_at": "2024-01-15T10:30:02Z"
}

Payload Fields

FieldTypeDescription
job_idstringUnique job identifier
statusstringcompleted, failed, or cancelled
created_atstringISO 8601 timestamp when job was submitted
started_atstringWhen job began processing (may be absent if cancelled before start)
completed_atstringWhen job finished (success or failure)
cancelled_atstringWhen job was cancelled (only for cancelled jobs)
execution_time_msnumberGPU execution time in milliseconds
outputsobjectOutput files from successful jobs
outputs.imagesarrayURLs to generated images
errorobjectError details for failed jobs
error.codestringMachine-readable error code
error.messagestringHuman-readable error message
error.node_idstringWhich workflow node failed (if applicable)

Implementing a Webhook Handler

from flask import Flask, request, jsonify
import logging

app = Flask(__name__)
logger = logging.getLogger(__name__)

@app.route('/webhooks/cmfy', methods=['POST'])
def handle_webhook():
payload = request.json
job_id = payload['job_id']
status = payload['status']

if status == 'completed':
images = payload['outputs']['images']
logger.info(f"Job {job_id} completed with {len(images)} images")
# Process images...
for url in images:
download_and_process(url)

elif status == 'failed':
error = payload['error']
logger.error(f"Job {job_id} failed: {error['message']}")
# Handle failure...
notify_user_of_failure(job_id, error)

elif status == 'cancelled':
logger.info(f"Job {job_id} was cancelled")
# Clean up...

# Always return 200 to acknowledge receipt
return jsonify({'received': True}), 200

Retry Policy

If your webhook endpoint fails to respond with a 2xx status code, cmfy.cloud automatically retries with increasing delays:

AttemptDelay After FailureTotal Time Elapsed
1Immediate0s
21 second1s
35 seconds6s
430 seconds36s

What Counts as a Failure?

ScenarioRetried?Notes
HTTP 2xx responseNoSuccess - delivery complete
HTTP 4xx responseYesClient errors are retried (your server might be temporarily misconfigured)
HTTP 5xx responseYesServer errors are retried
Connection refusedYesYour server might be restarting
DNS resolution failureYesDNS might be temporarily unavailable
Timeout (>10s)YesRequest took too long
SSL/TLS errorYesCertificate issues might be temporary

Permanent Failure

After 4 failed attempts (1 initial + 3 retries), the webhook is marked as permanently failed. Failed webhooks are stored for debugging but not retried further.

Recovering from Missed Webhooks

If your webhook endpoint was down and you missed deliveries, you can always retrieve job results using the status endpoint:

curl https://api.cmfy.cloud/v1/jobs/{job_id} \
-H "Authorization: Bearer sk_live_your_api_key"

Webhook Headers

Every webhook request includes these headers to help you verify and process the delivery:

HeaderExampleDescription
Content-Typeapplication/jsonAlways JSON
X-Webhook-ID550e8400-e29b-41d4-a716-446655440000Unique ID for this delivery attempt
X-Webhook-Timestamp2024-01-15T10:30:20.123ZISO 8601 timestamp when webhook was sent
X-Webhook-Eventjob.completedEvent type (job.completed or job.failed)
X-Webhook-Signaturesha256=abc123...HMAC-SHA256 signature for verification (see Signature Verification)

Using Headers for Debugging

The X-Webhook-ID header is useful for:

  • Correlating logs: Match webhook deliveries to your server logs
  • Deduplication: Detect if the same webhook was delivered twice
  • Support requests: Reference specific deliveries when contacting support
@app.route('/webhooks/cmfy', methods=['POST'])
def handle_webhook():
webhook_id = request.headers.get('X-Webhook-ID')
webhook_timestamp = request.headers.get('X-Webhook-Timestamp')
event_type = request.headers.get('X-Webhook-Event')

logger.info(f"Received webhook {webhook_id} at {webhook_timestamp}: {event_type}")

payload = request.json
# Process payload...

return jsonify({'received': True}), 200

Signature Verification

Every webhook includes an X-Webhook-Signature header containing an HMAC-SHA256 signature. This allows you to cryptographically verify that:

  1. Authenticity: The webhook originated from cmfy.cloud
  2. Integrity: The payload wasn't tampered with in transit
  3. Replay protection: The webhook isn't a replay of an old message (via timestamp validation)

Getting Your Webhook Secret

Your webhook secret is available in the Portal Dashboard under Settings → Webhooks. The secret is a 64-character hexadecimal string.

Keep Your Secret Secure

Never commit your webhook secret to source control. Use environment variables or a secrets manager.

Signature Format

The signature header format is:

X-Webhook-Signature: sha256={hex_digest}

The signature is computed over: {timestamp}.{payload}

Where:

  • timestamp is the X-Webhook-Timestamp header value (ISO 8601 format)
  • payload is the raw JSON body (not parsed)

Verification Examples

import hmac
import hashlib
from datetime import datetime, timezone
from flask import Flask, request, jsonify

app = Flask(__name__)

# Load from environment variable
WEBHOOK_SECRET = os.environ['CMFY_WEBHOOK_SECRET']
TIMESTAMP_TOLERANCE_SECONDS = 300 # 5 minutes

def verify_webhook_signature(payload: bytes, signature: str, timestamp: str) -> bool:
"""Verify the webhook signature using HMAC-SHA256."""
# Validate signature format
if not signature or not signature.startswith('sha256='):
return False

# Check timestamp is within tolerance (prevents replay attacks)
try:
webhook_time = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
age_seconds = abs((datetime.now(timezone.utc) - webhook_time).total_seconds())
if age_seconds > TIMESTAMP_TOLERANCE_SECONDS:
return False
except ValueError:
return False

# Compute expected signature
signed_payload = f"{timestamp}.{payload.decode('utf-8')}"
expected_signature = 'sha256=' + hmac.new(
WEBHOOK_SECRET.encode('utf-8'),
signed_payload.encode('utf-8'),
hashlib.sha256
).hexdigest()

# Constant-time comparison to prevent timing attacks
return hmac.compare_digest(signature, expected_signature)

@app.route('/webhooks/cmfy', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Webhook-Signature')
timestamp = request.headers.get('X-Webhook-Timestamp')

if not verify_webhook_signature(request.data, signature, timestamp):
return jsonify({'error': 'Invalid signature'}), 401

# Signature verified - process the webhook
payload = request.json
# ... handle payload ...

return jsonify({'received': True}), 200

Verification Checklist

When implementing signature verification:

  • Use constant-time comparison - Prevents timing attacks (hmac.compare_digest in Python, crypto.timingSafeEqual in Node.js, subtle.ConstantTimeCompare in Go)
  • Validate timestamp - Reject webhooks older than 5 minutes to prevent replay attacks
  • Use raw body - Verify signature against the raw request body, not parsed JSON (parsing may alter whitespace)
  • Store secret securely - Use environment variables or a secrets manager

Rotating Secrets

You can regenerate your webhook secret at any time in the Portal. After regenerating:

  1. Update your server with the new secret
  2. For zero-downtime rotation, temporarily accept both old and new secrets
  3. Remove the old secret after confirming webhooks are working

Best Practices

1. Respond Quickly

Return 200 immediately, then process asynchronously:

@app.route('/webhooks/cmfy', methods=['POST'])
def handle_webhook():
payload = request.json

# Queue for background processing
background_queue.enqueue(process_webhook, payload)

# Return immediately
return jsonify({'received': True}), 200

2. Handle Duplicates

Webhooks may be delivered more than once. Use the job_id for idempotency:

processed_jobs = set()  # Use database in production

def process_webhook(payload):
job_id = payload['job_id']

if job_id in processed_jobs:
return # Already processed

processed_jobs.add(job_id)
# Process the job...

3. Validate the Request

Verify the webhook is from cmfy.cloud (optional but recommended):

@app.route('/webhooks/cmfy', methods=['POST'])
def handle_webhook():
# Check request comes from expected IP ranges
# or validate a shared secret in a custom header

payload = request.json
job_id = payload['job_id']

# Optionally verify by fetching job status
job = fetch_job_status(job_id)
if job['status'] != payload['status']:
return jsonify({'error': 'Status mismatch'}), 400

# Process...
return jsonify({'received': True}), 200

4. Log Everything

Log webhook deliveries for debugging:

from datetime import datetime

@app.route('/webhooks/cmfy', methods=['POST'])
def handle_webhook():
payload = request.json

logger.info(f"Received webhook for job {payload['job_id']}", extra={
'job_id': payload['job_id'],
'status': payload['status'],
'webhook_received_at': datetime.now().isoformat()
})

# Process...
return jsonify({'received': True}), 200

5. Handle All Statuses

Don't just handle success. Account for failures and cancellations:

def process_webhook(payload):
status = payload['status']

if status == 'completed':
save_outputs(payload['outputs'])
notify_user_success(payload['job_id'])

elif status == 'failed':
log_failure(payload['error'])
maybe_retry_job(payload['job_id'])
notify_user_failure(payload['job_id'], payload['error'])

elif status == 'cancelled':
cleanup_resources(payload['job_id'])

Testing Webhooks

Option 1: Using webhook.site (Easiest)

webhook.site provides a free, temporary URL to inspect incoming webhooks:

  1. Visit webhook.site
  2. Copy your unique URL (e.g., https://webhook.site/abc123)
  3. Use this URL as your webhook_url when submitting jobs
  4. View incoming webhooks in real-time in your browser

This is perfect for:

  • Seeing the exact payload format
  • Debugging without writing any code
  • Quick testing during development

Option 2: Local Development with ngrok

Use ngrok to expose your local server to the internet:

# Start your local webhook server (e.g., on port 3000)
node server.js

# In another terminal, start ngrok
ngrok http 3000

ngrok provides a public HTTPS URL that tunnels to your local server:

Forwarding   https://abc123.ngrok-free.app -> http://localhost:3000

Use the ngrok URL as your webhook_url:

{
"prompt": {...},
"webhook_url": "https://abc123.ngrok-free.app/webhooks/cmfy"
}
ngrok Inspection

ngrok provides a web interface at http://localhost:4040 where you can:

  • View all incoming requests
  • Replay requests for debugging
  • Inspect request/response details

Option 3: Send Test Payloads Locally

Test your webhook handler by sending mock payloads:

# Test successful job webhook
curl -X POST http://localhost:3000/webhooks/cmfy \
-H "Content-Type: application/json" \
-H "X-Webhook-ID: test-$(date +%s)" \
-H "X-Webhook-Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-H "X-Webhook-Event: job.completed" \
-d '{
"event": "job.completed",
"job": {
"id": "test-job-123",
"status": "completed",
"result": {
"images": ["https://cdn.cmfy.cloud/outputs/test/image.png"]
},
"timing": {
"createdAt": "2024-01-15T10:30:00Z",
"startedAt": "2024-01-15T10:30:05Z",
"completedAt": "2024-01-15T10:30:20Z",
"executionTimeMs": 12450,
"downloadTimeMs": 2500
}
}
}'

# Test failed job webhook
curl -X POST http://localhost:3000/webhooks/cmfy \
-H "Content-Type: application/json" \
-H "X-Webhook-ID: test-$(date +%s)" \
-H "X-Webhook-Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-H "X-Webhook-Event: job.failed" \
-d '{
"event": "job.failed",
"job": {
"id": "test-job-456",
"status": "failed",
"error": "Node KSampler failed: CUDA out of memory",
"timing": {
"createdAt": "2024-01-15T10:30:00Z",
"startedAt": "2024-01-15T10:30:05Z",
"completedAt": "2024-01-15T10:30:08Z",
"executionTimeMs": 3000,
"downloadTimeMs": 0
}
}
}'

Debugging Checklist

Before going to production, verify your webhook handler:

  • Responds with 2xx status - Returns 200-299 within 10 seconds
  • Handles all event types - job.completed and job.failed
  • Processes asynchronously - Returns immediately, processes in background
  • Is idempotent - Can safely receive the same webhook twice
  • Logs webhook details - Records X-Webhook-ID for debugging
  • Has error handling - Catches exceptions without crashing

Troubleshooting

Common Issues

Webhook Not Received

SymptomPossible CauseSolution
No request arrivesWrong URLVerify URL in job submission matches your endpoint exactly
No request arrivesURL not reachableTest with curl from outside your network
No request arrivesFirewall blockingEnsure port is open for HTTPS traffic
No request arrivesJob still processingCheck job status - webhook sends after completion

Webhook Delivery Failures

ErrorCauseSolution
Connection refusedServer not runningStart your webhook server
Connection timeoutNetwork issue or slow handlerCheck network, respond within 10s
SSL certificate errorInvalid/expired certificateUse valid SSL certificate (Let's Encrypt works)
4xx responseHandler returned errorCheck your server logs for exceptions
5xx responseServer crashedAdd try/catch to prevent handler crashes

Debugging Steps

Step 1: Verify your endpoint is accessible

# Replace with your actual webhook URL
curl -X POST https://your-server.com/webhooks/cmfy \
-H "Content-Type: application/json" \
-d '{"test": true}'

Expected: 200 response (or your normal response code)

Step 2: Check job status

If the webhook hasn't arrived, verify the job completed:

curl https://api.cmfy.cloud/v1/jobs/{job_id} \
-H "Authorization: Bearer sk_live_your_api_key"

Look for "status": "completed" or "status": "failed"

Step 3: Check your server logs

Look for:

  • Incoming request from cmfy.cloud
  • Any exceptions or errors
  • Response status code

Step 4: Test with webhook.site

If your handler isn't receiving webhooks, submit a test job with webhook.site URL:

curl -X POST https://api.cmfy.cloud/v1/jobs \
-H "Authorization: Bearer sk_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"prompt": {...},
"webhook_url": "https://webhook.site/your-unique-id"
}'

If webhook.site receives it but your server doesn't, the issue is with your server.

Missed Webhooks

If you missed webhooks (server downtime, deployment, etc.), retrieve results via the API:

# Get all your recent jobs
curl "https://api.cmfy.cloud/v1/jobs?limit=100" \
-H "Authorization: Bearer sk_live_your_api_key"

# Get specific job status
curl "https://api.cmfy.cloud/v1/jobs/{job_id}" \
-H "Authorization: Bearer sk_live_your_api_key"

What's Next?

Was this page helpful?