Test Frame.io webhooks locally
Receive Frame.io V4 webhook events on localhost with a Horizon tunnel, and verify the X-Frameio-Signature header.
Receive Frame.io events on your laptop while you build, with a URL Frame.io can reach.
This guide covers Frame.io V4 webhooks. You create them with the V4 API.
Before you begin
- Node.js 18 or later
- A Horizon account and the CLI (see Getting started)
- A reserved subdomain for
-s. Reserve one on the Subdomains page. - A Next.js app that uses the App Router and runs on port 3000
- A Frame.io V4 account, with its account ID and workspace ID
- An OAuth 2.0 access token from the Adobe Developer Console. The V4 API doesn't accept legacy developer tokens or JWTs.
Start your app
Create a route handler. Frame.io sends two headers: X-Frameio-Request-Timestamp and X-Frameio-Signature. The signature is v0= followed by the hex HMAC-SHA256 (a keyed hash) of the message v0:<timestamp>:<body>, keyed with the signing secret of the webhook. Read the raw body with request.text(). Frame.io recommends rejecting a timestamp more than 5 minutes from your clock, to stop replayed requests.
import { createHmac, timingSafeEqual } from "node:crypto";
const MAX_TIMESTAMP_AGE_SECONDS = 5 * 60;
export async function POST(request: Request) {
const signingSecret = process.env.FRAMEIO_SIGNING_SECRET;
if (!signingSecret) {
return new Response("Missing FRAMEIO_SIGNING_SECRET", { status: 500 });
}
const body = await request.text();
const timestamp = request.headers.get("x-frameio-request-timestamp") ?? "";
const received = request.headers.get("x-frameio-signature") ?? "";
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!(ageSeconds < MAX_TIMESTAMP_AGE_SECONDS)) {
return new Response("Stale timestamp", { status: 401 });
}
const digest = createHmac("sha256", signingSecret).update(`v0:${timestamp}:${body}`).digest("hex");
const receivedBuffer = Buffer.from(received);
const expectedBuffer = Buffer.from(`v0=${digest}`);
const isValid =
receivedBuffer.length === expectedBuffer.length &&
timingSafeEqual(receivedBuffer, expectedBuffer);
if (!isValid) {
return new Response("Invalid signature", { status: 401 });
}
const event = JSON.parse(body) as { type: string; resource: { id: string; type: string } };
console.log(`Received Frame.io event: ${event.type} ${event.resource.id}`);
return new Response("ok", { status: 200 });
}Frame.io retries a delivery up to 4 times when it gets a non-2xx status or no response within 5 seconds. Keep the handler fast.
Frame.io shows the signing secret only once, when you create the webhook. You add it to .env.local in a later step.
FRAMEIO_SIGNING_SECRET=replace-with-your-signing-secretStart the app on port 3000.
npm run devStart a tunnel
Use -s with a subdomain you reserved. Without it, the subdomain is random and changes every run, so your Frame.io webhook would point at a dead URL after a restart. Reserved subdomains are a paid feature, see Pricing.
hrzn tunnel http://localhost:3000 -s my-appHORIZON: Tunnel connected
URL https://my-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-appYour public URL is https://my-app.hrzn.run. Keep this terminal open.
Create the webhook with the Frame.io API
Frame.io V4 webhooks belong to a workspace. They receive events for every project in that workspace. Send a POST request with a name, a url and the events you want, inside a data object.
curl -X POST "https://api.frame.io/v4/accounts/$FRAMEIO_ACCOUNT_ID/workspaces/$FRAMEIO_WORKSPACE_ID/webhooks" \
-H "Authorization: Bearer $FRAMEIO_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"data": {
"name": "Horizon test",
"url": "https://my-app.hrzn.run/api/webhooks/frameio",
"events": ["file.created"]
}
}'The response includes the signing secret of the webhook. Frame.io returns it only here, so copy it into FRAMEIO_SIGNING_SECRET in .env.local now, then restart npm run dev.
Subscribe to few events. Frame.io suggests one webhook per group of related events, each with its own endpoint.
Trigger an event
Frame.io's guide triggers its first webhook with a real action. Upload a file to any project in the workspace. That fires file.created.
The Frame.io webhook guide documents no resend tool. Repeat the action to send another event. Other event types include file.ready, file.upload.completed, comment.created and project.created.
Check it works
Upload a file to a project in the workspace. Your Horizon terminal prints one line:
POST 200 /api/webhooks/frameioYour app terminal prints:
Received Frame.io event: file.created <file id>A file.created event can arrive before the upload finishes. Use file.upload.completed or file.ready when you need the finished file.
If the line shows [401], see Troubleshooting.
Troubleshooting
The signature doesn't match
- Check that
FRAMEIO_SIGNING_SECRETis the secret from the create response of this webhook. - Build the message as
v0:<timestamp>:<body>, with the raw body. - Prefix your computed digest with
v0=before you compare. - Restart
npm run devafter you edit.env.local.
You lost the signing secret
Frame.io returns the secret only in the create response. Delete the webhook with DELETE /v4/webhooks/{webhook_id} and create a new one.
The webhook is inactive
Webhooks migrated from Frame.io Legacy are disabled when your account moves to V4. Check is_active on the webhook, or update it with PATCH /v4/webhooks/{webhook_id}.
The request returns 404
The Horizon line shows [404]. The webhook URL doesn't match your route. The file app/api/webhooks/frameio/route.ts serves /api/webhooks/frameio, and Frame.io sends POST requests.
The URL changed after a restart
You started the tunnel without -s, so Horizon gave you a new random subdomain. Update the webhook with PATCH /v4/webhooks/{webhook_id} and the new url, or restart with -s my-app and the URL stays the same. -s needs a subdomain you reserved, see Pricing.
Nothing reaches your app
- Check that the Horizon terminal is still running. If its last line is
Connection lost. Reconnecting…, wait forReconnected. - Check that the action happened in a project of the workspace you registered the webhook for, and that the event type is in the webhook's
events.
Next steps
- Read Frame.io's V4 webhooks guide.
Test Dropbox webhooks locally
Receive Dropbox webhook notifications on localhost with a Horizon tunnel, answer the challenge request, and verify X-Dropbox-Signature.
Test HubSpot webhooks locally
Receive HubSpot app webhook events on localhost with a Horizon tunnel, and verify the X-HubSpot-Signature-v3 signature.