Skip to Content
SimulatorsDropbox

Simulators

Dropbox simulator

dropbox-twin is a local, stateful simulator of the Dropbox API v2 — one account’s files and folders, listings and search with their cursors, shared links, file requests, shared folders and file members, upload sessions, batch jobs and save_url, and tags — serving both api.dropboxapi.com’s RPC routes and content.dropboxapi.com’s upload and download routes on one port. It never calls Dropbox: no OAuth, no email is sent and no real account changes.

Quickstart

You need an API key from Account → API keys  (how).

curl -fsSL https://www.rystic.ai/install.sh | sh
rystic login rys_…                      # your API key
rystic run dropbox-twin -d --port 0 --scenario dropbox_personal_archive   # prints the URL it serves on
curl -s http://localhost:<port>/_rystic/version

Expect "product":"dropbox-twin". The examples below use localhost:8080; use the port rystic run printed.

On Enterprise, rystic login ./rystic-license.json takes the place of the key (license files).

Read the account and its tree, then upload a file and download it back:

curl -s localhost:8080/2/users/get_current_account -H 'Authorization: Bearer sl.placeholder' \
  -H 'Content-Type: application/json' -d null
# -> {"account_id":"dbid:AAApersonalarchive00000000000000001","account_type":{".tag":"pro"},...,"email":"ada.sealy@example.test",...}

curl -s localhost:8080/2/files/list_folder -H 'Authorization: Bearer sl.placeholder' \
  -H 'Content-Type: application/json' -d '{"path":""}'
# -> {"cursor":"…","entries":[{".tag":"folder",...,"path_display":"/Apps",...},{...,"path_display":"/Documents",...},{".tag":"file",...,"path_display":"/notes.md",...,"size":1794},{...,"path_display":"/Photos",...}],"has_more":false}

curl -s localhost:8080/2/files/upload -H 'Authorization: Bearer sl.placeholder' \
  -H 'Content-Type: application/octet-stream' \
  -H 'Dropbox-API-Arg: {"path":"/hello.txt","mode":"add"}' --data-binary 'hello'
# -> {"client_modified":"2026-02-02T00:00:00Z",...,"name":"hello.txt","path_display":"/hello.txt",...,"size":5}

curl -s localhost:8080/2/files/download -H 'Authorization: Bearer sl.placeholder' \
  -H 'Dropbox-API-Arg: {"path":"/hello.txt"}'
# -> hello   (the metadata is in the Dropbox-API-Result response header)

Point the official JavaScript SDK at the twin through a fetch that rewrites its two hosts:

import { Dropbox } from "dropbox";
const twin = "http://localhost:8080";
const dbx = new Dropbox({
  accessToken: "sl.placeholder",
  fetch: (url, init) => fetch(url.replace(/^https:\/\/(api|content)\.dropboxapi\.com/, twin), init),
});
const me = await dbx.usersGetCurrentAccount();   // me.result.email === "ada.sealy@example.test"

Plans

dropbox-twin is on Pro and Enterprise, not Free (plans); on a Free key, rystic pull dropbox-twin and a start are refused with product_not_in_plan. Both plans serve the whole Dropbox surface and the whole control plane below, custom latency and fault injection included. Without --scenario the twin boots one Basic account with no files, and POST /_rystic/reset wipes it back to that.

Enterprise runs on a license file instead of an API key.

Auth

Dropbox reads an access token as Authorization: Bearer sl.…. The twin accepts any bearer value and checks nothing else; no Dropbox app, OAuth flow or token is involved.

  1. A request with no Authorization header, or with another scheme, answers 400 with a JSON string body, Error in call to API function "…": request is malformed, on users/get_current_account, and 401 invalid_access_token on the files and sharing routes. Dropbox answers 400 text/plain there (RYS-2663).
  2. In dropbox_team_shared the token picks the member: sl.rosa-desktop is Rosa, sl.toni-laptop is Toni, and any other value is Rosa, the world’s default caller. Every other world has one account, which every token reaches.

Coverage

93 routes across 17 resources, counted from the sim’s literal registrations.

