raindrop-to-ghost-sync is a serverless Google Cloud Function that syncs your Raindrop.io bookmarks to your Ghost blog. It lets you use a custom tag to automatically publish selected bookmarksβwith notes and highlightsβstraight to your blog, making it perfect for public linkrolls or curated reading lists.
β¨ For a high-level overview and design rationale, check out this post.
- Overview
- Why Use It?
- Example Workflow
- Features
- Content Formatting
- Technical Stack
- Setup Instructions
- Manual Test (Optional)
- How to Create a Custom Tag in index.js
- To Do
- License
You can publish a link to your blog the moment you find something worth sharing using the Raindrop.io browser extension. Itβs a fast, natural way to save and post what youβre reading.
This also lets you post links to a dedicated section of your blogβwithout cluttering your main posts. With Ghost.org adding ActivityPub support, youβll soon be able to automatically syndicate these posts to the fediverse and other social platformsβwhile keeping full ownership of your content and avoiding the need to repost manually.
You can use this feature to:
- Share quotes with commentary
- Build a public reading list
- Leave breadcrumbs from your research
- Start lightweight posts that are part blog, part bookmark
- Seamlessly distribute link posts to the fediverse
-
Save a page using the Raindrop extension in your browser (desktop or mobile).
-
Highlight a passage and add a note using the Raindrop extension.
- Automatic Publishing: Syncs the most recent Raindrop bookmark with a custom tag of your choice to your Ghost blog.
- Update Detection: If the bookmark was already synced, the corresponding Ghost post will be updated (not duplicated).
- Clean Formatting: Notes and highlights are wrapped in semantic HTML and stored in a Ghost HTML card block.
- Paragraphs, line breaks, and bullet lists (
-
or*
) are preserved. - Inline code (
`like this`
) and fenced code blocks (```lang) are rendered using proper HTML code tags. - Safe HTML tags like
<b>
,<i>
, and<a href="...">
are allowed and sanitized.
- Paragraphs, line breaks, and bullet lists (
- Media Embeds (YouTube & Spotify): Bookmarks linking to YouTube or Spotify automatically include an embedded player. If you've added a note to the Raindrop bookmark, your note will appear first, followed by the media player. These media links are processed even if they don't have other notes or highlights.
- Metadata Embedded: Posts include embedded metadata (like Raindrop ID and tags) for filtering or custom display logic.
- RSS Feed Friendly: Works well with Ghostβs RSS system to support custom feeds using the
links
tag.
When you add notes or highlights to a Raindrop bookmark, this function converts them into readable, structured HTML for Ghost.
Supported features:
- Paragraphs: Line breaks are preserved between paragraphs.
- Bullet Lists: Lines starting with
-
or*
are turned into<ul><li>
HTML lists. - Inline Code: Wrap text in backticks like
`code`
to render it as<code>code</code>
. - Code Blocks: Fenced code blocks using triple backticks (```) are supported and optionally language-tagged:
Entering:
```js
console.log("Hello");
```
Becomes:
console.log("Hello");
-
Safe HTML: Simple tags like
<b>
,<strong>
,<i>
,<em>
, and<a href="...">
are preserved and sanitized for safe rendering in Ghost. -
Highlight + Note Pairing: Highlights from Raindrop are rendered inside
<blockquote>
elements. Notes attached to highlights are displayed beneath them with full formatting. -
Media Embeds:
- YouTube: Links like
https://www.youtube.com/watch?v=VIDEO_ID
orhttps://youtu.be/VIDEO_ID
will generate an iframe embed:<iframe width="560" height="315" src="https://www.youtube.com/embed/VIDEO_ID" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
- Spotify: Links like
https://open.spotify.com/album/ALBUM_ID
orhttps://open.spotify.com/track/TRACK_ID
will generate an iframe embed:<!-- For albums/playlists/shows --> <iframe style="border-radius:12px" src="https://open.spotify.com/embed/album/ALBUM_ID?utm_source=generator" width="100%" height="352" frameBorder="0" allowfullscreen="" allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy"></iframe> <!-- For tracks/episodes --> <iframe style="border-radius:12px" src="https://open.spotify.com/embed/track/TRACK_ID?utm_source=generator" width="100%" height="152" frameBorder="0" allowfullscreen="" allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy"></iframe>
If a note exists on the Raindrop bookmark, it will be displayed above the embedded media player.
- YouTube: Links like
- Google Cloud Functions (Gen 2, Node.js 20)
- Google Cloud Scheduler (optional): Automates sync on a recurring schedule
- Raindrop REST API: Fetches bookmarks
- Ghost Admin API: Publishes or updates blog posts
- Node.js Libraries:
axios
,@tryghost/admin-api
,@google-cloud/functions-framework
Before you begin, make sure you have:
- A Google Cloud Platform (GCP) account
- Google Cloud CLI installed and authenticated if you want to deploy locally
- Node.js and npm installed (required to install dependencies and deploy the function via Google Cloud CLI)
- A Raindrop.io developer integration
- A test token (used as the
RAINDROP_API_KEY
environment variable) - A Raindrop Premium account if you want notes on highlights to appear in Ghost
- A Ghost blog with Admin API access
- A Ghost Admin API Key and blog URL
(Found under Ghost Admin β Settings β Integrations β Add Custom Integration)
git clone https://github.com/danielraffel/raindrop-to-ghost-sync.git
cd raindrop-to-ghost-sync
npm install
Youβll need to define the following variables when deploying:
Variable | Description |
---|---|
RAINDROP_API_KEY |
Your Raindrop test token |
GHOST_API_URL |
Your Ghost blog URL (e.g. https://yourdomain.com ) |
GHOST_ADMIN_API_KEY |
Your Ghost Admin API key (<id>:<secret> ) |
SYNC_SECRET |
A token youβll use in the Authorization header to trigger syncs securely |
gcloud functions deploy raindropToGhostSync \
--gen2 \
--runtime nodejs20 \
--trigger-http \
--region YOUR_REGION \
--entry-point raindropToGhostSync \
--set-env-vars RAINDROP_API_KEY=YOUR_RAINDROP_KEY,GHOST_API_URL=https://yourdomain.com,GHOST_ADMIN_API_KEY=YOUR_ADMIN_KEY,SYNC_SECRET=YOUR_SECRET
π Replace:
- YOUR_REGION with a region like us-central1
- RAINDROP_API_KEY with your Raindrop test token
- GHOST_API_URL with your Ghost Admin API key
- GHOST_ADMIN_API_KEY with your YOUR_ADMIN_KEY
- SYNC_SECRET with your token
When prompted:
Allow unauthenticated invocations of new function [raindropToGhostSync]? (y/N)? y
Trigger the sync manually with curl using your SYNC_SECRET:
curl -X POST https://REGION-PROJECT.cloudfunctions.net/raindropToGhostSync \
-H "Authorization: Bearer YOUR_SECRET"
To verify your Raindrop bookmarks are being tagged using YOUR_RAINDROP_API_KEY:
curl -H "Authorization: Bearer YOUR_RAINDROP_API_KEY" \
"https://api.raindrop.io/rest/v1/raindrops/0?tag=1"
If you make changes to index.js, re-deploy using YOUR_REGION:
gcloud functions deploy raindropToGhostSync \
--gen2 \
--runtime nodejs20 \
--trigger-http \
--region YOUR_REGION \
--entry-point raindropToGhostSync
β You do not need to re-set environment variables unless they change.
To run your Raindrop β Ghost sync automatically every minute, use Google Cloud Scheduler to call your function on a recurring schedule.
Cloud Scheduler needs permission to call your Cloud Function. Run:
gcloud functions add-iam-policy-binding raindropToGhostSync \
--region=us-central1 \
--member="serviceAccount:SERVICE_ACCOUNT_EMAIL" \
--role="roles/cloudfunctions.invoker"
Replace SERVICE_ACCOUNT_EMAIL with the service account Scheduler will use.
To find that email:
In most cases, the default is:
PROJECT_ID@appspot.gserviceaccount.com
You can confirm this in the IAM section of the Cloud Console or by running:
gcloud iam service-accounts list
Once the correct service account has access, create the job (update this with your region, your cloud function URI, and your SYNC_SECRET :
gcloud scheduler jobs create http raindrop-ghost-sync \
--location=us-central1 \
--schedule="* * * * *" \
--uri=https://us-central1-YOUR_PROJECT.cloudfunctions.net/raindropToGhostSync \
--http-method=POST \
--headers="Authorization=Bearer ${SYNC_SECRET}" \
--attempt-deadline=540s
π This sends your pre-defined SYNC_SECRET as a bearer token in the Authorization header. Your Cloud Function should reject requests that donβt include this.
You can test the sync manually by running:
gcloud scheduler jobs run raindrop-ghost-sync --location=us-central1
Check your function logs to confirm it was triggered successfully:
gcloud functions logs read raindropToGhostSync --region=us-central1 --limit=10
Changed something in your Raindrop bookmark and donβt want to wait for an automated update? Trigger a sync manually with curl using your SYNC_SECRET:
curl -X POST https://REGION-PROJECT.cloudfunctions.net/raindropToGhostSync \
-H "Authorization: Bearer YOUR_SECRET"
Or make it even easierβadd a shortcut to your terminal by including this in your ~/.zshrc:
alias pushlink='curl -X POST https://REGION-PROJECT.cloudfunctions.net/raindropToGhostSync -H "Authorization: Bearer YOUR_SECRET"'
Then just type pushlink
in your terminal whenever you want to instantly sync a new link.
By default, the function looks for the most recent bookmark tagged with 1. You can change this by editing the tag filter in getLatestRaindropBookmark():
params: {
tag: '1', // β change this to your preferred tag (e.g. 'publish', 'linkroll')
sort: '-created',
perpage: 10
}
You can also adjust:
- How the post content is formatted (inside formatGhostContent)
- The logic for filtering out empty bookmarks (via shouldProcessBookmark)
- Any HTML structure or metadata formatting as needed
- Add deletion support: Remove Ghost posts if the corresponding Raindrop no longer has the custom tag
- Strategy: Retrieve all links-tagged Ghost posts β extract raindrop-id from each β query Raindrop API β if tag is missing, delete the post
- Consider adding a simple database or caching layer to avoid redundant API calls
- Add support for a
.env
file
MIT License.