# Get a Phone Alert When a New File Reaches Dropbox

> Use Dropbox's signed webhook and cursor to detect one genuinely new file, then send a privacy-minimal Ping to the people waiting for it.

- Published: 2026-11-05
- Author: PingRoom
- Category: Developers
- Topics: Dropbox new file notification, Dropbox webhook notification, webhook push notifications, Dropbox folder alert
- Canonical: https://pingroom.io/blog/dropbox-new-file-push-notifications
- Cover image: https://pingroom.io/blog/dropbox-new-file-push-notifications-cover.webp
- Cover description: Two PingRoom avatars pass one blank file token from a closed wooden cabinet to a tray while Pingo watches the private handoff.

To get a phone alert when someone adds a file to a Dropbox folder, start with Dropbox's own **Follow** option. It can email followers when files are added or deleted. If a stable group needs one short, shared phone signal instead, use a Dropbox webhook to wake a small relay, resume the folder's saved cursor, and send one fixed message to a PingRoom Room only when the relay sees a file ID it has never seen before.

That last test matters. A Dropbox webhook says that an account changed; it does not contain the changed filename or tell you whether the event was an upload, edit, move, or deletion.

## Choose the smaller route first

| What you need                                                 | Best starting point                                                          |
| ------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| One person's basic add/delete awareness by email              | [Follow the Dropbox folder](https://help.dropbox.com/organize/follow-folder) |
| A shared phone signal for one selected folder                 | Signed Dropbox webhook → cursor-backed relay → PingRoom                      |
| Filenames, previews, approvals, or file discussion            | Keep that work in Dropbox or a document workflow tool                        |
| Audit history, guaranteed escalation, or life-safety response | Use a system designed and governed for that requirement                      |

Dropbox says its “as activity happens” folder emails may group activity for roughly ten minutes. It also documents cases that do not produce a notification, including a person's own add/delete action and a file move or rename. If that behavior already fits, stop there. The relay below adds operational work and should earn its place.

## Set a privacy boundary before code

Use a fixed Ping:

- **Title:** `New Dropbox file`
- **Message:** `A new file is ready in the shared folder`

Do not copy the filename, path, account ID, file contents, or shared link into the Ping. Dropbox stays the source of truth. PingRoom carries only the moment that tells the Room's members to look.

![Two PingRoom avatars move a cursor peg and one new blank file tile along a wooden track while Pingo guards the signal tray.](/blog/dropbox-new-file-push-notifications-1.webp)

In the illustrated relay, two familiar file tiles remain behind the divider while one unseen tile advances. [Pingo](/Pingo), PingRoom's fictional CMO, watches the signal tray—not the closed file cabinet. That is the useful boundary: compare private state inside the relay, then disclose only the small fact the Room needs.

## Prepare Dropbox and PingRoom

This example is for one authorized Dropbox account and one selected folder. It is not a native Dropbox integration in PingRoom.

1. In the Dropbox App Console, create a scoped app. Choose the narrowest access that can reach the selected folder. An arbitrary existing folder may require **Full Dropbox** access; an app-controlled folder can use **App folder** access.
2. Enable `files.metadata.read`, complete OAuth for the account, and keep the app secret, access or refresh token, and selected folder path in protected server-side configuration.
3. Register the public HTTPS URL for your relay as the Dropbox webhook URI. Dropbox uses one webhook URI for the app and may wake it for any account content the app can access; your stored folder cursor performs the narrow filtering.
4. In PingRoom, create or choose the destination Room, then open **Settings → Connections → From another app**. Add an incoming webhook with **Custom message** and copy its complete URL into protected configuration.

The Room owner needs active **PingRoom Pro** for incoming webhooks. A Room can have up to four incoming webhooks. Treat the complete PingRoom URL as a credential and keep it out of source control, browser code, filenames, logs, and screenshots. The [incoming-webhook walkthrough](/blog/webhook-to-push-notification) covers the Room setup; the [webhook reference](/documents/webhooks) carries current fields, limits, cooldown behavior, and error responses.

## Use a signed, cursor-backed relay

The following module leaves three platform-specific adapters explicit:

- a durable queue with `sendBatch`;
- a store that can load and atomically save cursor state;
- Dropbox and PingRoom HTTP clients that reject non-success responses.

It targets a server runtime with the Fetch and Web Crypto APIs (`Request`, `Response`, `TextEncoder`, and `crypto.subtle`). Keep secrets and OAuth tokens in that runtime's protected configuration, never in client-side code.