ResourceOperations
Asynchronous job (7)POST /2/files/copy_batch/check_v2; POST /2/files/create_folder_batch/check; POST /2/files/delete_batch; POST /2/files/delete_batch/check; POST /2/files/move_batch/check_v2; POST /2/files/save_url; POST /2/files/save_url/check_job_status
File request (7)POST /2/file_requests/count; POST /2/file_requests/create; POST /2/file_requests/delete; POST /2/file_requests/delete_all_closed; POST /2/file_requests/get; POST /2/file_requests/list; POST /2/file_requests/update
File revision (content) (1)POST /2/files/restore
File-request list cursor (2)POST /2/file_requests/list/continue; POST /2/file_requests/list_v2
File/folder entry (23)POST /2/files/copy_batch_v2; POST /2/files/copy_v2; POST /2/files/create_folder_batch; POST /2/files/create_folder_v2; POST /2/files/delete_v2; GET, POST /2/files/download; GET, POST /2/files/download_zip; POST /2/files/get_metadata; GET, POST /2/files/get_preview; POST /2/files/get_temporary_link; GET, POST /2/files/get_thumbnail_v2; POST /2/files/list_revisions; POST /2/files/move_batch_v2; POST /2/files/move_v2; POST /2/files/tags/add; POST /2/files/tags/get; POST /2/files/tags/remove; POST /2/files/upload; POST /2/files/upload_session/finish; POST /2/sharing/get_file_metadata; POST /2/sharing/get_file_metadata/batch; POST /2/sharing/list_file_members/batch; POST /2/sharing/list_shared_links
list_folder cursor (3)POST /2/files/list_folder; POST /2/files/list_folder/continue; POST /2/files/list_folder/get_latest_cursor
Per-file sharing record (1)POST /2/sharing/update_file_policy
Search cursor (2)POST /2/files/search/continue_v2; POST /2/files/search_v2
Shared folder (3)POST /2/sharing/get_folder_metadata; POST /2/sharing/share_folder; POST /2/sharing/update_folder_policy
Shared link (5)POST /2/sharing/create_shared_link_with_settings; GET, POST /2/sharing/get_shared_link_file; POST /2/sharing/get_shared_link_metadata; POST /2/sharing/modify_shared_link_settings; POST /2/sharing/revoke_shared_link
Shared-file membership (5)POST /2/sharing/add_file_member; POST /2/sharing/relinquish_file_membership; POST /2/sharing/remove_file_member_2; POST /2/sharing/unshare_file; POST /2/sharing/update_file_member
Shared-folder membership (5)POST /2/sharing/add_folder_member; POST /2/sharing/mount_folder; POST /2/sharing/relinquish_folder_membership; POST /2/sharing/unmount_folder; POST /2/sharing/update_folder_member
Sharing asynchronous job (5)POST /2/sharing/check_job_status; POST /2/sharing/check_remove_member_job_status; POST /2/sharing/check_share_job_status; POST /2/sharing/remove_folder_member; POST /2/sharing/unshare_folder
Sharing listing cursor (10)POST /2/sharing/list_file_members; POST /2/sharing/list_file_members/continue; POST /2/sharing/list_folder_members; POST /2/sharing/list_folder_members/continue; POST /2/sharing/list_folders; POST /2/sharing/list_folders/continue; POST /2/sharing/list_mountable_folders; POST /2/sharing/list_mountable_folders/continue; POST /2/sharing/list_received_files; POST /2/sharing/list_received_files/continue
Space usage (1)POST /2/users/get_space_usage
Upload session (5)POST /2/files/upload_session/append_batch; POST /2/files/upload_session/append_v2; POST /2/files/upload_session/finish_batch_v2; POST /2/files/upload_session/start; POST /2/files/upload_session/start_batch
User account (3)POST /2/users/get_account; POST /2/users/get_account_batch; POST /2/users/get_current_account
  1. RPC routes take a JSON body under Content-Type: application/json; users/get_current_account, users/get_space_usage, file_requests/list, count and delete_all_closed take the literal null. The content routes — files/upload, download, download_zip, get_thumbnail_v2, get_preview, upload_session/start, append_v2 and finish, and sharing/get_shared_link_file — take their argument in the Dropbox-API-Arg header beside the bytes and answer their result in the Dropbox-API-Result header. The download routes also take it as the arg query parameter (GET /2/files/download?arg=<json>), and download and get_shared_link_file answer a byte Range with 206 and Content-Range. GET /_rystic/version names both hosts in service_hosts.
  2. Paths are matched case-insensitively and answered as path_lower beside the path_display you gave. A path that holds nothing answers 409 path/not_found; a create, or an add-mode upload, over an existing entry answers 409 path/conflict, and autorename: true lands the upload as name (1). Every error carries Dropbox’s error object and error_summary.
  3. delete_v2 is a soft delete: the entry no longer reads back by path, and files/download of it answers 409 path/not_found. list_folder with include_deleted: true lists its tombstone, and get_metadata with include_deleted: true answers it as DeletedMetadata (.tag, name, path_lower, path_display). A deleted file is still a file to files/list_revisions, which answers its revisions with is_deleted: true and server_deleted, and any of them restores through files/restore. Every upload, session finish, restore and move of a file records its own rev; list_revisions in id mode lists a file’s revisions across renames, and mode takes the bare tag ("id") as well as {".tag": "id"}.
  4. files/list_folder pages by limit and the cursor list_folder/continue walks; files/search_v2 matches the words of a name, case-insensitively, scoped by options.path, and pages through search/continue_v2. A cursor the twin never handed out answers 400.
  5. save_url, copy_batch_v2, move_batch_v2 and delete_batch answer an async_job_id; the check answers in_progress on the first read and complete on the next, or once the clock has moved two seconds past the launch, as Dropbox’s do. Each entry of a copy, move or delete batch is copied, moved or deleted as copy_v2, move_v2 and delete_v2 do it: a copied file downloads its bytes under its own rev, a folder goes with everything beneath it, and a file’s members and shared links follow it or go with it. create_folder_batch answers complete inline, and a completed save_url job has written its file at the job’s path. An upload_session/append_v2 whose cursor.offset differs from the bytes held answers 409 incorrect_offset with correct_offset; a second finish at the held offset commits the bytes again, and an append after finish answers 409 closed.
  6. Each account holds its own files: in dropbox_team_shared, Toni’s /Drafts answers path/not_found to Rosa, and what reaches her is the file Toni shared, through sharing/list_received_files and sharing/get_file_metadata.
  7. The account that shares a folder is its owner: after share_folder, list_folders lists the share, get_folder_metadata and list_folder_members answer it with access_type owner, and unshare_folder launches its job.

