Real-world recipes for common Dropbox tasks with Magpie. All examples assume a client:
# Short-lived: Dropbox access tokens expire in about 4 hours
client = Magpie.Client.new(System.fetch_env!("DROPBOX_ACCESS_TOKEN"))
# Long-running: Magpie keeps the access token fresh from a refresh token
client =
Magpie.Client.new(
refresh_token: System.fetch_env!("DROPBOX_REFRESH_TOKEN"),
app_key: System.fetch_env!("DROPBOX_APP_KEY"),
app_secret: System.fetch_env!("DROPBOX_APP_SECRET")
)Checking your credentials
# Validates the user token — Dropbox echoes the query back
{:ok, %{"result" => "ping"}} = Magpie.Check.user(client)
# Who am I?
{:ok, %{"email" => email}} = Magpie.Users.current_account(client)
# A token Dropbox no longer accepts comes back as a plain error
{:error, %Magpie.Error{status: 401, summary: "invalid_access_token/.."}} =
Magpie.Check.user(Magpie.Client.new("nope"))Staying authenticated for more than 4 hours
Anything that runs unattended — a nightly backup, a worker, a daemon — needs a refresh token instead of an access token. Put the token holder in your supervision tree and point clients at it:
# lib/my_app/application.ex
children = [
{Magpie.Auth.TokenServer,
name: MyApp.DropboxToken,
app_key: System.fetch_env!("DROPBOX_APP_KEY"),
app_secret: System.fetch_env!("DROPBOX_APP_SECRET"),
refresh_token: System.fetch_env!("DROPBOX_REFRESH_TOKEN")}
]# Cheap to build — the token lives in the server, not in the struct
client = Magpie.Client.new(token_provider: {Magpie.Auth.TokenServer, MyApp.DropboxToken})
# Runs at 3am on day 40 just like it did on day 1
{:ok, _} = Magpie.Files.upload_file(client, "/Backup/db.dump", "priv/db.dump")The access token is renewed a few minutes before it expires, concurrent
requests share a single refresh, and a request rejected as
expired_access_token is refreshed and replayed once — none of which you
have to write.
Getting that refresh token takes a browser round-trip, once:
# 1. Open this URL, approve the app, copy the code Dropbox shows
Magpie.Auth.authorize_url(app_key)
# 2. Trade the code for tokens and store the refresh one as a secret
{:ok, token} = Magpie.Auth.exchange_code(app_key, code, app_secret: app_secret)
token.refresh_tokenThe OAuth guide covers the whole picture: the web redirect
flow, PKCE for apps that cannot keep a secret, persisting tokens with
:on_refresh, and writing your own Magpie.Auth.TokenProvider when tokens
live in your database.
Using Dropbox as simple storage
Magpie.Storage is the recommended entry point when you need storage rather
than a Dropbox-specific endpoint. It offers an object-storage-style API while
still using Dropbox paths — there are no S3 buckets or S3 compatibility:
alias Magpie.Storage
# A local file: small files use one request, large ones use an upload session
{:ok, %Magpie.FileMetadata{} = invoice} =
Storage.put(client, "/Invoices/2026-09.pdf", {:file, "priv/invoice.pdf"})
# In-memory content can be a binary or iodata
{:ok, %Magpie.FileMetadata{}} =
Storage.put(
client,
"/Exports/status.json",
{:binary, Jason.encode_to_iodata!(%{status: "ready"})},
mode: "overwrite"
)
# Any enumerable of binary/iodata chunks is uploaded without collecting it all
csv_rows = Stream.map(rows, fn row -> [to_string(row.id), ",", row.email, "\n"] end)
{:ok, %Magpie.FileMetadata{}} =
Storage.put(client, "/Exports/users.csv", {:stream, csv_rows})Use get/3 when the file belongs in memory and download/4 for large files.
download/4 writes to a temporary sibling first, so an API failure does not
replace an existing destination:
{:ok, json} = Storage.get(client, "/Exports/status.json")
%{"status" => "ready"} = Jason.decode!(json)
{:ok, "tmp/users.csv"} =
Storage.download(client, "/Exports/users.csv", "tmp/users.csv", mkdir_p: true)
# Temporary direct-download links normally expire after four hours
{:ok, url} = Storage.url(client, "/Invoices/2026-09.pdf")Metadata, existence checks and listings use the same small interface:
true = Storage.exists?(client, "/Invoices/2026-09.pdf")
false = Storage.exists?(client, "/Invoices/missing.pdf")
{:ok, %Magpie.FileMetadata{size: size, content_hash: hash}} =
Storage.stat(client, "/Invoices/2026-09.pdf")
# Eager: returns all cursor pages in one list
{:ok, entries} = Storage.list(client, "/Invoices", recursive: true)
# Lazy: fetches only as many pages as the consumer needs
recent_invoices =
client
|> Storage.stream("/Invoices", recursive: true)
|> Stream.filter(&match?(%Magpie.FileMetadata{}, &1))
|> Enum.take(10)
{:ok, %Magpie.FileMetadata{}} =
Storage.delete(client, "/Invoices/2026-09.pdf")For production uploads, Magpie can verify Dropbox's content hash and avoid sending a local file or binary that is already identical:
case Storage.put(client, "/Backup/db.dump", {:file, "priv/db.dump"},
verify: true,
skip_unchanged: true,
progress: fn sent, total -> Logger.info("#{sent}/#{total} bytes") end
) do
{:ok, :unchanged, %Magpie.FileMetadata{} = file} -> {:unchanged, file}
{:ok, %Magpie.FileMetadata{} = file} -> {:uploaded, file}
{:error, error} -> {:failed, error}
endUse if_rev to replace only the revision your application last read. Dropbox
returns a conflict if another writer changed the file first:
{:ok, %Magpie.FileMetadata{rev: rev}} = Storage.stat(client, "/state.json")
Storage.put(client, "/state.json", {:binary, Jason.encode!(new_state)},
if_rev: rev,
verify: true
)Common relocation, folder and concurrent batch operations stay in the same interface. Batch results preserve input order and isolate failures:
{:ok, %Magpie.FolderMetadata{}} = Storage.mkdir(client, "/Archive")
{:ok, _} = Storage.copy(client, "/report.pdf", "/Archive/report.pdf")
{:ok, _} = Storage.move(client, "/draft.pdf", "/Archive/final.pdf")
{:ok, results} =
Storage.put_many(
client,
[
{"/Exports/one.json", {:binary, Jason.encode!(one)}},
{"/Exports/two.json", {:binary, Jason.encode!(two)}, if_rev: two_rev}
],
max_concurrency: 4,
verify: true,
on_progress: fn key, result -> Logger.debug("#{key}: #{inspect(result)}") end
)
for {key, {:error, error}} <- results do
message = if is_exception(error), do: Exception.message(error), else: inspect(error)
Logger.warning("#{key}: #{message}")
endMagpie emits [:magpie, :request, :start | :stop | :exception | :retry] and
[:magpie, :transfer, :progress] events. Stop metadata includes the HTTP
status and Dropbox request ID. This example observes completed, failed and
retried requests, plus transfer progress:
defmodule MyApp.MagpieTelemetry do
require Logger
def handle_event([:magpie, :request, event], measurements, metadata, _config) do
Logger.debug("Dropbox #{event}: #{metadata.operation} #{inspect(measurements)}")
end
def handle_event(
[:magpie, :transfer, :progress],
%{transferred: transferred, total: total},
%{direction: direction, path: path},
_config
) do
Logger.debug("Dropbox #{direction} #{path}: #{transferred}/#{inspect(total)} bytes")
end
end
request_events =
for event <- [:stop, :exception, :retry], do: [:magpie, :request, event]
:telemetry.attach_many(
"my-app-magpie",
request_events ++ [[:magpie, :transfer, :progress]],
&MyApp.MagpieTelemetry.handle_event/4,
nil
)Normal functions return success or error tuples; expected Req transport errors
are values too, so a failed network call does not bring down a background job.
For one-off scripts, bang variants such as get!/3, download!/4 and
delete!/3 return the value directly and raise on failure. put!/4 does the
same after an upload, or returns {:unchanged, metadata} when
skip_unchanged: true avoids the upload. Use Magpie.Files when you need
Dropbox-specific operations beyond this storage interface.
Uploading files
Magpie.Files.upload_file/4 picks the right strategy for you: small files go
through a single request, files above 150 MiB are automatically streamed
through an upload session in chunks — without loading the file into memory:
# Works the same for a 2 KB text file or a 40 GB backup
{:ok, %Magpie.FileMetadata{} = file} =
Magpie.Files.upload_file(client, "/Backup/db.dump", "priv/db.dump")
file.size
# => 1_073_741_824
# Dropbox hashes what it stored — compare it with the local file
file.content_hash == Magpie.Metadata.content_hash(File.stream!("priv/db.dump", 4 * 1024 * 1024))
# => true
# Overwrite an existing file, with a custom chunk size
{:ok, _} =
Magpie.Files.upload_file(client, "/Backup/db.dump", "priv/db.dump",
mode: "overwrite",
chunk_size: 16 * 1024 * 1024
)Uploads coming from a Phoenix controller or a LiveView form — including streaming straight into Dropbox without touching your disk — are covered in the Phoenix guide.
If you need manual control over the session (e.g. the data is generated on the fly), use the lower-level primitives:
{:ok, %{"session_id" => sid}} = Magpie.Files.UploadSession.start_data(client, chunk1)
{:ok, _} = Magpie.Files.UploadSession.append_data(client, sid, byte_size(chunk1), chunk2)
{:ok, %Magpie.FileMetadata{}} =
Magpie.Files.UploadSession.finish_data(
client,
sid,
byte_size(chunk1) + byte_size(chunk2),
%{"path" => "/generated.bin"}
)Downloading files
# Into memory
{:ok, %{body: contents}} = Magpie.Files.download(client, "/Backup/db.dump")
File.write!("db.dump", contents)
# Verify it arrived intact
{:ok, %Magpie.FileMetadata{content_hash: hash}} = Magpie.Files.get_metadata(client, "/Backup/db.dump")
^hash = Magpie.Metadata.content_hash(contents)
# A whole folder as a zip
{:ok, %{body: zip}} = Magpie.Files.download_zip(client, "/Backup")
File.write!("backup.zip", zip)
# Or hand out a short-lived direct link instead
{:ok, %{"link" => url}} = Magpie.Files.get_temporary_link(client, "/Backup/db.dump")Working with metadata
The files endpoints describe every entry with one of three structs —
Magpie.FileMetadata, Magpie.FolderMetadata or Magpie.DeletedMetadata
— so the kind of entry is the struct you match on, timestamps are
DateTimes and the content_hash is a field (see Magpie.Metadata):
{:ok, %Magpie.FileMetadata{} = file} = Magpie.Files.get_metadata(client, "/Backup/db.dump")
file.size
# => 1_073_741_824
file.server_modified
# => ~U[2026-09-01 03:00:12Z]
DateTime.diff(DateTime.utc_now(), file.server_modified, :hour)
# => 41
# Folders and files come from the same call
case Magpie.Files.get_metadata(client, path) do
{:ok, %Magpie.FileMetadata{size: size}} -> {:file, size}
{:ok, %Magpie.FolderMetadata{}} -> :folder
{:error, %Magpie.Error{status: 409}} -> :not_found
endEndpoints that answer with a result object (create_folder/2,
delete_folder/2, copy/3, move/3) are unwrapped, so the struct is the
whole result:
{:ok, %Magpie.FolderMetadata{id: "id:" <> _}} = Magpie.Files.create_folder(client, "/Photos/2026")
{:ok, %Magpie.FileMetadata{path_display: "/Archive/a.txt"}} = Magpie.Files.move(client, "/a.txt", "/Archive/a.txt")Listing folders lazily
Magpie.Files.ListFolder.stream/2 hides cursor pagination behind a regular
Stream — pages are only fetched as you consume it, and every entry is a
metadata struct:
# All PDF names in a folder, no matter how many pages Dropbox returns
client
|> Magpie.Files.ListFolder.stream("/Documents")
|> Stream.filter(&match?(%Magpie.FileMetadata{}, &1))
|> Stream.filter(&String.ends_with?(&1.name, ".pdf"))
|> Enum.map(& &1.name)
# Lazy: only fetches as many pages as needed for the first 10 entries
client |> Magpie.Files.ListFolder.stream("/Photos") |> Enum.take(10)
# Files changed in the last day, largest first
client
|> Magpie.Files.ListFolder.stream("/Shared", %{"recursive" => true})
|> Stream.filter(&match?(%Magpie.FileMetadata{}, &1))
|> Stream.filter(&(DateTime.diff(DateTime.utc_now(), &1.server_modified, :day) < 1))
|> Enum.sort_by(& &1.size, :desc)
# Deleted entries show up as Magpie.DeletedMetadata when asked for
client
|> Magpie.Files.ListFolder.stream("/Inbox", %{"include_deleted" => true})
|> Enum.filter(&match?(%Magpie.DeletedMetadata{}, &1))The same pattern is available for searches, shared folders and file
requests — and Magpie.Pager.stream/3 lets you wrap any other paginated
endpoint yourself. Search matches carry their metadata struct under
"metadata"; the sharing and file-request streams are outside the files
namespace and yield Dropbox's maps as they are:
client
|> Magpie.Files.search_stream("invoice", %{"path" => "/Work"})
|> Enum.map(fn %{"metadata" => %Magpie.FileMetadata{} = file} -> file.path_display end)
client |> Magpie.Sharing.list_folders_stream() |> Enum.map(& &1["name"])
client |> Magpie.FileRequests.stream() |> Enum.count()Watching folders for changes with a saved cursor
Suppose /backups/clients/<client_id> holds each client's backup files and your
application keeps a file-list snapshot for each client. Listing the root and
then every client folder used to cost 2 + N requests in this workload, before
any extra pagination. Instead, keep one recursive cursor per watched root and
refresh only the client folders touched since the previous cycle. With no
changes, a cycle needs just one request per root, making polling every few
minutes practical. Active cycles also pay for continuation pages and touched
folder listings.
The application module passed as app supplies load_cursor(key) returning
{:ok, cursor_or_nil}, plus save_cursor(key, cursor),
write_snapshot(client_folder, files) and full_scan(root) returning :ok or
{:error, reason}. Scope these callbacks to the Dropbox account/namespace;
the key includes the normalized root and fixed listing options. Snapshot writes
must be idempotent replacements. full_scan/1 requests the application's full
reconciliation, including removal of clients that no longer exist.
defmodule MyApp.BackupWatcher do
alias Magpie.{Error, Storage}
def cycle(client, root, app) do
root = root |> String.downcase() |> String.trim_trailing("/")
key = {:backup_watch, root, [recursive: true, limit: 2000]}
with {:ok, cursor} <- app.load_cursor(key) do
result = if cursor, do: Storage.continue_list(client, cursor), else: {:error, :seed}
case pages(client, root, result, MapSet.new()) do
{:ok, final, touched} ->
with :ok <- refresh(client, touched, app), do: app.save_cursor(key, final)
{:error, reason} when reason == :seed or is_struct(reason, Magpie.CursorError) ->
first = Storage.list_page(client, root, recursive: true, limit: 2000)
with {:ok, final, _} <- pages(client, root, first, nil),
:ok <- app.save_cursor(key, final),
do: app.full_scan(root)
error ->
error
end
end
end
defp pages(client, root, result, touched) do
with {:ok, page} <- result do
touched = if touched, do: Enum.reduce(page.entries, touched, &touch(&1, root, &2))
if page.has_more do
pages(client, root, Storage.continue_list(client, page.cursor), touched)
else
{:ok, page.cursor, touched}
end
end
end
defp touch(%{path_lower: path} = entry, root, touched) when is_binary(path) do
prefix = root <> "/"
if String.starts_with?(path, prefix) do
[id | rest] = path |> String.replace_prefix(prefix, "") |> String.split("/", parts: 2)
folder = is_struct(entry, Magpie.FolderMetadata) or is_struct(entry, Magpie.DeletedMetadata)
if id != "" and (rest != [] or folder), do: MapSet.put(touched, prefix <> id), else: touched
else
touched
end
end
defp touch(_, _, touched), do: touched
defp refresh(client, touched, app) do
Enum.reduce_while(touched, :ok, fn path, :ok ->
result =
case Storage.list(client, path, recursive: true) do
{:ok, entries} ->
files = Enum.filter(entries, &is_struct(&1, Magpie.FileMetadata))
app.write_snapshot(path, files)
{:error, %Error{} = error} ->
if Error.not_found?(error), do: :ok, else: {:error, error}
error ->
error
end
if result == :ok, do: {:cont, :ok}, else: {:halt, result}
end)
end
endCall MyApp.BackupWatcher.cycle(client, "/backups/clients", MyApp.BackupState)
from your application's scheduler. Each file, folder or deleted entry's
path_lower identifies its first-level client folder; nested changes are folded
into that same MapSet entry. Root entries, files directly under the root and
paths outside it are ignored. A direct deletion is a candidate client folder,
since deleted metadata does not identify its former type; not_found skips it
and leaves removal to the next full reconciliation.
Only a successfully drained window with all snapshots written advances the
cursor, including an empty window. Other listing or snapshot errors leave the
saved cursor intact: the next cycle repeats the window and may rewrite snapshots
that already succeeded. Without a cursor, or after Magpie.CursorError, the
module drains a recursive listing without retaining its entries, saves its final
cursor, then requests a full scan of the present state. Make that full-scan
request durable and retry it independently; coordinate checkpointing and enqueueing
in the application so a failed enqueue cannot leave initialization unfinished.
Run one cycle at a time per account/root so a slow worker cannot overwrite a newer checkpoint. An Oban unique job can reduce duplicate schedules; enforce serialization for the whole cycle, including full reconciliation. Keep a periodic full scan as a safety net. See the incremental guide for cursor recovery and webhooks.
Batch operations without polling boilerplate
Batch endpoints may finish asynchronously and hand you an async_job_id.
Magpie.Async.await/4 polls the matching check endpoint with exponential
backoff — and passes through jobs that completed synchronously, so you can
pipe it unconditionally:
entries = [
%{"from_path" => "/Old/a.txt", "to_path" => "/New/a.txt"},
%{"from_path" => "/Old/b.txt", "to_path" => "/New/b.txt"}
]
{:ok, launch} = Magpie.Files.MoveBatch.move_batch(client, entries)
{:ok, %{"entries" => results}} =
Magpie.Async.await(client, launch, &Magpie.Files.MoveBatch.check/2, timeout: 120_000)Shared links
# Anyone with the link can view
{:ok, %{"url" => url}} =
Magpie.Sharing.create_shared_link(client, "/report.pdf", %{"audience" => "public"})
# List existing links for a path, then revoke them
{:ok, %{"links" => links}} = Magpie.Sharing.list_shared_links(client, %{"path" => "/report.pdf"})
Enum.each(links, fn %{"url" => url} -> Magpie.Sharing.revoke_shared_link(client, url) end)File requests
{:ok, request} =
Magpie.FileRequests.create(client, "Send me the invoices", "/Inbox/Invoices", %{
"deadline" => "2027-01-01T00:00:00Z"
})
request["url"]
# => "https://www.dropbox.com/request/..."Handling errors
Successful calls return {:ok, result}. Dropbox errors come back as
{:error, %Magpie.Error{}} carrying the HTTP status, Dropbox's
error_summary and the full decoded error body:
case Magpie.Files.create_folder(client, "/Existing") do
{:ok, %Magpie.FolderMetadata{} = folder} ->
folder
{:error, %Magpie.Error{status: 409, summary: "path/conflict" <> _}} ->
:already_exists
{:error, error} ->
# Magpie.Error is an exception — raise it when you cannot handle it
raise error
endPaginated streams raise Magpie.Error instead, since a Stream
cannot return a tuple mid-enumeration.
For storage operations, the classification helpers avoid matching the exact
Dropbox error_summary, which may gain extra path segments over time:
case Magpie.Storage.get(client, "/Invoices/latest.pdf") do
{:ok, contents} ->
contents
{:error, %Magpie.Error{} = error} ->
cond do
Magpie.Error.not_found?(error) -> :missing
Magpie.Error.rate_limited?(error) -> :try_again_later
true -> raise error
end
endTesting your app
Magpie's requests can be routed to Req.Test
stubs, so your test suite never touches the network. In config/test.exs:
config :magpie, req_options: [plug: {Req.Test, Magpie}]Then stub responses per test. Stubs return JSON exactly as Dropbox would —
including the ".tag" on listed entries — and Magpie decodes it into the
same structs your code sees in production:
test "lists the backup folder" do
Req.Test.stub(Magpie, fn conn ->
Req.Test.json(conn, %{
"entries" => [%{".tag" => "file", "name" => "db.dump", "rev" => "015", "size" => 42}],
"cursor" => "c",
"has_more" => false
})
end)
assert {:ok, [%Magpie.FileMetadata{name: "db.dump", size: 42}]} = MyApp.Backups.list()
endIncremental listings and webhook processing
See the incremental guide for page checkpoints, explicit cursor recovery, raw-body webhook validation, background jobs and offline consumer tests.