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?
| Approach | Pros | Cons |
|---|---|---|
| Polling | Simple to implement | Wastes API quota, adds latency |
| Webhooks | Real-time, efficient | Requires 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
- Python
- JavaScript
- Go
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"
}'
import requests
response = requests.post(
"https://api.cmfy.cloud/v1/jobs",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json"
},
json={
"prompt": {...},
"webhook_url": "https://your-server.com/webhooks/cmfy"
}
)
print(response.json())
const response = await fetch('https://api.cmfy.cloud/v1/jobs', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
prompt: {...},
webhook_url: 'https://your-server.com/webhooks/cmfy'
})
});
const data = await response.json();
console.log(data);
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
func main() {
payload := map[string]interface{}{
"prompt": map[string]interface{}{/* ... */},
"webhook_url": "https://your-server.com/webhooks/cmfy",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "https://api.cmfy.cloud/v1/jobs", bytes.NewBuffer(body))
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
fmt.Printf("%+v\n", result)
}
2. Requirements
Your webhook endpoint must:
| Requirement | Details |
|---|---|
| Use HTTPS | HTTP URLs are rejected |
| Be publicly accessible | No localhost or private IPs |
| Respond with 2xx | Within 30 seconds |
| Accept POST requests | With JSON body |
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
| Field | Type | Description |
|---|---|---|
job_id | string | Unique job identifier |
status | string | completed, failed, or cancelled |
created_at | string | ISO 8601 timestamp when job was submitted |
started_at | string | When job began processing (may be absent if cancelled before start) |
completed_at | string | When job finished (success or failure) |
cancelled_at | string | When job was cancelled (only for cancelled jobs) |
execution_time_ms | number | GPU execution time in milliseconds |
outputs | object | Output files from successful jobs |
outputs.images | array | URLs to generated images |
error | object | Error details for failed jobs |
error.code | string | Machine-readable error code |
error.message | string | Human-readable error message |
error.node_id | string | Which workflow node failed (if applicable) |
Implementing a Webhook Handler
- Python (Flask)
- JavaScript (Express)
- Go
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
const express = require('express');
const app = express();
app.use(express.json());
app.post('/webhooks/cmfy', (req, res) => {
const { job_id, status, outputs, error } = req.body;
switch (status) {
case 'completed':
console.log(`Job ${job_id} completed`);
outputs.images.forEach(url => {
downloadAndProcess(url);
});
break;
case 'failed':
console.error(`Job ${job_id} failed: ${error.message}`);
notifyUserOfFailure(job_id, error);
break;
case 'cancelled':
console.log(`Job ${job_id} was cancelled`);
break;
}
// Acknowledge receipt
res.status(200).json({ received: true });
});
app.listen(3000);
package main
import (
"encoding/json"
"log"
"net/http"
)
type WebhookPayload struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Outputs struct {
Images []string `json:"images"`
} `json:"outputs"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
var payload WebhookPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
switch payload.Status {
case "completed":
log.Printf("Job %s completed with %d images",
payload.JobID, len(payload.Outputs.Images))
for _, url := range payload.Outputs.Images {
go downloadAndProcess(url)
}
case "failed":
log.Printf("Job %s failed: %s", payload.JobID, payload.Error.Message)
case "cancelled":
log.Printf("Job %s cancelled", payload.JobID)
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"received": true})
}
func main() {
http.HandleFunc("/webhooks/cmfy", webhookHandler)
log.Fatal(http.ListenAndServe(":3000", nil))
}
Retry Policy
If your webhook endpoint fails to respond with a 2xx status code, cmfy.cloud automatically retries with increasing delays:
| Attempt | Delay After Failure | Total Time Elapsed |
|---|---|---|
| 1 | Immediate | 0s |
| 2 | 1 second | 1s |
| 3 | 5 seconds | 6s |
| 4 | 30 seconds | 36s |
What Counts as a Failure?
| Scenario | Retried? | Notes |
|---|---|---|
| HTTP 2xx response | No | Success - delivery complete |
| HTTP 4xx response | Yes | Client errors are retried (your server might be temporarily misconfigured) |
| HTTP 5xx response | Yes | Server errors are retried |
| Connection refused | Yes | Your server might be restarting |
| DNS resolution failure | Yes | DNS might be temporarily unavailable |
| Timeout (>10s) | Yes | Request took too long |
| SSL/TLS error | Yes | Certificate 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.
If your webhook endpoint was down and you missed deliveries, you can always retrieve job results using the status endpoint:
- cURL
- Python
- JavaScript
- Go
curl https://api.cmfy.cloud/v1/jobs/{job_id} \
-H "Authorization: Bearer sk_live_your_api_key"
import requests
response = requests.get(
f"https://api.cmfy.cloud/v1/jobs/{job_id}",
headers={"Authorization": f"Bearer {API_KEY}"}
)
print(response.json())
const response = await fetch(`https://api.cmfy.cloud/v1/jobs/${jobId}`, {
headers: {
'Authorization': `Bearer ${apiKey}`
}
});
const data = await response.json();
console.log(data);
req, _ := http.NewRequest("GET", "https://api.cmfy.cloud/v1/jobs/"+jobID, nil)
req.Header.Set("Authorization", "Bearer "+apiKey)
client := &http.Client{}
resp, _ := client.Do(req)
defer resp.Body.Close()
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
fmt.Printf("%+v\n", result)
Webhook Headers
Every webhook request includes these headers to help you verify and process the delivery:
| Header | Example | Description |
|---|---|---|
Content-Type | application/json | Always JSON |
X-Webhook-ID | 550e8400-e29b-41d4-a716-446655440000 | Unique ID for this delivery attempt |
X-Webhook-Timestamp | 2024-01-15T10:30:20.123Z | ISO 8601 timestamp when webhook was sent |
X-Webhook-Event | job.completed | Event type (job.completed or job.failed) |
X-Webhook-Signature | sha256=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
- Python
- JavaScript
- Go
@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
app.post('/webhooks/cmfy', (req, res) => {
const webhookId = req.headers['x-webhook-id'];
const webhookTimestamp = req.headers['x-webhook-timestamp'];
const eventType = req.headers['x-webhook-event'];
console.log(`Received webhook ${webhookId} at ${webhookTimestamp}: ${eventType}`);
const payload = req.body;
// Process payload...
res.status(200).json({ received: true });
});
func webhookHandler(w http.ResponseWriter, r *http.Request) {
webhookID := r.Header.Get("X-Webhook-ID")
webhookTimestamp := r.Header.Get("X-Webhook-Timestamp")
eventType := r.Header.Get("X-Webhook-Event")
log.Printf("Received webhook %s at %s: %s", webhookID, webhookTimestamp, eventType)
var payload WebhookPayload
json.NewDecoder(r.Body).Decode(&payload)
// Process payload...
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"received": true})
}
Signature Verification
Every webhook includes an X-Webhook-Signature header containing an HMAC-SHA256 signature. This allows you to cryptographically verify that:
- Authenticity: The webhook originated from cmfy.cloud
- Integrity: The payload wasn't tampered with in transit
- 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.
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:
timestampis theX-Webhook-Timestampheader value (ISO 8601 format)payloadis the raw JSON body (not parsed)
Verification Examples
- Python
- JavaScript (Node.js)
- Go
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
const crypto = require('crypto');
const express = require('express');
const app = express();
// Load from environment variable
const WEBHOOK_SECRET = process.env.CMFY_WEBHOOK_SECRET;
const TIMESTAMP_TOLERANCE_SECONDS = 300; // 5 minutes
// Use raw body for signature verification
app.use('/webhooks/cmfy', express.raw({ type: 'application/json' }));
function verifyWebhookSignature(payload, signature, timestamp) {
// Validate signature format
if (!signature || !signature.startsWith('sha256=')) {
return { valid: false, error: 'Invalid signature format' };
}
// Check timestamp is within tolerance (prevents replay attacks)
const webhookTime = new Date(timestamp);
if (isNaN(webhookTime.getTime())) {
return { valid: false, error: 'Invalid timestamp format' };
}
const ageSeconds = Math.abs(Date.now() - webhookTime.getTime()) / 1000;
if (ageSeconds > TIMESTAMP_TOLERANCE_SECONDS) {
return { valid: false, error: `Timestamp too old (${Math.floor(ageSeconds)}s)` };
}
// Compute expected signature
const signedPayload = `${timestamp}.${payload.toString('utf8')}`;
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload, 'utf8')
.digest('hex');
// Constant-time comparison to prevent timing attacks
const signaturesMatch = crypto.timingSafeEqual(
Buffer.from(signature, 'utf8'),
Buffer.from(expectedSignature, 'utf8')
);
return signaturesMatch
? { valid: true }
: { valid: false, error: 'Invalid signature' };
}
app.post('/webhooks/cmfy', (req, res) => {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
const result = verifyWebhookSignature(req.body, signature, timestamp);
if (!result.valid) {
console.error('Webhook verification failed:', result.error);
return res.status(401).json({ error: result.error });
}
// Signature verified - process the webhook
const payload = JSON.parse(req.body.toString('utf8'));
// ... handle payload ...
res.status(200).json({ received: true });
});
app.listen(3000);
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"os"
"strings"
"time"
)
var (
webhookSecret = os.Getenv("CMFY_WEBHOOK_SECRET")
timestampToleranceSeconds = 300.0 // 5 minutes
)
func verifyWebhookSignature(payload []byte, signature, timestamp string) error {
// Validate signature format
if !strings.HasPrefix(signature, "sha256=") {
return fmt.Errorf("invalid signature format")
}
// Check timestamp is within tolerance (prevents replay attacks)
webhookTime, err := time.Parse(time.RFC3339, timestamp)
if err != nil {
return fmt.Errorf("invalid timestamp format: %w", err)
}
ageSeconds := math.Abs(time.Since(webhookTime).Seconds())
if ageSeconds > timestampToleranceSeconds {
return fmt.Errorf("timestamp too old (%d seconds)", int(ageSeconds))
}
// Compute expected signature
signedPayload := fmt.Sprintf("%s.%s", timestamp, string(payload))
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write([]byte(signedPayload))
expectedSignature := "sha256=" + hex.EncodeToString(mac.Sum(nil))
// Constant-time comparison to prevent timing attacks
if subtle.ConstantTimeCompare([]byte(signature), []byte(expectedSignature)) != 1 {
return fmt.Errorf("invalid signature")
}
return nil
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
// Read raw body for signature verification
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
signature := r.Header.Get("X-Webhook-Signature")
timestamp := r.Header.Get("X-Webhook-Timestamp")
if err := verifyWebhookSignature(body, signature, timestamp); err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
// Signature verified - process the webhook
var payload WebhookPayload
if err := json.Unmarshal(body, &payload); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// ... handle payload ...
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"received": true})
}
func main() {
http.HandleFunc("/webhooks/cmfy", webhookHandler)
http.ListenAndServe(":3000", nil)
}
Verification Checklist
When implementing signature verification:
- Use constant-time comparison - Prevents timing attacks (
hmac.compare_digestin Python,crypto.timingSafeEqualin Node.js,subtle.ConstantTimeComparein 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:
- Update your server with the new secret
- For zero-downtime rotation, temporarily accept both old and new secrets
- Remove the old secret after confirming webhooks are working
Best Practices
1. Respond Quickly
Return 200 immediately, then process asynchronously:
- Python
- JavaScript
- Go
@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
app.post('/webhooks/cmfy', (req, res) => {
const payload = req.body;
// Queue for background processing
backgroundQueue.add('processWebhook', payload);
// Return immediately
res.status(200).json({ received: true });
});
func webhookHandler(w http.ResponseWriter, r *http.Request) {
var payload WebhookPayload
json.NewDecoder(r.Body).Decode(&payload)
// Queue for background processing
go processWebhook(payload)
// Return immediately
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"received": true})
}
2. Handle Duplicates
Webhooks may be delivered more than once. Use the job_id for idempotency:
- Python
- JavaScript
- Go
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...
const processedJobs = new Set(); // Use database in production
function processWebhook(payload) {
const { job_id } = payload;
if (processedJobs.has(job_id)) {
return; // Already processed
}
processedJobs.add(job_id);
// Process the job...
}
var processedJobs = make(map[string]bool) // Use database in production
var mu sync.Mutex
func processWebhook(payload WebhookPayload) {
mu.Lock()
if processedJobs[payload.JobID] {
mu.Unlock()
return // Already processed
}
processedJobs[payload.JobID] = true
mu.Unlock()
// Process the job...
}
3. Validate the Request
Verify the webhook is from cmfy.cloud (optional but recommended):
- Python
- JavaScript
- Go
@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
app.post('/webhooks/cmfy', async (req, res) => {
// Check request comes from expected IP ranges
// or validate a shared secret in a custom header
const { job_id, status } = req.body;
// Optionally verify by fetching job status
const job = await fetchJobStatus(job_id);
if (job.status !== status) {
return res.status(400).json({ error: 'Status mismatch' });
}
// Process...
res.status(200).json({ received: true });
});
func webhookHandler(w http.ResponseWriter, r *http.Request) {
// Check request comes from expected IP ranges
// or validate a shared secret in a custom header
var payload WebhookPayload
json.NewDecoder(r.Body).Decode(&payload)
// Optionally verify by fetching job status
job := fetchJobStatus(payload.JobID)
if job.Status != payload.Status {
http.Error(w, "Status mismatch", http.StatusBadRequest)
return
}
// Process...
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"received": true})
}
4. Log Everything
Log webhook deliveries for debugging:
- Python
- JavaScript
- Go
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
app.post('/webhooks/cmfy', (req, res) => {
const { job_id, status } = req.body;
console.log('Received webhook', {
job_id,
status,
webhook_received_at: new Date().toISOString()
});
// Process...
res.status(200).json({ received: true });
});
func webhookHandler(w http.ResponseWriter, r *http.Request) {
var payload WebhookPayload
json.NewDecoder(r.Body).Decode(&payload)
log.Printf("Received webhook for job %s, status: %s, received_at: %s",
payload.JobID,
payload.Status,
time.Now().Format(time.RFC3339))
// Process...
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"received": true})
}
5. Handle All Statuses
Don't just handle success. Account for failures and cancellations:
- Python
- JavaScript
- Go
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'])
function processWebhook(payload) {
const { status, job_id, outputs, error } = payload;
switch (status) {
case 'completed':
saveOutputs(outputs);
notifyUserSuccess(job_id);
break;
case 'failed':
logFailure(error);
maybeRetryJob(job_id);
notifyUserFailure(job_id, error);
break;
case 'cancelled':
cleanupResources(job_id);
break;
}
}
func processWebhook(payload WebhookPayload) {
switch payload.Status {
case "completed":
saveOutputs(payload.Outputs)
notifyUserSuccess(payload.JobID)
case "failed":
logFailure(payload.Error)
maybeRetryJob(payload.JobID)
notifyUserFailure(payload.JobID, payload.Error)
case "cancelled":
cleanupResources(payload.JobID)
}
}
Testing Webhooks
Option 1: Using webhook.site (Easiest)
webhook.site provides a free, temporary URL to inspect incoming webhooks:
- Visit webhook.site
- Copy your unique URL (e.g.,
https://webhook.site/abc123) - Use this URL as your
webhook_urlwhen submitting jobs - 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 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:
- cURL
- Python
# 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
}
}
}'
import requests
from datetime import datetime
def send_test_webhook(endpoint, event_type="job.completed"):
"""Send a test webhook to your local endpoint."""
timestamp = datetime.utcnow().isoformat() + "Z"
if event_type == "job.completed":
payload = {
"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
}
}
}
else:
payload = {
"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
}
}
}
response = requests.post(
endpoint,
json=payload,
headers={
"Content-Type": "application/json",
"X-Webhook-ID": f"test-{int(datetime.utcnow().timestamp())}",
"X-Webhook-Timestamp": timestamp,
"X-Webhook-Event": event_type
}
)
print(f"Status: {response.status_code}")
print(f"Response: {response.text}")
# Test your local endpoint
send_test_webhook("http://localhost:3000/webhooks/cmfy", "job.completed")
send_test_webhook("http://localhost:3000/webhooks/cmfy", "job.failed")
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.completedandjob.failed - Processes asynchronously - Returns immediately, processes in background
- Is idempotent - Can safely receive the same webhook twice
- Logs webhook details - Records
X-Webhook-IDfor debugging - Has error handling - Catches exceptions without crashing
Troubleshooting
Common Issues
Webhook Not Received
| Symptom | Possible Cause | Solution |
|---|---|---|
| No request arrives | Wrong URL | Verify URL in job submission matches your endpoint exactly |
| No request arrives | URL not reachable | Test with curl from outside your network |
| No request arrives | Firewall blocking | Ensure port is open for HTTPS traffic |
| No request arrives | Job still processing | Check job status - webhook sends after completion |
Webhook Delivery Failures
| Error | Cause | Solution |
|---|---|---|
| Connection refused | Server not running | Start your webhook server |
| Connection timeout | Network issue or slow handler | Check network, respond within 10s |
| SSL certificate error | Invalid/expired certificate | Use valid SSL certificate (Let's Encrypt works) |
| 4xx response | Handler returned error | Check your server logs for exceptions |
| 5xx response | Server crashed | Add 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:
- cURL
- Python
- JavaScript
- Go
# 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"
import requests
# Get all your recent jobs
response = requests.get(
"https://api.cmfy.cloud/v1/jobs",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"limit": 100}
)
jobs = response.json()
# Get specific job status
response = requests.get(
f"https://api.cmfy.cloud/v1/jobs/{job_id}",
headers={"Authorization": f"Bearer {API_KEY}"}
)
job = response.json()
// Get all your recent jobs
const jobsResponse = await fetch('https://api.cmfy.cloud/v1/jobs?limit=100', {
headers: { 'Authorization': `Bearer ${apiKey}` }
});
const jobs = await jobsResponse.json();
// Get specific job status
const jobResponse = await fetch(`https://api.cmfy.cloud/v1/jobs/${jobId}`, {
headers: { 'Authorization': `Bearer ${apiKey}` }
});
const job = await jobResponse.json();
// Get all your recent jobs
req, _ := http.NewRequest("GET", "https://api.cmfy.cloud/v1/jobs?limit=100", nil)
req.Header.Set("Authorization", "Bearer "+apiKey)
client := &http.Client{}
resp, _ := client.Do(req)
var jobs []map[string]interface{}
json.NewDecoder(resp.Body).Decode(&jobs)
resp.Body.Close()
// Get specific job status
req, _ = http.NewRequest("GET", "https://api.cmfy.cloud/v1/jobs/"+jobID, nil)
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, _ = client.Do(req)
var job map[string]interface{}
json.NewDecoder(resp.Body).Decode(&job)
resp.Body.Close()
What's Next?
- Error Handling - Handle errors gracefully
- Idempotency - Prevent duplicate jobs
- Performance Best Practices - Optimize your integration