Mountable

Webhooks

Get an HTTPS request, signed, whenever files are created, written, renamed or deleted in a filesystem, from mounts and the files API alike.

A webhook is a URL that Mountable calls with a filesystem's changes, so your code reacts without running a poller. It sees every change, whether an agent made it on a mount or a backend made it through the files API, and it carries the same events as GET /changes. There is no topic, queue or cloud permission to set up: one API call with your API key.

Set up

Create a webhook with an API key that has read-write access to the filesystem (a key limited to that filesystem is the usual choice):

curl -s https://mountable.io/api/v1/orgs/$ORG_ID/filesystems/$FS_ID/webhooks \
  -H "Authorization: Bearer $MOUNTABLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/mountable", "path_prefix": "/"}'

The answer is the webhook with its secret (whsec_…), shown once: store it where your endpoint can read it. Within a second (up to about 20 seconds while Mountable is being redeployed) the endpoint receives a first message, a files.resync, so you can check that the URL, TLS and signature work before any file changes.

  • url must be https:// on port 443, with no user name or password, and reach a public address. Private, loopback and link-local addresses are refused, also when a public name resolves to one.
  • path_prefix (optional, default /, at most 4,096 bytes) limits the webhook to a directory: /tickets delivers changes to /tickets and everything below it, not to /tickets-old.
  • A filesystem can have up to 5 webhooks.

What your endpoint receives

POST /hooks/mountable HTTP/1.1
Content-Type: application/json
User-Agent: Mountable-Webhooks/1
webhook-id: msg_2xq4…
webhook-timestamp: 1760961600
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{"type": "files.changed",
 "timestamp": "2026-10-20T12:00:00.123Z",
 "data": {"webhook_id": "…", "filesystem_id": "…",
          "events": [{"type": "created", "path": "/tickets/AB-42.md",
                      "file": {"path": "/tickets/AB-42.md", "type": "file", "size": 1234,
                               "etag": "\"3f2a…\"", "modified_at": "2026-10-20T12:00:00Z"}}],
          "cursor": "48213"}}

Each event says that a path changed and what is there now: file is its current FileInfo, or null if nothing exists there any more. type is:

ChangeEvents
A file or directory is createdcreated
A file is writtencreated for a new file, updated for an existing one; a mount's write can show as several events
A file or directory is renamed or moveddeleted for the old path and created for the new one, for a directory and everything below it
A file or directory is deleteddeleted
chmod or touchupdated with the same etag

Treat events as invalidations: reconcile each path to its file (or read it), do not replay events as a history. A message carries up to 100 events.

files.resync has the same shape with no events. It means "list what you need, then carry on": the webhook is new, or changes it had not delivered are no longer kept (an endpoint that was down for more than about an hour, for example).

Verify the signature

The headers follow Standard Webhooks: webhook-signature holds one or more v1,<base64> signatures, separated by spaces, of <webhook-id>.<webhook-timestamp>.<body> with HMAC-SHA256, keyed with the base64-decoded part of the secret after whsec_. Verify against the raw request body, before parsing it, and reject old timestamps.

Python:

import base64
import hashlib
import hmac
import time
 
 
def verify_webhook(secret: str, headers, body: bytes) -> bool:
    """True if a signature is ours and the message is recent."""
    msg_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
    return any(
        hmac.compare_digest(sig[3:].encode(), expected)
        for sig in headers["webhook-signature"].split()
        if sig.startswith("v1,")
    )

TypeScript (Node.js):

import { createHmac, timingSafeEqual } from "node:crypto";
 
export function verifyWebhook(secret: string, headers: Headers, body: string): boolean {
  const id = headers.get("webhook-id") ?? "";
  const timestamp = headers.get("webhook-timestamp") ?? "";
  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest();
  return (headers.get("webhook-signature") ?? "").split(" ").some((sig) => {
    if (!sig.startsWith("v1,")) return false;
    const given = Buffer.from(sig.slice(3), "base64");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}

The published standardwebhooks packages for Python and JavaScript verify the same headers if you prefer a library.

Handle deliveries

  • Answer 2xx within 10 seconds, then do the work. Anything else, a redirect included, counts as a failure and is retried.
  • At least once. A message can arrive more than once; a retry has the same webhook-id.
  • Apply a message only if its cursor is greater than the last one you applied, comparing them as integers, and store it with the result. Each new message's cursor is greater than every earlier one's, so this one check discards duplicates and anything that arrives late or out of order.
  • On files.resync, list what you keep track of (with the files API or a mount) and keep receiving: queue the messages that arrive while you list and apply them, by cursor, after the listing. The resync's cursor is taken before you list, so nothing in between is lost.
def handle(message: dict) -> None:
    data = message["data"]
    cursor = int(data["cursor"])
    if cursor <= state.last_cursor:
        return  # a duplicate, or overtaken by a newer message
    if message["type"] == "files.resync":
        state.relist()  # queue messages that arrive meanwhile; apply them after
    else:
        for event in data["events"]:
            state.reconcile(event["path"], event["file"])
    state.last_cursor = cursor

data.cursor is also a GET /changes cursor: a consumer that was offline can catch up from it by polling.

Retries and disabling

A failed delivery is retried with the same message after 5 seconds, then 10, 20 and so on, at most an hour apart, with some jitter. Messages for one webhook go one at a time: the next is sent once the previous one was acknowledged. After 24 hours of failures the webhook is disabled ("state": "disabled", "disabled_reason": "failing"). Re-enable it once the endpoint works again; its next message is usually a files.resync.

The last failure is shown as a code, never as your endpoint's answer: timeout, connect_failed, tls_failed, blocked_address (the URL resolved to an address that is not public), redirect or http_<status>.

Manage webhooks

All under /api/v1/orgs/{org_id}/filesystems/{fs_id}, with read-write access to the filesystem:

Method and pathDoes
POST …/webhookscreate a webhook; returns its secret once
GET …/webhookslist the filesystem's webhooks, with state, disabled_reason, last_success_at, last_failure_at, last_failure
DELETE …/webhooks/{webhook_id}delete a webhook
POST …/webhooks/{webhook_id}:rotate-secreta new secret, returned once; for 24 hours messages carry signatures with both the new and the old secret
POST …/webhooks/{webhook_id}:enablere-enable a webhook disabled after failures

A webhook delivers only while the key or person who created it can still read the filesystem. Revoking that key, removing its grant or removing the person from the organisation disables the webhook ("disabled_reason": "access_revoked"); create a new one with a key that has access. A webhook of a key that expired shows the same.

Latency and limits

  • Changes are picked up within a quarter of a second. In our end-to-end test (create, write, rename and delete, 20 isolated changes, endpoint on the same host), the median time from a change's response to its delivery was 39 ms and the slowest 256 ms. Add your network round trip.
  • A webhook starts at most one delivery a second; a burst of changes is batched into messages of up to 100 events (at most 256 KiB). A burst too large to deliver within the history the change log keeps (about an hour, and at most 100,000 changes) becomes a files.resync.
  • Webhooks are not billed, like the change feed: messages carry names, sizes and times, not file contents.
  • Hard-linked files: a change made through one name is reported for that name only.