A Python CLI tool to sync your X (Twitter) bookmarks and liked posts to Raindrop.io collections.
- Sync X bookmarks and/or liked posts to Raindrop.io collections
- Optional: map X bookmark folders to Raindrop subcollections under the parent collection
- Per-source configuration: separate collections, tags, link modes, and remove-from-X settings
- Configurable link handling:
- Use X post permalink
- Use first external URL from the post (with fallback to permalink)
- Both: create entries for external URLs with X permalink stored in notes
- Apply custom tags to synced Raindrops
- Optional: unbookmark and/or unlike on X after syncing
- Idempotent syncing with local state tracking (bookmarks and likes tracked separately)
- Dry-run mode for safe testing
- Interactive OAuth 2.0 PKCE authentication flow for X
- Python 3.12 or higher
- uv for dependency and environment management
- X Developer account with OAuth 2.0 app
- Raindrop.io account with API token
pip install x2raindrop-cligit clone https://github.com/dotWee/x2raindrop-cli.git
cd x2raindrop-cli
# Install dependencies
uv syncPull the image from GitHub Container Registry:
docker pull ghcr.io/dotwee/x2raindrop-cli:latestRun commands by mounting your local directory (for config and state persistence):
# Show help
docker run --rm ghcr.io/dotwee/x2raindrop-cli --help
# Initialize config in current directory
docker run --rm -v "$PWD":/data ghcr.io/dotwee/x2raindrop-cli config init
# Sync bookmarks
docker run --rm -v "$PWD":/data ghcr.io/dotwee/x2raindrop-cli sync --collection 12345
# Sync bookmarks and likes
docker run --rm -v "$PWD":/data ghcr.io/dotwee/x2raindrop-cli \
sync --collection 12345 --likes --likes-collection 54321See the Docker Usage section for more details.
You have two options for X authentication:
If you already have an access token (e.g., from another OAuth flow or the X Developer Portal):
- Set
X_ACCESS_TOKENin your config or environment - No browser login required - just run sync directly
[x]
access_token = "your_access_token_here"# Optional: provide refresh_token to enable automatic token refreshrefresh_token = "your_refresh_token_here"For browser-based login:
- Go to the X Developer Portal
- Create a new project and app (or use an existing one)
- Under "User authentication settings", configure:
- App permissions: Read and write
- Type of App: Native App (for PKCE without client secret) or Confidential Client
- Callback URL:
http://127.0.0.1:8765/callback
- Note your Client ID (and Client Secret if using Confidential Client)
- Run
x2raindrop x loginto authenticate
Required OAuth 2.0 Scopes:
bookmark.read- Read your bookmarksbookmark.write- Remove bookmarks (optional, only if using--remove-from-xfor bookmarks)like.read- Read your liked posts (required for likes sync)like.write- Unlike posts (optional, only if using--remove-from-xfor likes)tweet.read- Read tweet datausers.read- Read user profile dataoffline.access- Refresh tokens for persistent access
If you previously authenticated without like.read / like.write, run
x2raindrop x logout then x2raindrop x login again so the new scopes are granted.
- Go to Raindrop.io Integrations
- Under "For Developers", create a new app or use "Test token"
- Copy the Test token for personal use
Create a configuration file:
# Create default config file in current directory
uv run x2raindrop config init
# Edit the config file
nano config.tomlOr use environment variables:
# X API credentials (choose one method)# Option A: Direct access tokenexport X_ACCESS_TOKEN="your_access_token"# Option B: OAuth PKCE flow (then run `x2raindrop x login`)export X_CLIENT_ID="your_client_id"export X_CLIENT_SECRET="your_client_secret"# Optional for public clients# Raindrop.io credentialsexport RAINDROP_TOKEN="your_raindrop_token"# Sync settings (nested per source; note single underscore after SYNC_)export SYNC_BOOKMARKS__COLLECTION_ID="12345"export SYNC_BOOKMARKS__TAGS='["x-bookmark", "auto-synced"]'export SYNC_BOOKMARKS__REMOVE_FROM_X="false"export SYNC_BOOKMARKS__SKIP_EXISTING_LINKS="true"export SYNC_BOOKMARKS__LINK_MODE="permalink"# Optional: enable liked-post syncexport SYNC_LIKES__ENABLED="true"export SYNC_LIKES__COLLECTION_ID="54321"export SYNC_LIKES__TAGS='["x-like", "auto-synced"]'First, authenticate with X using the interactive OAuth 2.0 PKCE flow:
uv run x2raindrop x loginThis will open your browser for authorization. After approving, the tokens are saved locally.
Find the collection ID you want to sync to:
uv run x2raindrop raindrop collectionsBasic bookmark sync:
uv run x2raindrop sync --collection 12345Sync liked posts only:
uv run x2raindrop sync --no-bookmarks --likes --likes-collection 54321Sync both bookmarks and likes (each to its own Raindrop collection):
uv run x2raindrop sync --collection 12345 --likes --likes-collection 54321Passing
--likes-collectionalso enables likes sync unless--no-likesis set.
With options:
# Sync bookmarks with custom tags (--tags applies to bookmarks only;# configure likes tags under [sync.likes] in config.toml)
uv run x2raindrop sync --collection 12345 --tags "x,bookmarks,auto"# Use first external URL from tweets (applies to all enabled sources)
uv run x2raindrop sync --collection 12345 --link-mode first_external_url
# Remove synced items from X after syncing (unbookmark and/or unlike)
uv run x2raindrop sync --collection 12345 --likes --likes-collection 54321 --remove-from-x
# Override config that has remove_from_x / dry_run enabled
uv run x2raindrop sync --no-remove-from-x --no-dry-run
# Dry run - see what would happen without making changes
uv run x2raindrop sync --collection 12345 --likes --likes-collection 54321 --dry-runEnable likes permanently in config.toml with [sync.likes] enabled = true and a
collection_id, then plain x2raindrop sync will sync both sources.
uv run x2raindrop x statusuv run x2raindrop x logoutDefault: config.toml in the current working directory (project root).
Override with --config flag on any command.
log_level = "INFO"
[x]
# Option A: Direct access token (simplest - no browser login needed)access_token = ""# Option B: OAuth PKCE flow (use `x2raindrop x login`)client_id = ""client_secret = ""# Leave empty for public clientsredirect_uri = "http://127.0.0.1:8765/callback"scopes = [
"bookmark.read",
"bookmark.write",
"like.read",
"like.write",
"tweet.read",
"users.read",
"offline.access",
]
[raindrop]
token = "YOUR_RAINDROP_TOKEN"
[sync]
dry_run = false
[sync.bookmarks]
enabled = truecollection_id = 12345collection_title = ""# Optional: look up by title when collection_id is unsettags = ["x-bookmark", "auto-synced"]
remove_from_x = falseskip_existing_links = truelink_mode = "permalink"# permalink, first_external_url, or bothboth_behavior = "one_external_plus_note"# one_external_plus_note or two_raindropsmap_folders_to_subcollections = false# Map X bookmark folders to Raindrop child collections
[sync.likes]
enabled = falsecollection_id = 54321collection_title = ""# Optional: look up by title when collection_id is unsettags = ["x-like", "auto-synced"]
remove_from_x = false# Unlike posts on X after syncingskip_existing_links = truelink_mode = "permalink"both_behavior = "one_external_plus_note"Copy config.example.toml as a starting point, or run x2raindrop config init.
Legacy flat [sync] settings (without bookmarks / likes sections) are still
supported and are treated as bookmark settings for backward compatibility.
Each source can be configured independently: different Raindrop collections, tags, link modes, and remove-from-X behavior.
When map_folders_to_subcollections = true on [sync.bookmarks], X bookmark
folders are mirrored as Raindrop subcollections under the configured parent
collection. For example, bookmarks in an X folder named Test are stored in a
child collection also named Test. The child collection is created if it does
not already exist. Unfiled bookmarks stay in the parent collection. This option
requires a regular Raindrop collection (not All / Unsorted / Trash).
CLI flags override config when passed. Boolean flags are tri-state, so
--no-remove-from-x and --no-dry-run clear values that are enabled in config.
log_level controls stdlib/structlog verbosity for CLI commands.
| Mode | Description |
|---|---|
permalink | Create a Raindrop with the X post URL |
first_external_url | Use the first external URL in the tweet (falls back to permalink if none) |
both | Create entries for both external URL and permalink (behavior configurable) |
When link_mode = "both" and the tweet contains an external URL:
| Option | Description |
|---|---|
one_external_plus_note | Create one Raindrop for the external URL, store X permalink in the note |
two_raindrops | Create two separate Raindrops (one for external URL, one for X permalink) |
The tool stores data in the current working directory:
config.toml- Configuration file.x2raindrop/x_token.json- X OAuth tokens (keep secure!).x2raindrop/state.json- Sync state for idempotency (bookmarks and likes tracked separately)
- Dry Run First: Always use
--dry-runbefore syncing to preview changes - Remove from X: The
remove_from_xsetting permanently removes bookmarks or unlikes posts on X. Use with caution and consider backing up first - Token Security: The
x_token.jsonfile contains sensitive tokens. Ensure proper file permissions
IMPORTANT: X API has strict rate limits, especially on the Free Tier.
| Tier | Rate Limit | Notes |
|---|---|---|
| Free | 1 request / 15 min | Very limited - sync may take a long time |
| Basic | Higher limits | Check X Developer Portal for current limits |
API Request Breakdown:
- Fetching bookmarks: 1 request per 100 bookmarks (paginated)
- Fetching liked posts: 1 request per 100 likes (paginated)
- Listing bookmark folders: 1 request per 100 folders (when mapping folders)
- Listing bookmarks in a folder: 1 request per folder (when mapping folders)
- Deleting a bookmark / unliking a post: 1 request per item
Rate Limit Behavior: The CLI now uses the official Python XDK for X API calls. If X returns a 429 rate-limit response, the command exits with the API error from the SDK.
Recommendations for Free Tier:
- Don't use
--remove-from-x- each unbookmark/unlike is a separate request - Wait for the current rate-limit window to reset, then rerun the command
- The tool tracks synced bookmarks and likes locally, so interrupted syncs can resume
- Sync one source at a time on Free Tier if rate limits are tight
- Consider upgrading to Basic tier if you have many bookmarks or likes
The Docker image provides a convenient way to run x2raindrop-cli without installing Python dependencies locally.
# Latest version
docker pull ghcr.io/dotwee/x2raindrop-cli:latestThe container's working directory is /data. Mount your local directory there to persist configuration and state:
# Create an alias for conveniencealias x2raindrop='docker run --rm -v "$PWD":/data ghcr.io/dotwee/x2raindrop-cli'# Now use it like the native CLI
x2raindrop --version
x2raindrop config init
x2raindrop raindrop collections
x2raindrop sync --collection 12345 --dry-run
x2raindrop sync --collection 12345 --likes --likes-collection 54321 --dry-runPass credentials via environment variables instead of a config file:
docker run --rm \
-e X_ACCESS_TOKEN="your_token" \
-e RAINDROP_TOKEN="your_raindrop_token" \
-e SYNC_BOOKMARKS__COLLECTION_ID="12345" \
-e SYNC_LIKES__ENABLED="true" \
-e SYNC_LIKES__COLLECTION_ID="54321" \
-v "$PWD":/data \
ghcr.io/dotwee/x2raindrop-cli syncThe interactive OAuth 2.0 PKCE flow (x2raindrop x login) requires a browser, which doesn't work well inside a container. You have two options:
Option 1: Use a Direct Access Token (Recommended for Docker)
Set X_ACCESS_TOKEN in your config or as an environment variable. No browser login required.
Option 2: Authenticate on Host, Then Use in Docker
- Install the CLI locally and run
x2raindrop x loginon your host machine - This creates
.x2raindrop/x_token.jsonin your current directory - Mount that directory when running Docker:
docker run --rm -v "$PWD":/data ghcr.io/dotwee/x2raindrop-cli sync --collection 12345The container will use the token file from your mounted directory.
The container stores data in /data (the working directory):
| File | Purpose |
|---|---|
config.toml | Configuration file |
.x2raindrop/x_token.json | X OAuth tokens |
.x2raindrop/state.json | Sync state for bookmarks and likes |
Always mount a volume to /data to persist this data between runs.
uv sync --group devuv run pytestuv run pytest --cov=x2raindrop_cli --cov-report=htmluv run ruff check src tests
uv run ruff format src testsuv run ty check srcThe package version is derived from Git tags via
uv-dynamic-versioning.
Do not set version in pyproject.toml.
uvx uv-dynamic-versioning
uv run x2raindrop --versionA tagged commit such as v1.2.3 builds as 1.2.3. Commits after a tag get a
PEP 440 development version (for example 1.2.3.post1.dev0+abc1234).
Run x2raindrop x login to authenticate.
The tool automatically refreshes tokens. If issues persist, run x2raindrop x logout then x2raindrop x login.
Run x2raindrop raindrop collections to list available collections and their IDs.
Wait for the current rate-limit window to reset (often 15 minutes on Free Tier), then rerun. Fetching bookmarks/likes and each unbookmark/unlike consume separate request quota.
Ensure your X app token includes like.read (and like.write if removing likes).
Re-authenticate with x2raindrop x logout then x2raindrop x login after updating
scopes in config.toml.
Copyright (c) 2026 Lukas 'dotWee' Wolfsteiner lukas@wolfsteiner.media
Licensed under the Do What The Fuck You Want To Public License. See the LICENSE file for details.
- python-raindropio - Raindrop.io API wrapper
- X Python XDK - X API documentation
Releases are driven by Git tags. Pushing a v*.*.* tag runs
.github/workflows/release.yml, which creates
the GitHub Release, publishes to PyPI, and pushes the Docker image to GHCR.
Merge the changes you want to ship into
main.Decide the next version from the latest tag (do not edit
pyproject.toml):git fetch --tags git tag --sort=-v:refname | head -n 1 uvx uv-dynamic-versioningCreate an annotated tag on
mainusing avprefix (required by the default version pattern and the release workflow):git checkout main git pull git tag -a v1.2.3 -m "v1.2.3"Push the tag:
git push origin v1.2.3
Confirm the Release & Publish workflow succeeds. It will:
- Resolve the version from the tag with
uv-dynamic-versioning - Build the sdist and wheel with
uv build - Create a GitHub Release and attach the distributions
- Publish to PyPI
- Build and push
ghcr.io/dotwee/x2raindrop-clitagged with the SemVer version andlatest
- Resolve the version from the tag with
Docker images exclude .git, so the workflow passes
UV_DYNAMIC_VERSIONING_BYPASS (the tag without the v prefix) into the image
build. Local Docker builds without that build-arg fall back to 0.0.0.