Not modelled, by decision: the team, paper, file_properties, contacts, account, auth, check and openid families, longpoll and webhooks, the deprecated routes whose successors are served, and the Business-only permanently_delete. An unserved route answers 404 {"message":"No route in twin for …"}, not Dropbox’s error.

Control plane

Every route under /_rystic/ is listed in routes by plan. The twin serves no longpoll and sends no webhook.

Scenarios

Five shipped worlds, each on 2026-02-02T00:00:00Z.

ScenarioWhat it holds
dropbox_personal_archiveAda Sealy’s verified Pro account and a nine-entry tree with revs, hashes and sizes: /Apps, /Documents, /notes.md, /Photos/2025
dropbox_fresh_signupa just-created Basic account, email unverified, holding only the auto-created /Apps
dropbox_deleted_backlogfour deleted files and a deleted folder across /Archive and /Drafts, listed as tombstones, beside the one file that survived; each deleted file lists its revision and restores
dropbox_paired_identitya personal Basic account paired with a Northwind work account, both is_paired, each with its own namespace and tree
dropbox_team_sharedtwo Acme members with their own tokens (sl.rosa-desktop, sl.toni-laptop), a shared team folder tree beside one private file, and a timeline the clock plays: Toni creates /Drafts, uploads a sketch and shares it with Rosa, then Rosa files her scratch note and deletes /Scratch

Seeding state

