Skip to main content
SocialCutter

AI and agents

Dropbox to SocialCutter: images in, formats out

Watch a Dropbox folder with files/list_folder and its cursor, process each image with SocialCutter and upload every size to a second folder.

  • Dropbox
  • API v2
  • files/list_folder
  • cursor
  • files/upload
  • Python
  • centred crop

One folder in, one folder out

The pattern repeats everywhere: someone drops the design in a shared Dropbox folder and the versions for each network have to come out of it. SocialCutter does not reach into your Dropbox and does not watch it: hand it an image and a list of destinations and it returns one URL per output. Moving the files is your script’s job.

The circuit has four steps:

  1. Detect the new file in the input folder.
  2. Fetch the binary (or a temporary link).
  3. Process it with SocialCutter.
  4. Upload each output to the destination folder.

If you would rather not write code, the same circuit is built from nodes: Automate image resizing with n8n or with Make.

Why pre-generate before uploading

Dropbox stores and syncs the file as it is; it does not crop. If you upload a single master and reuse it for every slot, each destination will stretch or trim it its own way. Generating the sizes up front hands you files at the exact measurement with a centred crop that you decided.

Where the image goesSocialCutter destinationSize
Square for a card or a thumbnailinstagram post1080x1080 (1:1)
Portrait for a folder storyinstagram story1080x1920 (9:16)
Standard landscape for a cardtwitter post1200x675 (16:9)
Wide landscape for a bannerlinkedin post1200x627 (1.91:1)
Narrow folder headerlinkedin cover1128x191 (5.9:1)

Destinations and sizes come from the public catalogue: GET /api/v1/platforms returns 6 platforms and 13 destinations with their width, height and aspect ratio.

Listing the folder with files/list_folder

files/list_folder is an RPC endpoint: the arguments travel as JSON in the request body and so does the response.

POST https://api.dropboxapi.com/2/files/list_folder
Scope: files.metadata.read

The body accepts, among others, path (the folder; the empty string is the root), recursive, include_deleted, limit (approximate, up to 2000 entries) and include_non_downloadable_files.

The response is a ListFolderResult with three fields that matter:

  • entries: the files and subfolders. Each entry carries name, path_lower, path_display and a .tag distinguishing file, folder and deleted.
  • cursor: the pagination token.
  • has_more: if true, entries are still pending.

When has_more is true you continue with the cursor:

POST https://api.dropboxapi.com/2/files/list_folder/continue
Scope: files.metadata.read

That endpoint takes {"cursor": "..."} and returns another ListFolderResult. The same cursor does two jobs: finishing the pagination of a large folder and, on the next pass, asking for only what changed since the last query. Store it between runs instead of walking the whole folder again.

Two warnings from the official documentation save debugging time: if the cursor is invalidated the response carries the reset error and you start over with files/list_folder; and if two identical list_folder calls overlap, Dropbox may answer with a rate limit error, so the retry must wait for the previous request to finish.

Fetching the binary with files/download

files/download is a content endpoint: it lives on another domain and its arguments travel in the Dropbox-API-Arg header (serialised JSON, with non-ASCII characters escaped), not in the body.

POST https://content.dropboxapi.com/2/files/download
Dropbox-API-Arg: {"path": "/Designs/input/master.jpg"}
Scope: files.content.read

The response body is the file, and the metadata arrives in the Dropbox-API-Result header. This is the path to take when you want the binary to travel inside your own process without exposing any link.

files/download only works on downloadable files: documents that Dropbox keeps as an external link have to be exported first. SocialCutter works with raster images (JPG, PNG, WebP); a folder full of documents is not your case.

Two ways to hand the image to SocialCutter

Multipart, no links. The binary goes to POST /api/v1/images/process/upload with the X-API-Key header, the file in the file field and the destination list in the destinations field as a JSON string. The ceiling is 5 MB; above that it answers 413.

By URL with a temporary link. files/get_temporary_link is an RPC that takes {"path": "..."} and returns link and metadata. That link expires after four hours and then answers 410 Gone, so you request it right before the call and pass it as a source of type url:

{ "source": { "type": "url", "value": "<temporary link>" },
  "destinations": [ { "platform": "instagram", "format": "post" } ] }

It is the handiest route when you do not want the file to pass through your process twice, but the link stays exposed for those four hours.

Uploading the outputs to another folder

files/upload is a content endpoint again: the binary goes in the body with Content-Type: application/octet-stream and the arguments go in Dropbox-API-Arg, which here is a CommitInfo.

