Webhooks
A render can take minutes. Polling for that is a timer you have to own; a webhook is one request you receive.
Pass callbackUrl when you create the render:
{
"templateId": "tpl_7f8a9b",
"inputs": { "headline": "Launch Special 2026" },
"callbackUrl": "https://hooks.example.com/premation/render-finished"
}When it fires
Once per job, on the terminal transition — completed, failed or
cancelled. There is no queued or processing callback, and no progress
stream. If you need progress, poll.
The payload
{
"jobId": "clx9zz110000",
"status": "completed",
"videoUrl": "https://res.cloudinary.com/…/out.mp4",
"error": null,
"renderDurationMs": 74213
}{
"jobId": "clx9zz110000",
"status": "failed",
"videoUrl": null,
"error": "Render worker timed out.",
"renderDurationMs": null
}This is deliberately the same shape GET /v1/renders/{jobId} returns, plus
renderDurationMs. One handler can serve both the webhook and a polling
fallback without branching.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
X-Premation-Event | render.finished |
X-Premation-Job-Id | The job id, mirrored from the body |
Answering
Return any 2xx. The body is ignored.
| Your response | What happens |
|---|---|
| 2xx | Done — delivered |
| 4xx | Logged and not retried — a 4xx will not improve on a retry |
| 5xx | Retried |
| Timeout or connection error | Retried |
Retries: 3 attempts, at 0 s, 5 s and 25 s, with a 10-second request timeout. After that it is logged and dropped.
Delivery is best-effort, and the job row is the source of truth. A
callback that never lands does not un-complete a render. If your webhook
handler is critical, reconcile: keep a list of jobs you queued and sweep them
with GET /v1/renders/{jobId} on a slow timer to catch anything the webhook
missed.
Your endpoint must be publicly reachable
The URL is checked against the same SSRF rules as asset URLs — at request time for shape, and again at delivery time against what DNS answers, because hours can pass between the two and a rebinding record could point a stored callback somewhere it should not go.
That means these do not work as callback targets on the hosted backend:
http://localhost:5678/webhook/…http://127.0.0.1,http://192.168.x.x,http://10.x.x.x*.local,*.internalhostnames- Raw IPv6 literals
For local n8n during development, expose it through a tunnel (ngrok,
Cloudflare Tunnel) and use the public hostname. On a self-hosted
motion-back where n8n genuinely sits on the same box, the operator can opt out
with AUTOMATION_ALLOW_PRIVATE_CALLBACKS=true; the URL must still be a valid
http(s) URL.
No signature yet
Callbacks are not signed. There is no HMAC header to verify, so treat the payload as a hint, not as proof. Anyone who learns your callback URL can POST to it.
Defend it the way you would any unauthenticated webhook: use a long,
unguessable path or a secret query parameter that you check, and — before you
act on a completed — confirm the job with
GET /v1/renders/{jobId} using your API key. The API answer is
authenticated; the callback is not.
Worked example
import express from 'express'
const app = express()
app.post('/premation/:secret', express.json(), async (req, res) => {
// 1. Cheap gate on a secret only you and Premation know.
if (req.params.secret !== process.env.WEBHOOK_PATH_SECRET) {
return res.sendStatus(404)
}
// 2. Acknowledge immediately — the sender times out at 10 s.
res.sendStatus(204)
// 3. Confirm against the authenticated API before acting on it.
const { jobId } = req.body
const job = await fetch(`${BASE}/v1/renders/${jobId}`, {
headers: { Authorization: `Bearer ${process.env.PREMATION_API_KEY}` },
}).then((r) => r.json())
if (job.status === 'completed') {
await publish(job.videoUrl)
}
})Acknowledge first, work after. A handler that does the work inline and takes longer than 10 seconds gets its connection aborted and the whole delivery retried — three times.