POST /_rystic/seed takes a register map — state_account (keyed account_id), state_entry (keyed path_lower), state_revision, state_shared_link, state_file_request, state_shared_folder, state_shared_folder_member, state_file_member and the rest GET /_rystic/registers lists with their fields — and lands it directly. --seed <file> or RYSTIC_SEED=<file> boots one.

Determinism & reset

Ids, revs, cursors, job ids and link URLs come from sequences the reset rewinds, so the first folder you create after a reload has the same id: every time. The clock starts at 2026-01-01T00:00:00Z in a bare boot and at 2026-02-02T00:00:00Z in every world, and stays there — never moving by wall time — until POST /_rystic/clock sets or advances it, so two identical runs from the same reset give byte-identical entries, timestamps included. POST /_rystic/reset wipes back to one Basic account with no files; POST /_rystic/scenario loads a world again without a restart.

Egress

The twin calls nothing out: save_url fetches nothing (its completed job writes a placeholder derived from the URL), a shared link, a file request or a member added to a file sends no email, and there is no webhook. GET /_rystic/egress answers an empty log.

Limitations

Known issues.

  • The scenario pack and one repair record describe calls the twin does not serve (RYS-2230)
  • A request with no Authorization header answers a 400 or 401 JSON body where Dropbox answers 400 text/plain (RYS-2663)
  • The twin’s 500 answers, such as the one for a malformed upload-session id, are never compared against Dropbox’s (RYS-2664)
  • Dropbox’s text/plain error bodies, such as the 400 for a malformed argument, are answered as JSON (RYS-2665)
  • files/tags/get on a folder addressed by the id that create_folder_v2 answered is unmeasured: the probe sends the id where Dropbox keys by path (RYS-2685)
  • An upload or move into a missing parent folder creates no intermediate folders, where Dropbox creates them; create the folder with create_folder_v2 first (RYS-2697)
  • list_folder/continue is a positional window over the current listing, not Dropbox’s change feed: a delete, a move’s source, an overwrite and an entry that sorts earlier never reach a cursor, and already-listed entries are re-sent (RYS-2772)
  • No file over 1 MiB can be stored: files/upload above 1 MiB answers 413 with a non-Dropbox body and an upload session’s second MiB answers 409 too_large, where Dropbox accepts 150 MB (RYS-2773)
  • A shared folder is not one namespace: a member’s mount lists nothing and each member’s writes stay invisible to the other; the worlds’ seeded shared_folder_ids answer invalid_id and list_folders is empty; a shared folder and its children never carry sharing_info (RYS-2774)
  • move_v2 writes under a file, moves a tombstone and moves a folder onto / (it vanishes), refuses a move onto a deleted path with to/conflict/other and a case-only rename with duplicated_or_nested_paths; an upload with a trailing slash stores name "" (RYS-2776)
  • sharing/get_file_metadata/batch and list_file_members/batch answer 500 when any entry is not a file, and a malformed cursor answers 500 on file_requests/list/continue and list_received_files/continue and is ignored by list_shared_links, where Dropbox answers per-entry errors and 409 (RYS-2777)
  • files/upload ignores strict_conflict, mints a new rev for an identical-bytes overwrite where Dropbox keeps the rev, and accepts an unknown mode (RYS-2778)
  • A request with a wrong Content-Type (form-urlencoded or text/plain on a JSON route, anything but octet-stream on an upload) is accepted where Dropbox answers 400, so a client that works on the twin is refused by Dropbox (RYS-2791)
  • create_shared_link_with_settings with requested_visibility: team_only on an account with no team answers 200 with a public link; what Dropbox answers there is unmeasured (RYS-2806)
  • A content GET that carries its token only in the authorization query parameter answers 401, and Dropbox-API-Path-Root and ns: paths are ignored, so every call reads the personal tree (RYS-2807)
  • Two accounts’ files at the same path share one set of file members and share records, so one owner’s sharing shows on the other’s file (RYS-2817)
  • add_folder_member with no access_level adds an editor, where Dropbox adds a viewer; pass access_level explicitly (RYS-2818)
  • check_remove_member_job_status words its warning differently from Dropbox, and relinquishing a mounted folder completes where Dropbox documents a mounted error (RYS-2819)
  • In the dropbox_personal_archive world, seeded folders carry the file-only fields is_downloadable, rev and size (RYS-2820)