{ "path": "/Designs/output/master-instagram-post.webp",
  "mode": "add",
  "autorename": true,
  "mute": true }
  • path: target path. It must start with a slash.
  • mode: add (the default, fails if the file already exists), overwrite, or update with the file revision.
  • autorename: on a conflict, Dropbox renames instead of failing. Useful in shared folders where someone may have left a file with the same name.
  • mute: does not notify desktop clients. Worth setting on unattended processes that write many files.
  • strict_conflict: hardens how conflicts are compared.

This endpoint must not be used for files larger than 150 MB; above that you set up a session with upload_session/start. SocialCutter outputs are images of a few hundred kilobytes, so that ceiling will not come up.

A useful target name keeps the original and appends the platform and format:

master-instagram-post.webp
master-twitter-post.webp
master-linkedin-post.webp

OAuth and permission requirements

  • Scopes. An App Console app declares its permissions on the Permissions tab and they are fixed into the token:
    • files.metadata.read — list the folder and follow the cursor.
    • files.content.read — download and request the temporary link.
    • files.content.write — upload the outputs.
  • App Folder or Full Dropbox. If the app only touches its own /apps folder, App Folder access is enough. To read and write a folder that already exists in the account (this guide’s case) you need Full Dropbox.
  • Long-lived token. For background processes with nobody at the keyboard, request the token with token_access_type=offline: the token endpoint response then carries a refresh_token you use to mint new short-lived tokens without asking the user to authorise again.
  • Re-authorisation. If the user revokes the app’s access from their account, calls start answering 401 and you have to authorise again. Scopes can be widened later with the scopes parameter on the authorisation URL.
  • Upload limit to SocialCutter. 5 MB per image; above that it answers 413.

Full Python snippet

import json, os, requests

API = "https://api.socialcutter.theboomer.dev"
RPC = "https://api.dropboxapi.com/2"
CONTENT = "https://content.dropboxapi.com/2"
INPUT = "/Designs/input"
OUTPUT = "/Designs/output"
DESTINATIONS = [
    {"platform": "instagram", "format": "post"},
    {"platform": "twitter", "format": "post"},
]

dbx = requests.Session()
dbx.headers["Authorization"] = f"Bearer {os.environ['DROPBOX_TOKEN']}"

sc = requests.Session()
sc.headers["X-API-Key"] = os.environ["SOCIALCUTTER_API_KEY"]

def list_folder(path, cursor=None):
    if cursor is None:
        url, body = f"{RPC}/files/list_folder", {"path": path}
    else:
        url, body = f"{RPC}/files/list_folder/continue", {"cursor": cursor}
    r = dbx.post(url, json=body, timeout=60)
    r.raise_for_status()
    return r.json()

def download(path):
    # The arguments travel in the header, not in the body
    r = dbx.post(f"{CONTENT}/files/download",
                 headers={"Dropbox-API-Arg": json.dumps({"path": path})},
                 timeout=120)
    r.raise_for_status()
    return r.content          # the binary; metadata lands in Dropbox-API-Result

def upload(path, data):
    r = dbx.post(f"{CONTENT}/files/upload",
                 headers={"Dropbox-API-Arg": json.dumps({
                     "path": path, "mode": "add",
                     "autorename": True, "mute": True}),
                     "Content-Type": "application/octet-stream"},
                 data=data, timeout=120)
    r.raise_for_status()
    return r.json()

def temporary_link(path):
    r = dbx.post(f"{RPC}/files/get_temporary_link",
                 json={"path": path}, timeout=60)
    r.raise_for_status()
    return r.json()["link"]   # expires after four hours

result = list_folder(INPUT)
while True:
    for entry in result["entries"]:
        if entry[".tag"] != "file" or not entry["name"].lower().endswith(".jpg"):
            continue

        binary = download(entry["path_lower"])
        r = sc.post(f"{API}/api/v1/images/process/upload",
                    headers={"X-API-Key": os.environ["SOCIALCUTTER_API_KEY"]},
                    files={"file": (entry["name"], binary, "image/jpeg")},
                    data={"destinations": json.dumps(DESTINATIONS)}, timeout=120)
        r.raise_for_status()

        for out in r.json()["outputs"]:
            img = sc.get(out["url"], timeout=120)
            img.raise_for_status()
            target = f"{OUTPUT}/{entry['name']}-{out['platform']}-{out['format']}.webp"
            print(upload(target, img.content)["path_display"])

    if not result["has_more"]:
        break
    result = list_folder(INPUT, cursor=result["cursor"])

Store the cursor from the last pass next to your process state: the next run can resume from there instead of scanning the whole folder again.

