Async Delivery
Non-blocking webhook calls don’t slow down job processing
Receive real-time notifications when jobs complete, fail, or when batches finish processing. Mend supports standard JSON webhooks with HMAC signatures and Discord-style rich embeds.
Async Delivery
Non-blocking webhook calls don’t slow down job processing
Automatic Retries
3 attempts with exponential backoff for reliability
Multiple Formats
Standard JSON or Discord embeds
Secure
HMAC-SHA256 signatures for default style
Standard JSON payload with HMAC-SHA256 signature for verification.
Use Cases:
Payload Structure:
{ "event": "job.completed", "timestamp": "2025-10-18T22:00:00Z", "job": { "id": "job-abc-123", "type": "image_resize", "status": "completed", "source_bucket": "input", "source_key": "image.jpg", "dest_bucket": "output", "dest_key": "resized.jpg", "result": { "output_url": "s3://output/resized.jpg", "file_size": 102400, "metadata": {} }, "created_at": "2025-10-18T21:59:00Z", "updated_at": "2025-10-18T22:00:00Z" }}Headers:
Content-Type: application/jsonUser-Agent: Mend-Webhook/1.0X-Mend-Signature: abc123def456...X-Mend-Timestamp: 2025-10-18T22:00:00ZRich embed format compatible with Discord webhooks for team notifications.
Use Cases:
Payload Structure:
{ "username": "Mend", "embeds": [ { "title": "✅ Job Completed", "description": "Job **job-abc-123** has been processed", "color": 3066993, "fields": [ { "name": "Job ID", "value": "job-abc-123", "inline": true }, { "name": "Type", "value": "image_resize", "inline": true }, { "name": "Status", "value": "completed", "inline": true } ], "timestamp": "2025-10-18T22:00:00Z", "footer": { "text": "Mend Media Processing" } } ]}Color Codes:
Set the default webhook style in config.yaml:
webhook: timeout: 10s max_retries: 3 secret: "${WEBHOOK_SECRET}" # For HMAC signatures style: "default" # Options: "default" or "discord"{ "source_bucket": "input", "source_key": "image.jpg", "dest_bucket": "output", "dest_key": "resized.jpg", "width": 800, "height": 600, "webhook_url": "https://your-app.com/webhooks/mend", "webhook_style": "default"}{ "source_bucket": "input", "source_key": "image.jpg", "dest_bucket": "output", "dest_key": "resized.jpg", "width": 800, "height": 600, "webhooks": [ { "url": "https://your-app.com/webhooks/mend", "style": "default" }, { "url": "https://discord.com/api/webhooks/123/abc", "style": "discord" } ]}Benefits:
job.completed
Job finished successfully with results
job.failed
Job encountered an error
batch.completed
All batch jobs completed
batch.failed
Batch processing failed
batch.partial
Some batch jobs succeeded, some failed
Create Discord Webhook
Use in Job Creation
curl -X POST http://localhost:8080/api/v1/jobs/image/resize \ -H "Content-Type: application/json" \ -H "X-API-Key: your_api_key_here" \ -d '{ "source_bucket": "input", "source_key": "photo.jpg", "dest_bucket": "output", "dest_key": "thumbnail.jpg", "width": 200, "height": 200, "webhooks": [ { "url": "https://discord.com/api/webhooks/123456789/abcdefg...", "style": "discord" } ] }'Receive Notifications
You’ll see rich embeds in your Discord channel with job status updates!
const express = require('express');const crypto = require('crypto');
const app = express();app.use(express.json());
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
// Verify HMAC signaturefunction verifySignature(payload, signature, timestamp) { const data = timestamp + '.' + JSON.stringify(payload); const expectedSignature = crypto .createHmac('sha256', WEBHOOK_SECRET) .update(data) .digest('hex');
return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expectedSignature) );}
app.post('/webhooks/mend', (req, res) => { const signature = req.headers['x-mend-signature']; const timestamp = req.headers['x-mend-timestamp'];
// Verify signature if (!verifySignature(req.body, signature, timestamp)) { return res.status(401).json({ error: 'Invalid signature' }); }
// Process webhook const { event, job } = req.body; console.log(`Received ${event} for job ${job.id}`);
if (event === 'job.completed') { // Handle successful job console.log('Job completed:', job.result); } else if (event === 'job.failed') { // Handle failed job console.error('Job failed:', job.error); }
res.status(200).json({ received: true });});
app.listen(3000, () => { console.log('Webhook receiver listening on port 3000');});from flask import Flask, request, jsonifyimport hmacimport hashlibimport os
app = Flask(__name__)WEBHOOK_SECRET = os.getenv('WEBHOOK_SECRET')
def verify_signature(payload, signature, timestamp): data = f"{timestamp}.{payload}" expected_signature = hmac.new( WEBHOOK_SECRET.encode(), data.encode(), hashlib.sha256 ).hexdigest() return hmac.compare_digest(signature, expected_signature)
@app.route('/webhooks/mend', methods=['POST'])def webhook(): signature = request.headers.get('X-Mend-Signature') timestamp = request.headers.get('X-Mend-Timestamp') payload = request.get_data(as_text=True)
# Verify signature if not verify_signature(payload, signature, timestamp): return jsonify({'error': 'Invalid signature'}), 401
# Process webhook data = request.json event = data['event'] job = data['job']
print(f"Received {event} for job {job['id']}")
if event == 'job.completed': print(f"Job completed: {job['result']}") elif event == 'job.failed': print(f"Job failed: {job['error']}")
return jsonify({'received': True})
if __name__ == '__main__': app.run(port=3000)package main
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os")
type WebhookPayload struct { Event string `json:"event"` Job struct { ID string `json:"id"` Status string `json:"status"` Error string `json:"error,omitempty"` } `json:"job"`}
func verifySignature(payload []byte, signature, timestamp string) bool { secret := os.Getenv("WEBHOOK_SECRET") data := fmt.Sprintf("%s.%s", timestamp, string(payload))
h := hmac.New(sha256.New, []byte(secret)) h.Write([]byte(data)) expectedSignature := hex.EncodeToString(h.Sum(nil))
return hmac.Equal([]byte(signature), []byte(expectedSignature))}
func webhookHandler(w http.ResponseWriter, r *http.Request) { signature := r.Header.Get("X-Mend-Signature") timestamp := r.Header.Get("X-Mend-Timestamp")
body, _ := io.ReadAll(r.Body)
if !verifySignature(body, signature, timestamp) { http.Error(w, "Invalid signature", http.StatusUnauthorized) return }
var payload WebhookPayload json.Unmarshal(body, &payload)
fmt.Printf("Received %s for job %s\n", payload.Event, payload.Job.ID)
w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]bool{"received": true})}
func main() { http.HandleFunc("/webhooks/mend", webhookHandler) http.ListenAndServe(":3000", nil)}Signature Generation:
signature = HMAC-SHA256(secret, timestamp + "." + payload)Verification Steps:
X-Mend-Signature and X-Mend-Timestamp headerstimestamp + "." + JSON.stringify(payload)const timestamp = new Date(req.headers['x-mend-timestamp']);const now = new Date();const fiveMinutes = 5 * 60 * 1000;
if (now - timestamp > fiveMinutes) { return res.status(401).json({ error: 'Webhook too old' });}Mend automatically retries failed webhook deliveries:
Attempt 1
Immediate delivery
Attempt 2
After 2 seconds
Attempt 3
After 4 seconds
Success Criteria:
Failure Handling:
const processedJobs = new Set();
app.post('/webhooks/mend', async (req, res) => { const { job } = req.body;
// Return 200 immediately res.status(200).json({ received: true });
// Process asynchronously if (processedJobs.has(job.id)) { console.log('Duplicate webhook, skipping'); return; }
processedJobs.add(job.id); await processJob(job);});Check:
Check:
WEBHOOK_SECRET matches configCheck:
discord style{ "event": "batch.completed", "timestamp": "2025-10-18T22:10:00Z", "batch": { "id": "batch-550e8400", "status": "completed", "total_jobs": 10, "completed": 10, "failed": 0, "created_at": "2025-10-18T22:00:00Z", "completed_at": "2025-10-18T22:10:00Z" }}{ "event": "job.failed", "timestamp": "2025-10-18T22:00:05Z", "job": { "id": "job-abc-123", "type": "image_resize", "status": "failed", "error": "Source file not found: s3://bucket/image.jpg", "created_at": "2025-10-18T22:00:00Z", "updated_at": "2025-10-18T22:00:05Z" }}