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
challengequery parameter. Echo it back with the headersContent-Type: text/plainandX-Content-Type-Options: nosniff. - POST receives notifications. Dropbox sends an
X-Dropbox-Signatureheader with the HMAC-SHA256 of the request body, hex-encoded, using your app secret as the key.
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.
DROPBOX_APP_SECRET=replace-with-your-app-secretStart the app:
npm run devStart 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-appHORIZON: Tunnel connected
URL https://my-dropbox-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-dropbox-appYour 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
- Open your app in the App Console.
- Find the Webhooks section.
- Enter
https://my-dropbox-app.hrzn.run/api/webhooks/dropboxas the webhook URI and add it.
Dropbox sends the verification request right away. Your Horizon terminal prints:
GET 200 /api/webhooks/dropboxIf 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:
POST 200 /api/webhooks/dropboxYour app terminal prints the account IDs that changed:
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_SECRETis the secret of the same app that holds the webhook. Restartnpm run devafter 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/continueto read the changes behind a notification. - Reserve a subdomain so your URL never changes: see pricing.
Test DocuSign Connect webhooks locally
Receive DocuSign Connect events on localhost with a Horizon tunnel, and verify the HMAC signature in a Next.js route handler.
Test Frame.io webhooks locally
Receive Frame.io V4 webhook events on localhost with a Horizon tunnel, and verify the X-Frameio-Signature header.