# 1. Temporary link to the master (expires in 4 hours)
LINK=$(curl -s -X POST "https://api.dropboxapi.com/2/files/get_temporary_link" \
  -H "Authorization: Bearer $DROPBOX_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"path":"/Designs/input/master.jpg"}' | jq -r .link)

# 2. Generate the formats
curl -s -X POST "https://api.socialcutter.theboomer.dev/api/v1/images/process" \
  -H "X-API-Key: $SOCIALCUTTER_API_KEY" \
  -H "Content-Type: application/json" \
  --data "{\"source\":{\"type\":\"url\",\"value\":\"$LINK\"},\"destinations\":[{\"platform\":\"instagram\",\"format\":\"post\"}]}" \
  > out.json

jq -r '.outputs[] | .platform + " " + .format + " " + .url' out.json

# 3. Upload the first output to the target folder
URL=$(jq -r '.outputs[0].url' out.json)
NAME=$(jq -r '"\(.outputs[0].platform)-\(.outputs[0].format).webp"' out.json)
curl -s -X POST "https://content.dropboxapi.com/2/files/upload" \
  -H "Authorization: Bearer $DROPBOX_TOKEN" \
  -H "Content-Type: application/octet-stream" \
  -H "Dropbox-API-Arg: {\"path\":\"/Designs/output/$NAME\",\"mode\":\"add\",\"autorename\":true}" \
  --data-binary @out.webp | jq -r '.path_display, .size'

If you are uploading several files in a row, remember that write calls compete with each other: space them out or group the uploads belonging to the same image.

Cost

  • 1 use per destination (platform and format) per SocialCutter request. Repeated destinations are not charged twice and failed processings are refunded.
  • Plans: Free 3 uses/day, 3 EUR 10/day, 9 EUR 30/day and 29 EUR 100/day, all with the API and the MCP server included.
  • The Dropbox API charges nothing per call for normal apps, but on Dropbox Business teams uploads count against the monthly data transport limit.

Common errors

CodeSourceWhat happensWhat to do
400DropboxMalformed body or header, or JSON failing validationFix the payload; retrying will not help
401DropboxToken expired, revoked or short on permissionsRefresh it with the refresh_token or authorise again
403DropboxThe account or team cannot reach that call or resourceCheck the scope and the path; the app may be in App Folder and cannot see the folder
409DropboxEndpoint-specific error: the detail sits in error and error_summaryThis is the path_not_found case: someone moved or deleted the file
429DropboxToo many calls or too many simultaneous writesWait for the seconds in Retry-After, or apply exponential backoff
500DropboxInternal error, usually briefRetry with backoff, not in a tight loop
resetDropboxCursor invalidatedStart again with files/list_folder and keep the new cursor
410 GoneTemporary linkMore than four hours since it was issuedCall files/get_temporary_link again right before using it
401SocialCutterMissing X-API-Key header or an invalid keyCheck the value starts with sc_ and is still active
413SocialCutterThe master exceeds 5 MBShrink the image before sending it
429SocialCutterWallet quota exhaustedCheck GET /api/v1/wallet before large batches

What SocialCutter does not do

The crop is centred and deterministic: it scales the image and trims the excess equally on both sides. It does not read the content of the image to decide what to keep, it does not edit the photo (no colour work, no background removal, no text compositing), it does not post to social networks and it does not accept files over 5 MB. It produces the versions at the exact size of each destination and returns their URLs: the exchange with Dropbox and the publishing are your script’s job.

Next steps

Frequently asked questions

Does the image have to be public for SocialCutter to read it?

No. If the file already lives in Dropbox, the short path is files/get_temporary_link: it returns a temporary link with its own token inside, which you pass as a source of type url. If you would rather not expose even a temporary link, download the binary with files/download and send it as multipart to /api/v1/images/process/upload.

Which scopes does the Dropbox app need?

files.metadata.read to list the folder and follow the cursor, files.content.read to download the file or ask for its temporary link, and files.content.write to upload the outputs. You tick them on the Permissions tab of the App Console.

How do I watch the folder without walking it every time?

Store the cursor returned by files/list_folder and call files/list_folder/continue on each pass: you only get what changed since the previous query. If the cursor is invalidated the response carries the reset error and you ask for a fresh one with files/list_folder.

How long does a Dropbox temporary link last?

Four hours. After that it answers 410 Gone, so request it right before calling SocialCutter instead of queueing it. Dropbox's own documentation also warns that the URL should not be used to display content directly in the browser.

What does it cost to prepare one image?

1 use per destination, meaning per platform and format pair. An image with three destinations spends 3 uses. Plans include the API and the MCP server.