Horizon

Test Dropbox webhooks locally

Receive Dropbox webhook notifications on localhost with a Horizon tunnel, answer the challenge request, and verify X-Dropbox-Signature.

Receive Dropbox notifications on your laptop while you build, with a public HTTPS URL Dropbox can reach.

Horizon has no Dropbox integration. Dropbox sends webhooks to a public URL, and Horizon provides that URL.

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 Dropbox app in the App Console
  • At least one Dropbox account connected to the app through OAuth. Without a connected account, Dropbox sends no notifications. If your app is scoped, the user must authorize files.metadata.read.
  • A Next.js app that uses the App Router and runs on port 3000

Start your app

Dropbox needs two handlers on the same URL.

  • GET answers the verification request. Dropbox sends a challenge query parameter. Echo it back with the headers Content-Type: text/plain and X-Content-Type-Options: nosniff.
  • POST receives notifications. Dropbox sends an X-Dropbox-Signature header with the HMAC-SHA256 of the request body, hex-encoded, using your app secret as the key.
app/api/webhooks/dropbox/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export async function GET(request: Request) {
  const challenge = new URL(request.url).searchParams.get("challenge") ?? "";

  return new Response(challenge, {
    status: 200,
    headers: {
      "Content-Type": "text/plain",
      "X-Content-Type-Options": "nosniff",
    },
  });
}

export async function POST(request: Request) {
  const appSecret = process.env.DROPBOX_APP_SECRET;
  if (!appSecret) {
    return new Response("Missing DROPBOX_APP_SECRET", { status: 500 });
  }

  const body = await request.text();
  const received = request.headers.get("x-dropbox-signature") ?? "";
  const expected = createHmac("sha256", appSecret).update(body).digest("hex");

  const receivedBuffer = Buffer.from(received);
  const expectedBuffer = Buffer.from(expected);
  const isValid =
    receivedBuffer.length === expectedBuffer.length &&
    timingSafeEqual(receivedBuffer, expectedBuffer);

  if (!isValid) {
    return new Response("Invalid signature", { status: 403 });
  }

  const notification = JSON.parse(body);
  console.log("Dropbox accounts with changes:", notification.list_folder?.accounts);

  return new Response("ok", { status: 200 });
}

The notification lists which accounts changed. It doesn't contain the file changes. To get them, call /files/list_folder/continue for each account. Dropbox gives you ten seconds to respond, so do that work after you return.

Add your app secret to .env.local. Find it in the App Console on your app's page.

.env.local
DROPBOX_APP_SECRET=replace-with-your-app-secret

Start the app:

npm run dev

Start a tunnel

Start the tunnel before you register the URL, because Dropbox sends the verification request the moment you add it. Use -s with a subdomain you reserved:

hrzn tunnel http://localhost:3000 -s my-dropbox-app
Output
HORIZON: Tunnel connected
  URL          https://my-dropbox-app.hrzn.run (reserved)
  Forwarding   http://localhost:3000
  Request log  https://hrzn.run/dashboard/tunnels/my-dropbox-app

Your public URL is https://my-dropbox-app.hrzn.run. Without -s the subdomain is random and changes on every run, so you would register a new URL after each restart. Reserved subdomains are a paid feature, see Pricing.

Add the webhook URI in Dropbox

  1. Open your app in the App Console.
  2. Find the Webhooks section.
  3. Enter https://my-dropbox-app.hrzn.run/api/webhooks/dropbox as the webhook URI and add it.

Dropbox sends the verification request right away. Your Horizon terminal prints:

Output
  GET     200  /api/webhooks/dropbox

If your handler doesn't echo the challenge, the App Console shows an error and Dropbox sends no notifications to that URI.

Trigger a notification

Dropbox has no test button and no resend. Change a file in a Dropbox account connected to your app: add, edit or delete one. Dropbox then sends a notification to your URI.

Check it works

Change a file in the connected account. The Horizon terminal prints:

Output
  POST    200  /api/webhooks/dropbox

Your app terminal prints the account IDs that changed:

Output
Dropbox accounts with changes: [ 'dbid:AAH4f99T0taONIb-OurWxbNQ6ywGRopQngc' ]

If the line shows [403], see Troubleshooting.

Troubleshooting

The App Console shows a verification error

Dropbox's GET request failed. Check that the tunnel and npm run dev are running before you add the URI. Check that the URI ends with /api/webhooks/dropbox. Check that the handler returns the challenge unchanged, with Content-Type: text/plain and X-Content-Type-Options: nosniff.

The signature doesn't match

The Horizon line shows [403]. Check these in order:

  • DROPBOX_APP_SECRET is the secret of the same app that holds the webhook. Restart npm run dev after you edit .env.local.
  • The handler hashes the raw body from request.text(). Parsing and re-serializing the JSON changes the bytes.
  • You compare against the hex digest, not Base64.

No notifications arrive

  • Check that at least one account is connected to the app. Dropbox sends no notifications for an app with no connected accounts.
  • For a scoped app, check that the user authorized files.metadata.read.
  • Change a file inside the folder scope your app can see.

The URL changed after a restart

You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-dropbox-app. If the URL differs from the one in the App Console, add the new one.

Next steps

  • Read Dropbox's webhooks documentation.
  • Call /files/list_folder/continue to read the changes behind a notification.
  • Reserve a subdomain so your URL never changes: see pricing.

On this page