Receive real-time notifications when your video submissions complete
Webhooks allow you to receive real-time HTTP notifications when events occur in your AdMorph account. Instead of continuously polling the API to check if a video is ready, AdMorph will send a POST request to your configured webhook URL when submissions complete or fail.
Important: Your webhook secret is shown only once. Store it securely - you'll need it to verify that webhooks are genuinely from AdMorph.
Your webhook endpoint should:
Tip: For local development, use tools like ngrok or localtunnel to expose your local server to the internet.
AdMorph sends webhooks for the following events:
| Event | Description | When Sent |
|---|---|---|
| submission.completed | Video generation completed successfully | When all videos for a submission are ready |
| submission.failed | Video generation failed | When a submission encounters an error |
| submission.started | Video generation started | When processing begins (optional) |
All webhook events follow this structure:
{
"event": "submission.completed",
"timestamp": "2025-01-15T10:30:00.000Z",
"data": {
"submissionId": "507f1f77bcf86cd799439011",
"submissionType": "sora2",
"status": "completed",
"creditsUsed": 1,
"videos": [
"https://admorph.net/videos/output_123.mp4"
],
"duration": "10",
"orientation": "16:9",
"createdAt": "2025-01-15T10:25:00.000Z",
"completedAt": "2025-01-15T10:30:00.000Z"
}
}
{
"event": "submission.completed",
"timestamp": "2025-01-15T10:30:00.000Z",
"data": {
"submissionId": "507f1f77bcf86cd799439011",
"submissionType": "ugc-pro",
"status": "completed",
"creditsUsed": 3,
"videos": [
"https://admorph.net/videos/ugc_123_v1.mp4",
"https://admorph.net/videos/ugc_123_v2.mp4",
"https://admorph.net/videos/ugc_123_v3.mp4"
],
"duration": null,
"orientation": null,
"createdAt": "2025-01-15T10:20:00.000Z",
"completedAt": "2025-01-15T10:30:00.000Z"
}
}
{
"event": "submission.failed",
"timestamp": "2025-01-15T10:30:00.000Z",
"data": {
"submissionId": "507f1f77bcf86cd799439011",
"submissionType": "sora2",
"status": "failed",
"creditsReserved": 1,
"createdAt": "2025-01-15T10:25:00.000Z",
"failedAt": "2025-01-15T10:30:00.000Z"
}
}
AdMorph signs all webhook requests using HMAC-SHA256. This allows you to verify that webhooks are genuinely from AdMorph and haven't been tampered with.
X-AdMorph-Signature: sha256=1a2b3c4d5e6f7g8h9i0j... X-AdMorph-Event: submission.completed User-Agent: AdMorph-Webhook/1.0 Content-Type: application/json
import hmac
import hashlib
def verify_webhook_signature(payload, signature, secret):
"""Verify AdMorph webhook signature"""
# Compute expected signature
expected = 'sha256=' + hmac.new(
secret.encode('utf-8'),
payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
# Compare using timing-safe comparison
return hmac.compare_digest(expected, signature)
# In your Flask/Django webhook handler
@app.route('/webhook', methods=['POST'])
def webhook():
payload = request.get_data(as_text=True)
signature = request.headers.get('X-AdMorph-Signature')
secret = 'your_webhook_secret_here'
if not verify_webhook_signature(payload, signature, secret):
return 'Invalid signature', 401
# Process the webhook
event = request.json
print(f"Received event: {event['event']}")
return 'OK', 200
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, secret) {
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// Express.js webhook handler
app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => {
const payload = req.body.toString('utf8');
const signature = req.headers['x-admorph-signature'];
const secret = 'your_webhook_secret_here';
if (!verifyWebhookSignature(payload, signature, secret)) {
return res.status(401).send('Invalid signature');
}
// Process the webhook
const event = JSON.parse(payload);
console.log(`Received event: ${event.event}`);
res.status(200).send('OK');
});
<?php
function verifyWebhookSignature($payload, $signature, $secret) {
$expectedSignature = 'sha256=' . hash_hmac('sha256', $payload, $secret);
return hash_equals($expectedSignature, $signature);
}
// Webhook handler
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_ADMORPH_SIGNATURE'];
$secret = 'your_webhook_secret_here';
if (!verifyWebhookSignature($payload, $signature, $secret)) {
http_response_code(401);
die('Invalid signature');
}
// Process the webhook
$event = json_decode($payload, true);
error_log("Received event: " . $event['event']);
http_response_code(200);
echo 'OK';
?>
Security Best Practice: Always verify webhook signatures in production to prevent unauthorized requests to your webhook endpoint.
If your webhook endpoint doesn't respond with a 2xx status code, AdMorph will automatically retry the webhook.
Tip: Make your webhook handler idempotent so it can safely process the same event multiple times without side effects.
from flask import Flask, request, jsonify
import hmac
import hashlib
app = Flask(__name__)
WEBHOOK_SECRET = 'your_webhook_secret_here'
def verify_signature(payload, signature):
expected = 'sha256=' + hmac.new(
WEBHOOK_SECRET.encode('utf-8'),
payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
@app.route('/webhook', methods=['POST'])
def webhook():
# Get raw payload and signature
payload = request.get_data(as_text=True)
signature = request.headers.get('X-AdMorph-Signature')
# Verify signature
if not verify_signature(payload, signature):
return jsonify({'error': 'Invalid signature'}), 401
# Parse event
event = request.json
event_type = event['event']
data = event['data']
# Handle different event types
if event_type == 'submission.completed':
submission_id = data['submissionId']
videos = data['videos']
print(f"✓ Submission {submission_id} completed")
print(f" Videos: {videos}")
# TODO: Add your business logic here
# - Update database
# - Send email notification
# - Process videos
elif event_type == 'submission.failed':
submission_id = data['submissionId']
print(f"✗ Submission {submission_id} failed")
# TODO: Handle failure
# - Alert user
# - Log error
return jsonify({'status': 'received'}), 200
if __name__ == '__main__':
app.run(port=5000)
const express = require('express');
const crypto = require('crypto');
const app = express();
const WEBHOOK_SECRET = 'your_webhook_secret_here';
function verifySignature(payload, signature) {
const expected = 'sha256=' + crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(payload)
.digest('hex');
try {
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
} catch (e) {
return false;
}
}
app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => {
// Get raw payload and signature
const payload = req.body.toString('utf8');
const signature = req.headers['x-admorph-signature'];
// Verify signature
if (!verifySignature(payload, signature)) {
return res.status(401).json({error: 'Invalid signature'});
}
// Parse event
const event = JSON.parse(payload);
const eventType = event.event;
const data = event.data;
// Handle different event types
if (eventType === 'submission.completed') {
const submissionId = data.submissionId;
const videos = data.videos;
console.log(`✓ Submission ${submissionId} completed`);
console.log(` Videos: ${videos.join(', ')}`);
// TODO: Add your business logic here
} else if (eventType === 'submission.failed') {
const submissionId = data.submissionId;
console.log(`✗ Submission ${submissionId} failed`);
// TODO: Handle failure
}
res.status(200).json({status: 'received'});
});
app.listen(3000, () => {
console.log('Webhook server listening on port 3000');
});
For local development, you can use these tools to expose your local server:
Example with ngrok: Run ngrok http 3000 to get a public URL like https://abc123.ngrok.io that forwards to your local server.