Not served. 195 of the 283 operations in the published spec are not served.

  • files, 37 of 67 served: POST /2/files/alpha/get_metadata, POST /2/files/alpha/upload, POST /2/files/copy, POST /2/files/copy_batch, POST /2/files/copy_batch/check, POST /2/files/copy_reference/get, POST /2/files/copy_reference/save, POST /2/files/create_folder, … and 22 more
  • sharing, 38 of 44 served: POST /2/sharing/create_shared_link, POST /2/sharing/get_shared_links, POST /2/sharing/relinquish_access, POST /2/sharing/remove_file_member, POST /2/sharing/set_access_inheritance, POST /2/sharing/transfer_folder
  • users, 4 of 5 served: POST /2/users/features/get_values
  • Not served at all: account (3), auth (2), check (2), contacts (2), file_properties (16), openid (1), paper (18), riviera (15), team (97), team_log (2)

Not measured. The fidelity suite runs 252 probes, and 466 documented branches have none. 149 need a credential the measuring account does not hold (401 ×80, 403 ×68, 409 ×1). The other 317:

OperationStatusWhy
GET, POST /2/files/download200nothing the twin serves creates the record it needs
GET, POST /2/files/download206nothing the twin serves creates the record it needs
GET, POST /2/files/download409the model describes no input that reaches it (2 branches)
GET, POST /2/files/download416the model describes no input that reaches it
GET, POST /2/files/download429no request a client sends provokes it
GET, POST /2/files/download500no request a client sends provokes it
GET, POST /2/files/download_zip200nothing the twin serves creates the record it needs
GET, POST /2/files/download_zip409the model describes no input that reaches it (3 branches)
GET, POST /2/files/download_zip429no request a client sends provokes it
GET, POST /2/files/download_zip500no request a client sends provokes it
GET, POST /2/files/get_preview200nothing the twin serves creates the record it needs
GET, POST /2/files/get_preview409a well-formed request does not provoke it
GET, POST /2/files/get_preview409the model describes no input that reaches it (4 branches)
GET, POST /2/files/get_preview429no request a client sends provokes it
GET, POST /2/files/get_preview500no request a client sends provokes it
GET, POST /2/files/get_thumbnail_v2200nothing the twin serves creates the record it needs
GET, POST /2/files/get_thumbnail_v2409nothing the twin serves creates the record it needs
GET, POST /2/files/get_thumbnail_v2409the model describes no input that reaches it (8 branches)
GET, POST /2/files/get_thumbnail_v2429no request a client sends provokes it
GET, POST /2/files/get_thumbnail_v2500no request a client sends provokes it
… and 225 more