`withAccountLease` must allow only one worker for an account at a time. Configure `listFolder` for the selected path and recursive traversal, then use the returned cursor for every continuation call. The first scan records a baseline without alerting on historical files.

```js
// dropbox-relay.mjs
const encoder = new TextEncoder();

function json(status, body) {
  return new Response(JSON.stringify(body), {
    status,
    headers: { "content-type": "application/json; charset=utf-8" },
  });
}

function hex(bytes) {
  return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}

function constantTimeEqual(left, right) {
  if (left.length !== right.length) return false;
  let difference = 0;
  for (let index = 0; index < left.length; index += 1) {
    difference |= left.charCodeAt(index) ^ right.charCodeAt(index);
  }
  return difference === 0;
}

async function hmacHex(secret, bytes) {
  const key = await crypto.subtle.importKey(
    "raw",
    encoder.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  return hex(new Uint8Array(await crypto.subtle.sign("HMAC", key, bytes)));
}

async function sha256Hex(value) {
  return hex(
    new Uint8Array(
      await crypto.subtle.digest("SHA-256", encoder.encode(value)),
    ),
  );
}

export async function receiveDropboxWebhook(request, env, queue) {
  const url = new URL(request.url);

  if (request.method === "GET") {
    const challenge = url.searchParams.get("challenge") ?? "";
    if (!/^[A-Za-z0-9]{1,256}$/.test(challenge)) {
      return new Response("Invalid challenge", { status: 400 });
    }
    return new Response(challenge, {
      headers: {
        "content-type": "text/plain; charset=utf-8",
        "x-content-type-options": "nosniff",
      },
    });
  }

  if (request.method !== "POST") {
    return new Response("Method not allowed", {
      status: 405,
      headers: { allow: "GET, POST" },
    });
  }

  const rawBody = new Uint8Array(await request.arrayBuffer());
  if (rawBody.byteLength > 65_536) {
    return json(413, { error: "payload_too_large" });
  }

  if (
    typeof env.DROPBOX_APP_SECRET !== "string" ||
    env.DROPBOX_APP_SECRET.length === 0
  ) {
    return json(500, { error: "missing_app_secret" });
  }

  const suppliedSignature =
    request.headers.get("x-dropbox-signature")?.toLowerCase() ?? "";
  const expectedSignature = await hmacHex(env.DROPBOX_APP_SECRET, rawBody);
  if (!constantTimeEqual(suppliedSignature, expectedSignature)) {
    return json(403, { error: "invalid_signature" });
  }

  let payload;
  try {
    payload = JSON.parse(new TextDecoder().decode(rawBody));
  } catch {
    return json(400, { error: "invalid_json" });
  }

  const accounts = payload?.list_folder?.accounts;
  if (!Array.isArray(accounts)) {
    return json(400, { error: "invalid_payload" });
  }

  const uniqueAccounts = [
    ...new Set(accounts.filter((account) => typeof account === "string")),
  ];
  await queue.sendBatch(
    uniqueAccounts.map((accountId) => ({ body: { accountId } })),
  );
  return new Response(null, { status: 200 });
}

async function collectPages(firstPage, continueFromCursor) {
  const entries = [...firstPage.entries];
  let page = firstPage;

  while (page.has_more) {
    page = await continueFromCursor(page.cursor);
    entries.push(...page.entries);
  }

  return { entries, cursor: page.cursor };
}

function fileIds(entries) {
  return entries
    .filter((entry) => entry[".tag"] === "file" && typeof entry.id === "string")
    .map((entry) => entry.id);
}

async function buildBaseline(accountId, folderPath, deps) {
  const listing = await collectPages(
    await deps.dropbox.listFolder(accountId, folderPath),
    (cursor) => deps.dropbox.continueList(accountId, cursor),
  );
  const knownFileIds = [...new Set(fileIds(listing.entries))];
  await deps.state.save(accountId, {
    cursor: listing.cursor,
    knownFileIds,
  });
  return { baseline: true, alerts: 0, known: knownFileIds.length };
}

export async function processDropboxAccount(message, env, deps) {
  const { accountId } = message.body;

  return deps.withAccountLease(accountId, async () => {
    const state = await deps.state.load(accountId);
    if (!state) {
      return buildBaseline(accountId, env.DROPBOX_FOLDER_PATH, deps);
    }

    let changes;
    try {
      changes = await collectPages(
        await deps.dropbox.continueList(accountId, state.cursor),
        (cursor) => deps.dropbox.continueList(accountId, cursor),
      );
    } catch (error) {
      if (error?.code === "reset") {
        return buildBaseline(accountId, env.DROPBOX_FOLDER_PATH, deps);
      }
      throw error;
    }

    const knownFileIds = new Set(state.knownFileIds);
    const additions = [];

    for (const entry of changes.entries) {
      if (entry[".tag"] !== "file" || typeof entry.id !== "string") continue;
      if (!knownFileIds.has(entry.id)) additions.push(entry);
      knownFileIds.add(entry.id);
    }

    for (const entry of additions) {
      const idempotencyKey = await sha256Hex(
        `${accountId}\n${entry.id}\n${entry.rev ?? ""}`,
      );
      await deps.pingroom.send({
        url: env.PINGROOM_WEBHOOK_URL,
        title: "New Dropbox file",
        message: "A new file is ready in the shared folder",
        idempotencyKey,
      });
    }

    await deps.state.save(accountId, {
      cursor: changes.cursor,
      knownFileIds: [...knownFileIds],
    });
    return { baseline: false, alerts: additions.length };
  });
}
```

