Skip to Content

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 transitioncompleted, 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

HeaderValue
Content-Typeapplication/json
X-Premation-Eventrender.finished
X-Premation-Job-IdThe job id, mirrored from the body

Answering

Return any 2xx. The body is ignored.

Your responseWhat happens
2xxDone — delivered
4xxLogged and not retried — a 4xx will not improve on a retry
5xxRetried
Timeout or connection errorRetried

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, *.internal hostnames
  • 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.

Last updated on