Not modeled. What the model’s operations state they leave out:

  • The 400 body is text/plain, not JSON; its exact wording is undocumented, so the branch is modelled shape-only and the response encoding is left as the default (a plain-text body on either side may read as an encoding gap rather than a value diff). (GET, POST /2/files/download)
  • The 409 LookupError tags restricted_content, unsupported_content_type and locked, and DownloadError.other, are documented but have no derivable recipe on a fixture account; they are not given their own branches. (GET, POST /2/files/download)
  • The deprecated rev argument field is declared in the header shape but no branch distinguishes its use from a rev: path. (GET, POST /2/files/download)
  • This route is served from https://content.dropboxapi.com , not the api.dropboxapi.com host the state model records for the RPC routes; the host difference is recorded here but not expressed in the vector. (GET, POST /2/files/download)
  • state_entry’s documented producers in this model include create_folder_v2 (a folder) and copy/move; a success probe needs a FILE at the referenced path, which only files/upload creates. No prior-step recipe is declared because files/upload takes its own argument in a JSON-valued header with an opaque payload body, which x_requires_steps cannot express as a body here. (GET, POST /2/files/download)
  • The 400 plain-text message for the sealed malformed-header recipe (‘{not json’) was measured live (round 20261010T112419Z) and sealed as the branch’s example_body; messages for other malformed-header variants (absent header, wrong-typed path, pattern violation) follow the same ‘Error in call to API function “files/download_zip”: ’ format but their exact texts are unmeasured. (GET, POST /2/files/download_zip)
  • The 500 InternalServerError branch has no documented body and no trigger condition, so it carries no example, no field policies and no probe recipe. (GET, POST /2/files/download_zip)
  • The route also accepts GET with the same Dropbox-API-Arg header (x-rystic-accepted-methods); only POST is modeled. (GET, POST /2/files/download_zip)
  • too_large and too_many_files carry no executable recipe: provisioning a 20 GB folder or 10,000 entries on the live account is out of reach. (GET, POST /2/files/download_zip)
  • LookupError members malformed_path, restricted_content, unsupported_content_type and locked are reachable under PreviewError.path but are not modeled as separate branches: no evidence names a request that provokes them on this route. (GET, POST /2/files/get_preview)
  • No fixture binding exists for an existing previewable file: the success example’s Dropbox-API-Arg path is a ${path:json} placeholder resolved through the state_entry reference, which needs a producer that uploads a file with a supported extension (files/upload carries its own argument in a header and an opaque byte body, which x_requires_steps cannot express today). (GET, POST /2/files/get_preview)
  • The deprecated PreviewArg.rev field is declared but carries no reference or guard: the spec directs callers to put the revision in path instead. (GET, POST /2/files/get_preview)
  • The route also accepts GET (x-rystic-accepted-methods); only the POST form is modeled. (GET, POST /2/files/get_preview)
  • path/not_file has no recipe: it needs an existing folder path, and no fixture-seeded folder is declared. (GET, POST /2/files/get_preview)
  • unsupported_extension, unsupported_content and in_progress carry no executable recipe: each needs a file of a particular kind (or a preview mid-generation) staged in the live account, and no evidence names a value that provokes them against the fixture. (GET, POST /2/files/get_preview)
  • No recipe for the 200 branch: reaching it needs a source file whose bytes are a real image of a supported extension, and the only producer on this surface (files/upload) takes its payload as opaque bytes in a request body with the path in a JSON-valued header, which x_requires_steps bodies cannot express. (GET, POST /2/files/get_thumbnail_v2)
  • The 200 payload is a server-generated thumbnail image: a hermetic twin cannot reproduce the exact bytes, so the declared bytes comparison (length + SHA-256) will differ from the oracle’s even on an otherwise correct answer. Recorded as a known fidelity gap rather than a weaker success claim. (GET, POST /2/files/get_thumbnail_v2)
  • The 20MB photo limit is documented in prose only; which of unsupported_image / conversion_error it produces is not stated. (GET, POST /2/files/get_thumbnail_v2)
  • The 400 body is text/plain (the response_encoding vocabulary has no text form); the branch is declared with no example_body, so only its status is modelled. (GET, POST /2/files/get_thumbnail_v2)
  • The route also accepts GET (x-rystic-accepted-methods); only the POST form is modelled. (GET, POST /2/files/get_thumbnail_v2)
  • … and 325 more

Sharing members are measured only as far as Dropbox’s daily invite cap allows: Dropbox refuses add_file_member and add_folder_member with 409 rate_limit or banned_member once an account has sent its day’s invites, so each round scores 19 member probes inconclusive rather than comparing them. Search matches names only, never a file’s content; content matching is declared unmodeled. Thumbnails and previews are placeholder bytes answered as application/octet-stream, and a shared link, a temporary link or a file request’s URL is a minted value its URL does not serve: sharing/get_shared_link_file answers a shared link’s bytes. A shipped world’s seeded files download their seeded bytes, and a file you upload downloads back byte for byte. The twin never answers 429.

Fidelity is scored differentially against a live Dropbox account, per response field: the last full-suite round matched 233 of 252 probes on 2026-10-13 (convergence_log.json 20261013T132744Z), with 13 inconclusive — 12 sharing-member probes the daily invite cap leaves rate-limited and one search page Dropbox’s index had not yet reached — against a target of 80%. Each release states its own number.

Release notes: Changelog

Last updated on