Dropbox's webhook guide requires verification against the **raw request body** using HMAC-SHA256 and the app secret. It also says to respond within ten seconds, which is why the receiver enqueues account IDs instead of listing files inside the request.

The queue worker then applies four deterministic rules:

1. **No state means baseline.** List the selected folder, remember every current file ID and the final cursor, and send nothing.
2. **A known file ID is not new.** A changed revision or a move within the watched tree stays silent.
3. **An unseen file ID is new to this relay.** Send one fixed Ping with an idempotency key derived from account ID, file ID, and revision.
4. **Save after acceptance.** If sending fails, do not advance the cursor. A retry produces the same idempotency key, so an accepted first attempt is not duplicated when state persistence is retried.

Deleted entries do not send a Ping, and the example retains previously seen IDs. That conservative choice avoids treating a restore or move as a fresh upload. For a high-volume folder, design and test a bounded tombstone policy instead of allowing the set to grow forever.

Dropbox can invalidate a cursor and ask the client to reset. This example rebuilds the baseline silently. It is safer to miss a historical replay than to flood the Room with every current file.

## Test the boundary, not just the happy path

The production check for this article ran the exact module above against mocks. It covered Dropbox's challenge, valid and invalid signatures, duplicate account IDs, first-run baseline, an update and deletion, a paginated new-file event, fixed outgoing copy, hashed retry identity, cursor persistence, and the per-account lease. No live Dropbox or PingRoom account was used.

Before using your own relay, test these real integration cases in a disposable folder:

1. Connect the account and confirm the initial baseline sends no Ping.
2. Add one harmless file from a second Dropbox user and expect one Room Ping.
3. Edit the file, rename it, and move it inside the watched tree; expect no new Ping.
4. Add two files before the worker runs; expect two Pings with different idempotency keys.
5. Make the PingRoom endpoint return an error; confirm the cursor is not committed and a retry keeps the same key.
6. Mute the Room or disable a test device's notifications so everyone understands that accepted server work is not a guarantee of lock-screen display.

Team spaces and Dropbox Business path roots require additional namespace handling; do not silently treat this single-account recipe as a team-wide connector. Also review token rotation, queue retention, storage access, logs, and secret redaction for your chosen platform.

## What the Ping proves

A successful PingRoom response means the event was accepted and queued into the Room's normal delivery path. It does not prove that a particular phone displayed it, that anyone opened Dropbox, or that the file was reviewed. Connectivity, OS policy, notification permissions, Room settings, and device state still matter.

If that boundary fits, [create the destination Room](/app/rooms), add one incoming webhook, and rehearse the six cases above with non-sensitive files. Keep the file truth in Dropbox and the signal small.

## Sources

- [Dropbox: Follow a shared or team folder](https://help.dropbox.com/organize/follow-folder)
- [Dropbox: Webhooks](https://docs.dropboxapi.com/dropbox-api/docs/webhooks)
- [Dropbox: Detecting changes](https://docs.dropboxapi.com/dropbox-api/docs/detecting-changes)
- [PingRoom incoming-webhook walkthrough](/blog/webhook-to-push-notification)
- [PingRoom webhook reference](/documents/webhooks)
