Micropub Implementation Guide¶
Overview¶
django-indieweb now provides a fully functional Micropub endpoint that can create content in your Django application. The implementation uses a pluggable content handler system that allows you to integrate Micropub with any Django content model.
Quick Start¶
1. Basic Setup¶
The Micropub endpoint is available at /indieweb/micropub/ by
default. It requires authentication via IndieAuth tokens. Scopes are
enforced per operation: POST entry create requires create (the
legacy alias post is still accepted), POST action=update requires
update, POST action=delete requires delete,
POST action=undelete requires undelete, and GET ?q=source
requires update. See API Reference for the full mapping.
2. Using the Default In-Memory Handler¶
For testing and development, django-indieweb includes an in-memory content handler that stores posts in memory:
# This is the default if no handler is configured
# Posts are stored in memory and lost on restart
3. Creating a Custom Content Handler¶
To integrate Micropub with your Django models, create a custom content handler:
# myapp/micropub_handler.py
from indieweb.handlers import MicropubContentHandler, MicropubEntry
from myapp.models import BlogPost
class BlogPostMicropubHandler(MicropubContentHandler):
def create_entry(self, properties, user):
# Extract properties
content = properties.get('content', [''])[0]
name = properties.get('name', [''])[0]
categories = properties.get('category', [])
# Create your model instance
post = BlogPost.objects.create(
author=user,
title=name or 'Untitled',
content=content,
status='published'
)
# Add categories/tags
for category in categories:
post.tags.add(category)
# Return MicropubEntry with the URL
return MicropubEntry(
url=post.get_absolute_url(),
properties=properties
)
def get_entry(self, url, user):
# Parse URL to get post
try:
post = BlogPost.objects.get(
slug=url.split('/')[-2], # Adjust based on your URL structure
author=user
)
return MicropubEntry(
url=post.get_absolute_url(),
properties={
'name': [post.title],
'content': [post.content],
'published': [post.created.isoformat()],
}
)
except BlogPost.DoesNotExist:
return None
def update_entry(self, url, updates, user):
# Implement update logic
post = self._get_post_from_url(url, user)
if 'replace' in updates:
for key, values in updates['replace'].items():
if key == 'content':
post.content = values[0]
elif key == 'name':
post.title = values[0]
post.save()
return self.get_entry(url, user)
def delete_entry(self, url, user):
post = self._get_post_from_url(url, user)
post.delete()
def undelete_entry(self, url, user):
# Implement if you support soft deletes
raise NotImplementedError("Undelete not supported")
4. Configure Your Handler¶
In your Django settings:
# settings.py
INDIEWEB_MICROPUB_HANDLER = 'myapp.micropub_handler.BlogPostMicropubHandler'
Supported Features¶
Content Types¶
The Micropub endpoint supports both form-encoded and JSON requests:
Form-encoded:
curl -X POST https://example.com/indieweb/micropub/ \
-H "Authorization: Bearer YOUR_TOKEN" \
-d "h=entry" \
-d "content=Hello World!" \
-d "category=indieweb,micropub"
JSON:
curl -X POST https://example.com/indieweb/micropub/ \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": ["h-entry"],
"properties": {
"content": ["Hello JSON!"],
"category": ["indieweb", "json"]
}
}'
Supported Properties¶
Common h-entry properties are supported: - content - The main
content - name - Title/name of the entry - category -
Tags/categories (comma-separated or array) - location - Geographic
location (geo URI format) - in-reply-to - URL this post is replying
to - photo - Photo URL(s) - published - Publication date
Update, Delete, Undelete¶
The endpoint also supports the Micropub update, delete, and undelete actions.
Updates are JSON-only (per Micropub §3.7); delete and undelete accept either
form-encoded or JSON bodies. Update bodies must contain at least one of
replace, add, or delete, and the values inside each operation
must be arrays (per Micropub §3.4) — empty bodies and scalar operation
values are rejected with 400 invalid_request.
Update and undelete return 204 No Content on success, or 201 Created
with a Location header when the configured handler relocates the entry.
Delete always returns 204 No Content (the handler interface does not
return an entry on delete, so a relocation response is not possible). All
three return 400 invalid_request when the entry is unknown to the
handler or url is missing, and 500 when the handler raises an
unexpected exception.
Update (replace, JSON):
curl -X POST https://example.com/indieweb/micropub/ \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"action": "update",
"url": "https://example.com/posts/123/",
"replace": {"content": ["Updated content"]}
}'
Update (add and delete combined, JSON):
curl -X POST https://example.com/indieweb/micropub/ \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"action": "update",
"url": "https://example.com/posts/123/",
"add": {"category": ["new-tag"]},
"delete": ["draft"]
}'
Delete (form-encoded):
curl -X POST https://example.com/indieweb/micropub/ \
-H "Authorization: Bearer YOUR_TOKEN" \
-d "action=delete" \
-d "url=https://example.com/posts/123/"
Undelete (form-encoded):
curl -X POST https://example.com/indieweb/micropub/ \
-H "Authorization: Bearer YOUR_TOKEN" \
-d "action=undelete" \
-d "url=https://example.com/posts/123/"
Query Endpoints¶
Configuration:
curl https://example.com/indieweb/micropub/?q=config \
-H "Authorization: Bearer YOUR_TOKEN"
Returns supported post types and features.
Syndication Targets:
curl https://example.com/indieweb/micropub/?q=syndicate-to \
-H "Authorization: Bearer YOUR_TOKEN"
Source Content:
curl "https://example.com/indieweb/micropub/?q=source&url=https://example.com/posts/123/" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Accept: application/json"
Returns {"type": ["h-entry"], "properties": {...}} for the entry returned
by your configured handler’s get_entry(url, user) method.
Filtered Source Content:
curl "https://example.com/indieweb/micropub/?q=source&url=https://example.com/posts/123/&properties[]=content&properties[]=name" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Accept: application/json"
Returns only the requested existing properties as {"properties": {...}}.
Missing requested property names are omitted.
Testing Your Implementation¶
Get an access token via IndieAuth with the
createscope (or the legacy aliaspost); useupdate/delete/undeletefor those actions, andupdateforGET ?q=sourceCreate a test post:
curl -X POST http://localhost:8000/indieweb/micropub/ \
-H "Authorization: Bearer YOUR_TOKEN" \
-d "h=entry" \
-d "content=Test post from Micropub!"
Check the response:
Status: 201 Created
Location header contains the URL of the created post
Advanced Integration¶
Handling Different Post Types¶
def create_entry(self, properties, user):
# Determine post type
post_type = 'note' # default
if properties.get('name'):
post_type = 'article'
elif properties.get('photo'):
post_type = 'photo'
elif properties.get('in-reply-to'):
post_type = 'reply'
# Create appropriate model based on type
if post_type == 'article':
return self._create_article(properties, user)
elif post_type == 'photo':
return self._create_photo_post(properties, user)
else:
return self._create_note(properties, user)
Error Handling¶
The Micropub endpoint returns the following HTTP status codes:
201 Created- Success on entry create, and on update/undelete actions whose handler returns a relocated entry URL; aLocationheader points at the new or canonical URL. Delete cannot relocate.204 No Content- Success on update/delete/undelete actions when the entry’s URL did not change (delete always returns this on success)400 Bad Request- Invalid request data: the configured handler raised on entry creation; an action request had an unknownurl(handler raisedValueError), missingurl, malformed JSON, a non-object JSON body, or — foraction=update— a non-JSON body, an empty update payload (noreplace/add/delete), a non-array operation value, or an otherwise spec-non-conformant operation shape; or aGET ?q=sourcerequest had a missingurlor aurlunknown to the handler. Action and source-query client failures use the plain-text bodyinvalid_request.401 Unauthorized- Missing, expired, or invalid access token, or the token’s owner is inactive403 Forbidden- bodyauthorization errorwhen the token lacks the scope required for the requested operation; bodyinvalid_clientwhen the token’sclient_idis rejected by the configuredINDIEWEB_CLIENT_ID_VALIDATOR500 Internal Server Error- The configured handler raised an unexpected exception (e.g. database failure) duringupdate/delete/undeleteorGET ?q=source; the exception is logged vialogger.exceptionso the stack trace stays in the server log rather than the response body
See API Reference for the full per-operation scope mapping and the complete error-response listing across all IndieWeb endpoints.
Security Considerations¶
Always validate user permissions in your handler
Sanitize content before storing
Validate URLs for properties like photo and in-reply-to
Rate limiting is recommended for production use
Example: Integration with django-cast¶
# cast_micropub.py
from indieweb.handlers import MicropubContentHandler, MicropubEntry
from cast.models import Post
class CastMicropubHandler(MicropubContentHandler):
def create_entry(self, properties, user):
from cast.models import Blog
# Get user's blog
blog = Blog.objects.get(user=user)
# Create post
post = Post.objects.create(
blog=blog,
author=user,
title=properties.get('name', [''])[0],
content=properties.get('content', [''])[0],
visible=True,
published=True
)
# Handle categories
categories = properties.get('category', [])
for cat_name in categories:
category, _ = Category.objects.get_or_create(
blog=blog,
name=cat_name
)
post.categories.add(category)
return MicropubEntry(
url=post.get_absolute_url(),
properties=properties
)
Then in settings:
INDIEWEB_MICROPUB_HANDLER = 'myproject.cast_micropub.CastMicropubHandler'
Next Steps¶
Add media endpoint support for file uploads
Implement WebSub for real-time updates
Add support for more post types (events, RSVPs, etc.)