API
All post operations use the freek.dev Blog Posts API. No browser automation needed.
Base URL
https://freek.dev/api
Authentication
Bearer token in the Authorization header.
Token location: .secrets/blog-freek-dev.md
Authorization: Bearer <token>
Endpoints
List posts — GET /api/posts
Query parameters (all optional):
published(0 or 1): filter by published statusoriginal_content(0 or 1): filter by original contenttag(string): filter by tag namesearch(string): search by title
Returns paginated results.
Get a post — GET /api/posts/{id}
Create a post — POST /api/posts
Body (JSON):
title(string, required)text(string, required, markdown)publish_date(ISO 8601 date, nullable)published(boolean, default false)original_content(boolean, default false)external_url(URL string, nullable)tags(array of strings)send_automated_tweet(boolean, default false)author_twitter_handle(string, nullable)series_slug(string, nullable)
Returns 201 with the created post.
Update a post — PUT /api/posts/{id}
Same fields as create, all optional. Only send fields you want to change.
Delete a post — DELETE /api/posts/{id}
Returns 204 No Content.
Response Format
All responses are wrapped in a data key:
{
"data": {
"id": 1,
"title": "...",
"slug": "...",
"text": "...",
"html": "...",
"publish_date": "2026-03-02T14:00:00+00:00",
"published": true,
"original_content": false,
"external_url": null,
"series_slug": null,
"author_twitter_handle": null,
"send_automated_tweet": false,
"tags": ["laravel", "php"],
"url": "https://freek.dev/1-post-slug",
"preview_url": "https://freek.dev/1-post-slug?preview_secret=abc123",
"created_at": "2026-03-02T12:00:00+00:00",
"updated_at": "2026-03-02T12:00:00+00:00"
}
}
API Usage Examples
Create a link post:
curl -X POST https://freek.dev/api/posts \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Article title here",
"text": "Short summary of the linked content.",
"external_url": "https://example.com/article",
"send_automated_tweet": true
}'
Schedule a post (set publish_date + published):
curl -X PUT https://freek.dev/api/posts/3040 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"publish_date": "2026-03-03T13:30:00+00:00",
"published": true
}'
Update post text:
curl -X PUT https://freek.dev/api/posts/3040 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"text": "Updated markdown content here."}'
Creating a Link Post
90% of posts are link posts (external content with a summary).
Steps
- Create the post via
POST /api/postswith:title: Use the original article title or a clear varianttext: 2 sentences max summarizing the linked content. No links in the text.external_url: The full URL being linkedoriginal_content: false (it's a link post)send_automated_tweet: true
- Before scheduling, ALWAYS inspect the existing unpublished queue (
GET /api/posts?published=0) and look atpublish_datevalues. Do not assume the next slot is today. - Schedule sequentially after the last already-scheduled post, usually one post per day, around 14:30 CET/CEST unless the existing queue clearly follows a different pattern.
- To schedule, set
publish_dateto the chosen future time. Do NOT setpublished: true(the blog auto-publishes when the date arrives). - Always confirm by displaying the created title, full summary text, and preview URL back to the user (so Freek can review what was written)
Link Post Text Guidelines
- Keep it to 2 sentences max
- Summarize what's interesting about the linked content
- No links in the summary text (the external URL field handles linking)
- No code blocks unless essential
- Write in third person ("They've released..." not "I found...")
- Never use em dashes in summaries or titles. Use commas, periods, or "and" instead.
Creating an Original Content Post
For posts written by Freek (package announcements, tutorials, opinions).
Steps
- Create the post via
POST /api/postswith:title: The post titletext: Full markdown content (see write-freek-dev-blogpost skill for writing style)original_content: truesend_automated_tweet: true (usually)tags: Relevant tags as array of strings
- Share the
preview_urlfrom the response for review - Schedule when Freek approves
Creating a YouTube Video Post
When Freek shares a YouTube link for the blog.
Steps
- Get the video ID from the YouTube URL (e.g.
dQw4w9WgXcQfromyoutube.com/watch?v=dQw4w9WgXcQ) - Get the video title via oembed API:
youtube.com/oembed?url=VIDEO_URL&format=json - Create the post via
POST /api/postswith:title: Use the video titletext: Summary text followed by a blank line and the YouTube iframe embed:Summary text here. <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>external_url: null (the embed IS the content)original_content: falseauthor_twitter_handle: Set if knownsend_automated_tweet: true
- Always confirm by displaying the title, full summary text, and preview URL
YouTube Embed Notes
- If the original URL has a timestamp (
t=24orstart=24), add?start=24to the embed src - Use
width="560" height="315"as standard dimensions
Creating a Tweet Embed Post
When Freek wants to embed a Twitter/X thread on the blog.
Steps
- Gather the tweet IDs from the thread
- Create the post via
POST /api/postswith:title: Descriptive title (e.g. "A Twitter thread about laravel-permission v7")text: Minimal blockquotes withdata-conversation="none":<blockquote class="twitter-tweet" data-conversation="none"> <a href="https://twitter.com/USERNAME/status/TWEET_ID"></a> </blockquote> <blockquote class="twitter-tweet" data-conversation="none"> <a href="https://twitter.com/USERNAME/status/TWEET_ID_2"></a> </blockquote> <script async src="https://platform.twitter.com/widgets.js"></script>external_url: nulloriginal_content: true (it's Freek's own content)author_twitter_handle: Tweet author (e.g.freekmurze)send_automated_tweet: false (the tweets already exist)
- Confirm with the preview URL so Freek can verify the embeds render
Tweet Embed Notes
data-conversation="none"prevents showing the parent tweet above each reply- Use ONE
<script>tag at the end, not per-blockquote - Twitter's widget.js renders the blockquotes client-side into rich embeds
Scheduling Convention
Standard scheduling: sequential daily at ~14:30 CET (13:30 UTC in winter, 12:30 UTC in summer).
⚠️ CRITICAL: NEVER set published: true for scheduled posts. Setting published: true publishes IMMEDIATELY and triggers the automated tweet. The blog auto-publishes posts when their publish_date arrives.
To schedule a new post: Create with published: false (or omit, it defaults to false) and set publish_date to the desired future date.
To schedule an existing post: Update with published: false and publish_date:
PUT /api/posts/{id}
{"published": false, "publish_date": "2026-03-03T13:30:00+00:00"}
Example Link Post
Title: SQL performance improvements: automatic detection & regression testing
Text:
The final part of Oh Dear's series on SQL performance. Mattias introduces phpunit-query-count-assertions, a package that catches N+1 queries, duplicate queries, and missing indexes in your test suite.
External url: https://ohdear.app/news-and-updates/...