15. Webhooks
Polling GET /v2/movies?project=โฆ is fine for scripts. Production apps prefer to be notified when a render finishes. This chapter adds a webhook destination to the listing โ when the video is ready, JSON2Video POSTs a JSON payload to your URL with the result.
Prerequisites: chapter 14. You should have a public HTTPS endpoint that can receive a POST (use webhook.site for quick testing).
Step 1 โ Add a webhook destination
Webhook destinations live in the top-level exports array. Each export item has a destinations array; each destination has a type and the fields that type requires.
{
"exports": [
{
"destinations": [
{
"type": "webhook",
"endpoint": "https://your-app.example/json2video-callback"
}
]
}
]
}
The renderer will POST to endpoint once when the movie finishes (success or failure).
Step 2 โ Include some correlation metadata
When your endpoint receives the callback it needs to know which property / listing the video belongs to. The cleanest way is client-data โ an arbitrary object echoed back verbatim in the callback.
{
"client-data": {
"listing_id": "L-4821",
"property_address": "123 Oak Street",
"agent_id": "AG-42"
}
}
Drop that at movie level. Whatever you put here, you'll receive in the webhook payload.
Step 3 โ What the webhook receives
JSON2Video POSTs a JSON body when the render is done. Shape:
{
"project": "abc123",
"status": "done",
"url": "https://assets.json2video.com/clients/.../abc123.mp4",
"duration": 24,
"size": 5421988,
"client-data": {
"listing_id": "L-4821",
"property_address": "123 Oak Street",
"agent_id": "AG-42"
}
}
On failure status is "error" and a message field replaces url. See Webhooks reference for the canonical payload contract.
Step 4 โ A minimal receiver
A receiver verifies the payload and triggers your downstream logic โ push to a CRM, send an email, update the listing record.
import express from "express";
const app = express();
app.use(express.json());
app.post("/json2video-callback", (req, res) => {
const { project, status, url, "client-data": cd } = req.body;
if (status === "done") {
console.log(`Listing ${cd.listing_id} video ready: ${url}`);
// updateCRM(cd.listing_id, url);
} else {
console.error(`Listing ${cd.listing_id} failed: ${req.body.message}`);
}
res.status(200).send("ok");
});
app.listen(3000);
from flask import Flask, request
app = Flask(__name__)
@app.post("/json2video-callback")
def callback():
body = request.get_json()
cd = body.get("client-data", {})
if body.get("status") == "done":
print(f"Listing {cd.get('listing_id')} ready: {body['url']}")
else:
print(f"Listing {cd.get('listing_id')} failed: {body.get('message')}")
return "ok", 200
<?php
$body = json_decode(file_get_contents("php://input"), true);
$cd = $body["client-data"] ?? [];
if (($body["status"] ?? "") === "done") {
error_log("Listing {$cd['listing_id']} ready: {$body['url']}");
} else {
error_log("Listing {$cd['listing_id']} failed: {$body['message']}");
}
http_response_code(200);
echo "ok";
Step 5 โ Use Dashboard connections for headers
If your endpoint needs an authentication header (e.g. an HMAC secret), create a Dashboard webhook connection and reference it by id instead of inlining the endpoint:
{
"exports": [
{
"destinations": [
{ "id": "my-app-webhook" }
]
}
]
}
The connection stores the endpoint + custom headers; you don't have to ship secrets through the API payload.
The complete final JSON
{
"resolution": "full-hd",
"client-data": {
"listing_id": "L-4821",
"property_address": "123 Oak Street",
"agent_id": "AG-42"
},
"exports": [
{
"destinations": [
{
"type": "webhook",
"endpoint": "https://your-app.example/json2video-callback"
}
]
}
],
"variables": {
"address": "123 Oak Street",
"open_house": true,
"rooms": [
{ "name": "Exterior", "image": "https://cdn.json2video.com/assets/images/sample-house-front.jpg" },
{ "name": "Chef's Kitchen", "image": "https://cdn.json2video.com/assets/images/sample-house-kitchen.jpg" },
{ "name": "Master Bedroom", "image": "https://cdn.json2video.com/assets/images/sample-house-bedroom.jpg" }
]
},
"elements": [
{
"type": "audio",
"src": "https://cdn.json2video.com/assets/audios/uplifting-corporate.mp3",
"volume": 0.4
},
{
"type": "voice",
"text": "Welcome to {{ address }}.",
"voice": "en-US-EmmaMultilingualNeural",
"start": 1.5
},
{
"type": "subtitles",
"language": "en",
"settings": {
"style": "boxed-word",
"font-family": "Inter",
"font-size": 90,
"font-color": "#FFFFFF",
"position": "bottom-center",
"all-caps": true,
"box-color": "#0E7C66"
}
}
],
"scenes": [
{
"duration": 4,
"elements": [
{
"type": "component",
"component": "basic/000",
"settings": { "headline": "FOR SALE", "subline": "{{address}}" }
}
]
},
{
"duration": 4,
"transition": { "style": "fade", "duration": 0.5 },
"iterate": "rooms",
"iterate-as": "room",
"elements": [
{ "type": "image", "src": "{{ room.image }}" },
{ "type": "text", "text": "{{ room.name }}", "position": "top-left", "x": 60, "y": 60 }
]
}
]
}
Expected output
Same chapter-13/14-style listing, but submitting the movie no longer needs polling. A few minutes after POST /v2/movies returns, your endpoint receives the JSON payload with the video URL and your client-data. Sample render: tutorial-15.mp4 (placeholder).
What you learned
exports[].destinations[]controls where the finished video goes.- A
webhookdestination POSTs a JSON payload toendpoint. client-datais echoed back in the payload โ use it to correlate the callback with your domain objects.- Connections let you store endpoint + auth headers in the Dashboard instead of inlining secrets.
Going further
Webhooks today are best-effort โ there are no automatic retries. Design your receiver to be idempotent and to log failures so you can re-poll if needed. See the Webhooks reference for the current delivery contract.