An upload finishing and a video being playable are two different events, separated by however long encoding takes. onSuccess fires for the first one. Nothing in the browser knows about the second, because it happens on infrastructure the browser never talks to. The signal is a webhook called video.media.ready. The endpoint that receives it's a public URL anyone can find. That's why the signature check matters more than the handler does.
TL;DR
onSuccess means the bytes arrived, not that the video can play. Encoding runs afterwards, and FastPix announces the result with a video.media.ready webhook carrying data.id and data.playbackIds[0].id. Verify it before you trust it. In Node, fastpix.webhooks.unwrap(rawBody, headers) verifies and parses in one call and throws WebhookVerificationError when the signature is wrong. Everywhere else, hash it yourself: FastPix-Signature is a Base64 HMAC-SHA256 over the raw request bytes, so read the body with request.text(), $request->getContent() or express.raw() before anything parses it. A parsed and re-serialised payload hashes differently and never matches. Handle video.media.failed too, or failed encodes sit in processing forever.
Why an upload finishing is not a video being ready
onSuccess means the last chunk was acknowledged. At that moment the media has no playback ID, no renditions and no manifest, because none of that exists until encoding runs.
This is why a naive implementation appears to work and then fails in production. On a 20 second clip over a fast connection, encoding finishes so quickly that polling once after upload usually finds it ready. On a 90 minute file it doesn't, and the video page renders with an undefined playback ID.
| Moment | What exists | What does not |
|---|---|---|
| onSuccess fires | The upload ID, all the bytes | Playback ID, renditions, manifest |
| Encoding runs | Same | Same |
| video.media.ready arrives | Playback ID, renditions, manifest | Nothing you need |
What you need before you start
- A publicly reachable HTTPS endpoint that accepts POST. A tunnel works while developing.
- A webhook registered under Org Settings in the FastPix dashboard, pointed at that endpoint.
- The signing secret from that same screen, which is a separate value from your Access Token ID and Secret Key.
The event carries the two IDs you need
The payload arrives with a type and a data object. Two fields in it do the work:
{
"type": "video.media.ready",
"data": {
"id": "...", // the mediaId
"playbackIds": [{ "id": "..." }] // the playback ID
}
}data.id is what you match against your own record. That's why the upload ID has to be stored when the upload is created, not when it finishes. data.playbackIds[0].id is what the player needs. A handler that does nothing but write those two values against your row is a complete handler.
export async function POST(request) {
const { type, data } = await request.json();
if (type === "video.media.ready") {
await markVideoReady(data.id, data.playbackIds[0].id);
}
return Response.json({ message: "ok" });
}That code is correct and unsafe, for the reason in the next section. The full event catalogue is in subscribe to media events.
Verifying the webhook signature over raw bytes, not JSON
FastPix-Signature carries a Base64-encoded HMAC-SHA256 of the request body. The word doing the damage in that sentence is body.
You must hash the exact bytes that arrived. A parsed and re-serialised payload is a different byte sequence even when the data is the same. Key order, whitespace and number formatting are all free to change in the round trip. Hash that and the signature never matches. It produces the most common webhook support question there is. Verification fails, the payload looks identical, and it is.
So read the raw body first, verify, and only then parse.
In Node the SDK does both in one call, and it's the shortest correct version of this whole section:
import { Fastpix, WebhookVerificationError } from "@fastpix/fastpix-node";
const fastpix = new Fastpix({
security: { username: process.env.FASTPIX_USERNAME, password: process.env.FASTPIX_PASSWORD },
webhookSecret: process.env.FASTPIX_WEBHOOK_SECRET,
});
export async function POST(request) {
const rawBody = await request.text();
try {
const event = fastpix.webhooks.unwrap(rawBody, request.headers);
if (event.type === "video.media.ready") {
await markVideoReady(event.data.id, event.data.playbackIds[0].id);
}
} catch (error) {
if (error instanceof WebhookVerificationError) {
return new Response("invalid signature", { status: 400 });
}
throw error;
}
return Response.json({ message: "ok" });
}unwrap verifies the signature and returns the parsed event, so there's no window in which you're holding parsed data you haven't checked. It throws WebhookVerificationError when the signature doesn't match, which is the case you return 400 for.
Put the Signing Secret in FASTPIX_WEBHOOK_SECRET. The SDK reads that variable by default, so webhookSecret above is belt and braces. A missing secret throws the same WebhookVerificationError as a bad signature, which is worth knowing: if every delivery returns 400, check the secret is set before you go looking at raw bodies. You can also pass it per call as unwrap(rawBody, headers, secret). Hand-rolling the HMAC is still the path in PHP and in any runtime the Node SDK doesn't cover, and the rules below apply there.
| Framework | Raw body accessor | The mistake |
|---|---|---|
| Next.js, SvelteKit, Astro, React Router | await request.text() | Calling request.json() first |
| Laravel | $request->getContent() | Using $request->json()->all() first |
| Express | express.raw({ type: "application/json" }) | Mounting express.json() on the route |
You can watch this pass on a real event without writing anything permanent. Register a tunnel URL, push one file on the free plan, and read the signature off the delivery.
Express deserves its own line because the failure is one middleware line away and reads as a framework bug. express.json() consumes the stream and hands you an object, so by the time your handler runs the bytes are gone.
Two other things go wrong at this step. The signing key is Base64 and has to be decoded before it's used as the HMAC key. And the signature covers the payload only, with no timestamp. There's no replay window to enforce, so deduplicate on the event ID instead.
Where the video.media.ready webhook handler goes, by framework
The handler goes wherever that framework puts POST endpoints. Five of the seven already have somewhere:
| Framework | File |
|---|---|
| Next.js | app/api/fastpix/webhook/route.ts |
| React Router v7 | app/routes/api.fastpix.webhook.ts, registered in routes.ts |
| SvelteKit | src/routes/api/fastpix/webhook/+server.ts |
| Astro | src/pages/api/fastpix/webhook.ts, with prerender = false |
| Laravel | A controller action, excluded from CSRF |
| React (Vite) | Whatever server you deployed for the upload URL |
| React Native (Expo) | The backend the app already calls |
Astro routes are prerendered by default, and a prerendered route can't receive a POST. So export const prerender = false is load-bearing rather than stylistic. Laravel routes are CSRF-protected and FastPix can't produce a token. The route has to be added to validateCsrfTokens(except: [...]). That's the second of the two 419 cases in why your Laravel video route returns a 419. That exclusion is precisely why the signature check is the only protection left.
Testing a webhook on localhost
A webhook needs a URL that something on the internet can resolve. Your dev machine doesn't have one, so during development you have two options and they aren't equivalent.
Tunnelling gives you the real thing. A tunnel service hands you a public URL that forwards to your local port. You receive genuine events with genuine signatures, and you test the verification path you'll actually ship.
Polling gives you a working app and tests nothing about webhooks:
const media = await fastpix.manageVideos.get({ mediaId });
// media.data.status === "Ready"Poll while you build the upload flow if you want. Do not let polling become the production design. It costs a request per check per video. It adds latency proportional to the poll interval. And it silently stops being viable at volume.
One tunnel-specific trap catches Next.js users. The dev server rejects requests from unrecognised origins. The file picker stops responding over a tunnel until the host is added to allowedDevOrigins in next.config.ts.
Handle video.media.failed as well
video.media.ready has a sibling that most implementations forget, and forgetting it produces a specific user-visible bug: a video stuck on "processing" forever.
video.media.failed fires when encoding can't complete, usually because the source file is corrupt or in a format nothing can decode. Without a handler for it, the row never leaves the pending state and nobody finds out until a user asks. Branch on type and treat the failure case as a real state, not as an absence.
The rest of the catalogue, including live stream and In-Video AI events, is in the webhook event reference.
Send yourself a FastPix webhook and verify it
Add the endpoint under Org Settings, copy the signing secret, and write a handler that reads the raw body before it parses anything. Two fields off the ready event, data.id and data.playbackIds[0].id, are all you store. The free plan covers 10 videos and 100K streaming minutes a month, no credit card. Push a real file through a tunnel and watch the signature check pass before any of it is load-bearing.
Frequently Asked Questions (FAQs)
Why is my FastPix webhook signature verification failing?
Almost always because the body was parsed before it was verified. The signature is an HMAC-SHA256 over the exact bytes that arrived, and a parsed then re-serialized payload is a different byte sequence even when the data is identical. Read the raw body first with request.text(), $request->getContent() or express.raw(), verify it, then parse it. Also confirm that you Base64-decoded the signing key before using it.
How do I know when an uploaded video is ready to play?
Listen for the video.media.ready webhook. It carries data.id, which is the media ID you stored when the upload was created, and data.playbackIds[0].id, which is what the player needs. Upload success only means the bytes were delivered; encoding happens afterward, and the playback ID does not exist until it finishes.
Can I use webhooks with localhost?
No. A webhook needs a publicly reachable URL, and localhost is not resolvable from the internet. Expose your development server through a tunnel to receive real events with real signatures. Or poll the media status while building, then switch to webhooks before shipping.
Do I need to verify the signature if the URL is secret?
Yes. The URL is in your DNS, your logs, and anywhere it has been pasted. An unverified endpoint lets anyone mark any video as ready. In Laravel, this is sharper still. The route has to be excluded from CSRF protection to receive the event at all. That leaves the signature as the only check standing.
What happens if encoding fails?
FastPix sends video.media.failed instead of video.media.ready. If you only handle the ready event, the record stays in its pending state indefinitely and the video appears to be processing forever. Branch on the event type and write a real failed state that your UI can render.






