# Guide to Dropbox Webhooks: Features and Best Practices

Dropbox webhooks tell your app that files have changed in an account connected to it. If you build on the Dropbox API, for example to sync files into your own storage, index documents or react when a user drops something into a folder, webhooks replace polling: Dropbox calls your endpoint as soon as there is something new, and your app then asks the API what changed.

This guide covers how Dropbox webhooks are registered and verified, what a notification contains (and what it leaves out), how to check the `X-Dropbox-Signature` header, the delivery rules that can disable your webhook, and the best practices for running a Dropbox receiver in production. It is about the Dropbox API. Dropbox Sign, formerly HelloSign, is a separate product with its own webhooks.

## What are Dropbox webhooks?

A Dropbox webhook is a single URI registered against your app in the App Console. Once it is set, Dropbox sends a request to it "every time there's a change in any of the accounts connected to your app". There is no list of events to choose from and no per-user subscription. If no accounts are connected to your app, the URI receives nothing.

The notification only tells you that something changed. Dropbox says "the payload of the notification request does not include the actual file changes. It only informs your app of which users have changes." Your app then calls `/files/list_folder/continue` with the cursor it stored for each account to find out what happened.

Two kinds of request reach your endpoint. When you add the URI, Dropbox sends an unsigned `GET` with a `challenge` query parameter to confirm that you want notifications there. After that, every notification is a `POST` with a JSON body and an `X-Dropbox-Signature` header.

## Dropbox webhook features

| Feature | Details |
| --- | --- |
| Configuration | App Console, in the app's **Webhooks** section. One URI per app |
| Subscription model | Every account linked to the app. No event selection |
| Verification request | Unsigned `GET <uri>?challenge=<random>`. Echo the value as the response body within 10 seconds |
| Signature header | `X-Dropbox-Signature` |
| Scheme | HMAC-SHA256, lowercase hex digest |
| Signed content | The raw request body, nothing else |
| Secret | Your app secret from the App Console, the same one used for OAuth |
| Replay window | None. No timestamp is sent or signed |
| Response window | 10 seconds |
| Retries | Non-200 responses and timeouts are retried with exponential backoff over about ten minutes |
| Auto-disable | More than 35 errors in 10 minutes and a failure rate above 4.5% (Dropbox says these thresholds may change) |
| Scopes | Scoped apps need `files.metadata.read` authorised by the user |

The URI must be reachable from the public internet. Dropbox notes that `localhost` and `127.0.0.1` will not work, and publishes no source IP list for webhooks, so the signature is how you know a request came from Dropbox.

## Notification payloads

File change notifications have no event type. A user app receives this shape, where each list can hold several entries because "if multiple users make changes at the same time, they may be included in the same notification":

```json
{
  "list_folder": {
    "accounts": ["dbid:AAH4f99T0taONIb-OurWxbNQ6ywGRopQngc"]
  },
  "delta": {
    "users": [12345678]
  }
}

```

`list_folder.accounts` holds API v2 account IDs, and `delta.users` holds the same news as API v1 user IDs. New code should use the account IDs.

Dropbox Business (team) apps get two more shapes. Apps with team member file access receive the same two keys, but keyed by team, with member IDs inside:

```json
{
  "list_folder": { "teams": { "team_id1": ["member_id1", "member_id2"] } },
  "delta": { "teams": { "team_id1": ["member_id1", "member_id2"] } }
}

```

Apps with team member management or file access permissions, or scoped apps whose team link includes `members.read`, also receive team change notifications. These fire when a user is invited to the team, joins it, is removed from it, or has their profile or permissions updated:

```json
{
  "team_events": [
    {
      "event": "member_info_change",
      "team_id": "team_id1",
      "member_ids": ["member_id1", "member_id2"]
    }
  ]
}

```

`team_events` is an array, so one body can carry several changes. `member_info_change` is the only `event` value Dropbox shows. It does not say whether invites, joins and removals each have their own value, so treat unknown values as possible.

Tell the three shapes apart by which keys are present: `list_folder.accounts` for user apps, `list_folder.teams` for team file access and `team_events` for team changes.

## Setting up Dropbox webhooks

Get your endpoint running before you add the URI, because Dropbox verifies it immediately and does not retry the check.

