Horizon

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.

app/api/webhooks/frameio/route.ts
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.

.env.local
FRAMEIO_SIGNING_SECRET=replace-with-your-signing-secret

Start the app on port 3000.

npm run dev

Start 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-app
Output
HORIZON: Tunnel connected
  URL          https://my-app.hrzn.run (reserved)
  Forwarding   http://localhost:3000
  Request log  https://hrzn.run/dashboard/tunnels/my-app

Your 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:

Output
  POST    200  /api/webhooks/frameio

Your app terminal prints:

Output
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_SECRET is 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 dev after 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 for Reconnected.
  • 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

On this page