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 refresh
refresh_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 token
export 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 credentials
export 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 sync
export 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 clients
redirect_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 = true
collection_id = 12345
collection_title = "" # Optional: look up by title when collection_id is unset
tags = ["x-bookmark", "auto-synced"]
remove_from_x = false
skip_existing_links = true
link_mode = "permalink" # permalink, first_external_url, or both
both_behavior = "one_external_plus_note" # one_external_plus_note or two_raindrops
map_folders_to_subcollections = false # Map X bookmark folders to Raindrop child collections
[sync.likes]
enabled = false
collection_id = 54321
collection_title = "" # Optional: look up by title when collection_id is unset
tags = ["x-like", "auto-synced"]
remove_from_x = false # Unlike posts on X after syncing
skip_existing_links = true
link_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 convenience
alias 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-versioning -
Create 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.