1. Store your app secret server-side, for example as `DROPBOX_APP_SECRET`. You will find it on your app's settings page in the [App Console](https://www.dropbox.com/developers/apps).
2. If your app is scoped, make sure it requests `files.metadata.read` during OAuth. Without it, users' changes produce no notifications.
3. In the App Console, open your app and add your full URI, for example `https://example.com/webhooks/dropbox`, in the **Webhooks** section.
4. Dropbox sends the verification request. If your endpoint echoes the challenge correctly, the URI is saved and notifications start. If not, the App Console shows an error.

The verification request looks like this:

```
GET https://example.com/webhooks/dropbox?challenge=abc123

```

Respond with status `200` and the `challenge` value as the whole body, within 10 seconds. Dropbox asks you to set `Content-Type: text/plain` and `X-Content-Type-Options: nosniff` on that response, because echoing a query parameter back is otherwise a reflected XSS risk on your own server.

For local development, the [Hookdeck CLI](/docs/cli) (`hookdeck listen 3000 dropbox --path /webhooks/dropbox`) gives you a public HTTPS URL that forwards to your local server, with no account required. Use that URL as the webhook URI.

## Securing Dropbox webhooks

Read `X-Dropbox-Signature`, compute an HMAC-SHA256 of the raw request body with your app secret as the key, encode it as hex, and compare the two in constant time. Dropbox calls checking the signature "a good idea (but not required)". Do it anyway: without it, anyone who learns your URL can make your app call the Dropbox API for arbitrary account IDs. Keep the `GET` challenge handler out of the verification path, since it is unsigned.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyDropboxSignature(rawBody, signatureHeader, appSecret) {
  if (!signatureHeader || !appSecret) return false;
  const expected = crypto.createHmac('sha256', appSecret).update(rawBody).digest('hex');
  const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

// Verification request: unsigned, echo the challenge as plain text.
app.get('/webhooks/dropbox', (req, res) => {
  const challenge = req.query.challenge;
  if (typeof challenge !== 'string') return res.sendStatus(400);
  res.set('Content-Type', 'text/plain');
  res.set('X-Content-Type-Options', 'nosniff');
  res.status(200).send(challenge);
});

app.post('/webhooks/dropbox', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyDropboxSignature(req.body, req.get('x-dropbox-signature'), process.env.DROPBOX_APP_SECRET)) {
    return res.sendStatus(403);
  }

  const notification = JSON.parse(req.body.toString('utf8'));
  const accounts = notification.list_folder?.accounts ?? [];
  // Queue one job per account, then answer. The job calls /files/list_folder/continue.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import os

def verify_dropbox_signature(raw_body: bytes, signature_header, app_secret: str) -> bool:
    if not signature_header or not app_secret:
        return False
    expected = hmac.new(app_secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    received = signature_header.strip().lower().encode("utf-8")
    return hmac.compare_digest(received, expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_dropbox_signature(raw_body, request.headers.get("x-dropbox-signature"),
#                          os.environ["DROPBOX_APP_SECRET"])

```

This matches Dropbox's own Flask sample, which compares the header with `hmac.new(APP_SECRET, request.data, sha256).hexdigest()`. To check your verifier, use the app secret `dropbox-test-app-secret` and this exact body:

```
{"list_folder":{"accounts":["dbid:AAH4f99T0taONIb-OurWxbNQ6ywGRopQngc"]},"delta":{"users":[12345678]}}

```

The signature should be:

```
37dca04044e0c306d21d2e9150aeff4304a8fac8d9011cdd10ac372ed16f192e

```

> **Make Dropbox webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) answers Dropbox's `GET ?challenge=` request at the edge, verifies `X-Dropbox-Signature` with your app secret, and durably queues every notification before your service sees it.

## Dropbox webhook limitations and pain points

### The notification doesn't say what changed

**The Problem:** A notification arrives, but it holds only account IDs. There is no file path, no change type and no event name.

**Why It Happens:** Dropbox webhooks are designed as a signal to fetch. The actual changes live behind `/files/list_folder/continue`, which your app calls with the cursor it saved for that account.

**Workarounds:**

* Store a cursor per account, starting from your first `/files/list_folder` call, and update it after every page of results.
* Page until `has_more` is false. Dropbox warns that `list_folder` results "can sometimes be very large", especially when a new user first links your app.
* Treat the notification as a trigger only. Nothing in it needs to be stored long term.

**How Hookdeck Can Help:** Hookdeck queues each notification and delivers it to a worker at a rate you set, so the API calls that follow a burst of notifications are spread out rather than all landing at once.

### Verification is answered once, and not retried

**The Problem:** You add the URI in the App Console and get an error, and Dropbox never sends a notification.

**Why It Happens:** The `GET ?challenge=` request is sent once when the URI is added. Dropbox "will not perform automatic retry for verification requests", and if the response is wrong it "would not send any notifications to that URI". A route that only accepts `POST`, signature middleware that also runs on `GET`, a JSON response or a server that is not yet running all fail.

**Workarounds:**

* Handle `GET` and `POST` on the same path, with signature checks on `POST` only.
* Return the challenge as the entire body with a `200`.
* Start your server and any tunnel before adding the URI, then add it again if it failed.

**How Hookdeck Can Help:** Hookdeck answers any `GET` that carries a `challenge` query parameter with a `200` and the value as a plain-text body, before the request reaches your code. A URI pointed at a Hookdeck source verifies even while your own service is offline.

### Ten seconds, then retries, then a disabled webhook

**The Problem:** Your webhook stops receiving notifications after a period of errors, and the first you hear of it is an email from Dropbox.

**Why It Happens:** Dropbox gives each request 10 seconds. Non-200 responses and timeouts are retried with exponential backoff "over the course of about ten minutes", and then reported as failures. A webhook with more than 35 errors in 10 minutes and a failure rate above 4.5% is disabled. Slow handlers that call the Dropbox API before answering are the usual cause.

**Workarounds:**

* Verify the signature, queue one job per account and return `200`. Do the `list_folder/continue` work in the job.
* If the webhook is disabled, fix the cause and re-enable it in the App Console's webhooks section.
* Run a periodic `list_folder/continue` for each account as a fallback, so a gap in notifications does not become a gap in data.

**How Hookdeck Can Help:** Hookdeck accepts each notification as it arrives and stores it, then delivers it to your service with [retries](/docs/retries) on your own schedule. Your downtime does not count as Dropbox errors, and any notification can be replayed.

### Several notifications for the same account

**The Problem:** One burst of activity produces several notifications for the same account within a second or two, and your app processes the same changes more than once.

**Why It Happens:** Dropbox says that when "a user makes a number of changes in rapid succession, your app is likely to receive multiple notifications for the same user at roughly the same time". For Business apps, "a single change to a file in a shared folder will trigger a webhook for each user that the folder is shared with". Notifications carry no ID, so there is nothing to deduplicate on.

**Workarounds:**

* Make processing idempotent where you can. Re-running `list_folder/continue` from a stored cursor returns nothing new if another worker already advanced it.
* Where work is not idempotent, such as sending email, take a lease per account so only one worker processes it at a time. This is what Dropbox recommends.
* For shared folders, track the shared folder ID, file path and `rev` to spot a change you have already handled.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can drop identical notification bodies that arrive within a window you set, and a [max delivery rate](/docs/destinations#set-a-max-delivery-rate) spreads the rest out so fewer workers start on the same account at once.

## Best practices

### Verify against the raw body

The signature covers the exact bytes Dropbox sent. Mount `express.raw()` on the route, or read `await request.body()` in FastAPI or `Buffer.from(await request.arrayBuffer())` in a Next.js route handler, and parse only after verifying.

### Route by key, not by event name

There is no event field for file changes. Check for `list_folder.accounts`, `list_folder.teams` and `team_events` in turn, and log and acknowledge anything else.

### Keep a cursor per account

Your cursor store is the real state of the integration. Save the latest cursor after each page, keyed by account ID (or by team and member for Business apps), and never move it backwards.

### Acknowledge fast and process asynchronously

Dropbox's own sample processes accounts on a separate thread and recommends a reliable queue for production. Answer within the 10-second window and keep API calls out of the request path. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Rotate the app secret with care

The signing key is your app secret, which is also your OAuth client secret. Changing it affects OAuth as well as webhook verification, so plan the change in both places at once.

## Conclusion

Dropbox webhooks are change pings. Answer the one-time `GET ?challenge=` request with the value as plain text, verify `X-Dropbox-Signature` as a hex HMAC-SHA256 of the raw body with your app secret, and use the account IDs to drive `/files/list_folder/continue` from a stored cursor. Respond within 10 seconds and handle duplicate notifications for the same account, and you will stay clear of the error thresholds that switch a webhook off.

[Hookdeck Event Gateway](https://hookdeck.com) answers the challenge, checks every signature and holds each notification until your workers are ready for it, so a slow `list_folder` call or a deploy never turns into a disabled webhook.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Dropbox webhooks reliably in minutes.