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

  1. Get an access token via IndieAuth with the create scope (or the legacy alias post); use update/delete/undelete for those actions, and update for GET ?q=source

  2. Create 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!"
  1. 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)

Adding Syndication Support

def get_config(self, user):
    config = super().get_config(user)

    # Add syndication targets
    config['syndicate-to'] = [
        {
            'uid': 'https://twitter.com/username',
            'name': 'Twitter'
        },
        {
            'uid': 'https://mastodon.social/@username',
            'name': 'Mastodon'
        }
    ]

    return config

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; a Location header 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 unknown url (handler raised ValueError), missing url, malformed JSON, a non-object JSON body, or — for action=update — a non-JSON body, an empty update payload (no replace/add/delete), a non-array operation value, or an otherwise spec-non-conformant operation shape; or a GET ?q=source request had a missing url or a url unknown to the handler. Action and source-query client failures use the plain-text body invalid_request.

  • 401 Unauthorized - Missing, expired, or invalid access token, or the token’s owner is inactive

  • 403 Forbidden - body authorization error when the token lacks the scope required for the requested operation; body invalid_client when the token’s client_id is rejected by the configured INDIEWEB_CLIENT_ID_VALIDATOR

  • 500 Internal Server Error - The configured handler raised an unexpected exception (e.g. database failure) during update/delete/undelete or GET ?q=source; the exception is logged via logger.exception so 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

  1. Always validate user permissions in your handler

  2. Sanitize content before storing

  3. Validate URLs for properties like photo and in-reply-to

  4. 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.)