Skip to Content
Command lineWebhooks

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

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