Skip to content

Curated Queue API

The Curated Queue API provides a reliable, tool-agnostic interface for consuming approved posts from FenLiu's curation queue. It uses an ack/nack/error pattern to ensure reliable delivery.

Intended Consumer: Zhongli (a Mastodon reblog bot) is the primary consumer of this API, but any external tool can integrate with it.

Overview

FenLiu maintains an ordered queue of posts that have passed the review and approval workflow. External tools like Zhongli request posts from this queue when ready to process them (e.g., reblog to Mastodon). FenLiu hands back one post at a time, and the consumer drives the pacing entirely—FenLiu does not push posts; it only serves them on request.

Queue States

Posts progress through these states:

pending → reserved → delivered (ack)
        ↓           → pending (nack/timeout)
        → error (error)

Pending: Approved and waiting for consumption Reserved: Currently held by a consumer (in-flight) Delivered: Successfully processed (ack'd) Error: Permanently failed (marked by consumer)

Authentication

Curated queue endpoints require API key authentication:

X-API-Key: your-api-key

Include this header in all requests:

curl -H "X-API-Key: your-api-key" \
  http://localhost:8000/api/v1/curated/next

Endpoints

GET /api/v1/curated/next

Reserve and return the next eligible post for consumption.

Note

This endpoint automatically sweeps for timed-out reservations (default: 5 minutes) and returns them to pending status. This prevents posts from being lost if a consumer crashes.

Query Parameters

Parameter Type Default Description
random boolean false When true, 1, or yes: select a random eligible post instead of the oldest one. If the chosen author has more than one pending post, their oldest post is returned to avoid back-to-back posts from the same author.
include_topical boolean false When true, prefer topical posts if today matches a configured topical day. Falls back to the normal queue if no topical posts are available.
topical_only boolean false When true (requires include_topical=true), return 204 instead of falling back to the normal queue when no topical post is available.

Request

# Default: oldest eligible post first (FIFO)
curl -H "X-API-Key: your-api-key" \
  http://localhost:8000/api/v1/curated/next

# Random selection
curl -H "X-API-Key: your-api-key" \
  "http://localhost:8000/api/v1/curated/next?random=true"

# Prefer topical posts on topical days, fall back to normal queue
curl -H "X-API-Key: your-api-key" \
  "http://localhost:8000/api/v1/curated/next?include_topical=true"

# Only return topical posts; 204 if none available
curl -H "X-API-Key: your-api-key" \
  "http://localhost:8000/api/v1/curated/next?include_topical=true&topical_only=true"

Response (200 OK)

{
  "fenliu_post_id": 42,
  "post_uri": "https://example.com/users/user/statuses/123",
  "post_url": "https://example.com/@user/123",
  "author_account": "@user@example.com",
  "content_snippet": "Check out this amazing post...",
  "hashtags": ["python", "programming"],
  "stream": "python",
  "has_attachments": true,
  "spam_score": 25,
  "post_created_at": "2026-03-02T10:30:00Z",
  "approved_at": "2026-03-02T11:00:00Z",
  "is_topical": false
}

Response (204 No Content)

When the queue is empty, returns 204 with no body. The consumer should back off and retry later.

Fields

Field Type Description
fenliu_post_id integer Internal FenLiu post ID (use in ack/nack/error)
post_uri string ActivityPub URI—the canonical identifier for the post
post_url string Human-readable web URL
author_account string Fediverse account (e.g., @user@instance.social)
content_snippet string First 280 characters of plain-text content
hashtags array List of hashtags in the post
stream string Source hashtag stream name
has_attachments boolean Whether post contains media
spam_score integer 0-100 confidence score
post_created_at string ISO 8601 timestamp (post creation)
approved_at string ISO 8601 timestamp (approval time)
is_topical boolean true when the post was selected because it matched a topical day rule

Topical Content

FenLiu supports preferential delivery of topical posts — posts whose hashtag is configured as topical for a specific day (e.g., #caturday on Saturdays). Topical rules are managed via Topical Tags.

Behaviour matrix

include_topical topical_only Today is a topical day Topical posts available Result
false Normal queue (FIFO or random)
true false Yes Yes Topical post returned; is_topical: true
true false Yes No Normal post returned; is_topical: false
true false No Normal post returned; is_topical: false
true true Yes Yes Topical post returned; is_topical: true
true true Yes No 204 No Content
true true No 204 No Content

A "topical day" is any day that matches at least one configured topical rule. When today is not a topical day, topical_only=true will always produce 204.


POST /api/v1/curated/{post_id}/ack

Confirm successful processing.

The consumer calls this after successfully processing a post (e.g., after Zhongli reblogs it). Transitions the post from reserveddelivered.

Request

curl -X POST \
  -H "X-API-Key: your-api-key" \
  http://localhost:8000/api/v1/curated/42/ack

Response (204 No Content)

Error Responses

{ "detail": "Post 42 not found" }  // 404
{ "detail": "Post is in state 'pending', expected 'reserved'" }  // 409

POST /api/v1/curated/{post_id}/nack

Return post to queue (transient failure).

The consumer calls this on transient failures (network error, rate limit, temporary service outage). The post is returned to pending and will be re-presented on the next /next call.

Request

curl -X POST \
  -H "X-API-Key: your-api-key" \
  http://localhost:8000/api/v1/curated/42/nack

Response (204 No Content)

Error Responses

{ "detail": "Post 42 not found" }  // 404
{ "detail": "Post is in state 'pending', expected 'reserved'" }  // 409

When to Use Nack

  • Network connectivity issues
  • Rate limit hit (usually temporary)
  • Service temporarily unavailable (5xx error)
  • Consumer restart before ack was sent

POST /api/v1/curated/{post_id}/error

Mark post as permanently failed.

The consumer calls this on permanent failures specific to the post. The post is moved to error terminal state and never re-queued automatically. The failure reason is recorded for operator investigation via the Queue Preview UI.

Request

curl -X POST \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{"reason":"Post was deleted on remote instance"}' \
  http://localhost:8000/api/v1/curated/42/error

Request Body

{
  "reason": "Human-readable error reason"
}

Response (204 No Content)

Error Responses

{ "detail": "Post 42 not found" }  // 404
{ "detail": "Post is in state 'pending', expected 'reserved'" }  // 409
{ "detail": "Invalid JSON body" }  // 400
{ "detail": "Validation error..." }  // 422

When to Use Error

  • Post deleted on remote instance
  • Author account deleted/suspended
  • Instance unreachable after multiple retries
  • Post violates instance policy

POST /api/v1/curated/{post_id}/requeue

Manually re-queue an errored post.

Operator action via the Queue Preview UI. Returns an errored post to pending if the underlying issue has been resolved (e.g., a temporary outage has recovered).

Request

curl -X POST \
  -H "X-API-Key: your-api-key" \
  http://localhost:8000/api/v1/curated/42/requeue

Response (204 No Content)

Error Responses

{ "detail": "Post 42 not found" }  // 404
{ "detail": "Post is in state 'pending', expected 'error'" }  // 409

POST /api/v1/curated/cleanup

Delete delivered posts older than a configurable retention period. The count of deleted posts is recorded in QueueStats before deletion so all-time statistics remain accurate. Runs automatically once a day via the scheduler; this endpoint allows on-demand execution.

Request

# Default: delete delivered posts older than 7 days
curl -X POST \
  -H "X-API-Key: your-api-key" \
  "http://localhost:8000/api/v1/curated/cleanup"

# Custom retention: delete posts older than 14 days
curl -X POST \
  -H "X-API-Key: your-api-key" \
  "http://localhost:8000/api/v1/curated/cleanup?retention_days=14"

Query Parameters

Parameter Type Default Description
retention_days integer 7 Delete delivered posts older than this many days

Response (200 OK)

{ "deleted": 42 }

Error Responses

{ "detail": "retention_days must be >= 0" }  // 400
{ "detail": "retention_days must be an integer" }  // 400

POST /api/v1/curated/trim-pending

Trim excess pending posts to maintain a healthy queue buffer. Keeps the pending queue at most twice the recent daily consumption rate. Deletion uses weighted random selection — older posts, posts with fewer likes, and posts from prolific authors are more likely to be removed.

Request

# Default: compute consumption rate over last 3 days
curl -X POST \
  -H "X-API-Key: your-api-key" \
  "http://localhost:8000/api/v1/curated/trim-pending"

# Custom lookback window
curl -X POST \
  -H "X-API-Key: your-api-key" \
  "http://localhost:8000/api/v1/curated/trim-pending?lookback_days=7"

Query Parameters

Parameter Type Default Description
lookback_days integer 3 Days of delivery history to compute consumption rate

Response (200 OK)

{ "deleted": 15 }

Returns 0 if the pending count is already within the target buffer, or if there is no delivery history yet.

Error Responses

{ "detail": "lookback_days must be >= 1" }  // 400
{ "detail": "lookback_days must be an integer" }  // 400

Integration Guide

Step 1: Request Next Post

import requests
import time

api_key = "your-api-key"
headers = {"X-API-Key": api_key}

response = requests.get(
    "http://localhost:8000/api/v1/curated/next",
    headers=headers
)

if response.status_code == 204:
    # Queue is empty, back off and retry later
    time.sleep(300)  # Wait 5 minutes
    continue

post = response.json()
post_id = post["fenliu_post_id"]

Step 2: Process the Post

try:
    # Your processing logic here
    # Example: Zhongli would reblog the post to Mastodon
    mastodon.status_reblog(post["post_uri"])

    # Success! Acknowledge the post
    requests.post(
        f"http://localhost:8000/api/v1/curated/{post_id}/ack",
        headers=headers
    )
except RateLimitError:
    # Transient error—return to queue
    requests.post(
        f"http://localhost:8000/api/v1/curated/{post_id}/nack",
        headers=headers
    )
except PostDeletedError:
    # Permanent error—report it
    requests.post(
        f"http://localhost:8000/api/v1/curated/{post_id}/error",
        headers=headers,
        json={"reason": "Post was deleted"}
    )

Step 3: Monitor the Queue

Use the Queue Preview UI in FenLiu:

  1. Navigate to Queue page
  2. View counts by status (pending, reserved, delivered, error)
  3. See post details and error reasons
  4. Manually re-queue posts if needed

Reserved Post Timeout

Posts reserved for more than 5 minutes (configurable via RESERVE_TIMEOUT_SECONDS in the environment) are automatically returned to pending. This prevents indefinite loss if a consumer crashes without sending ack/nack/error.

To configure the timeout:

RESERVE_TIMEOUT_SECONDS=300  # 5 minutes

Reliability Guarantees

At-least-once delivery: With ack/nack/error, each post is either: - Successfully processed (ack'd) - Explicitly failed (error'd) - Returned to queue (nack'd or timeout)

Consumers should be designed to handle duplicate processing (idempotent operations) in case of FenLiu crashes between ack and database commit.


Example: Zhongli Integration

Zhongli is a Mastodon reblog bot that consumes the Curated Queue API. It:

  1. Calls GET /api/v1/curated/next periodically
  2. Reblogs the post to Mastodon if it passes its own filters
  3. Calls POST /api/v1/curated/{post_id}/ack on success
  4. Calls POST /api/v1/curated/{post_id}/nack on transient errors
  5. Calls POST /api/v1/curated/{post_id}/error on permanent failures

This separation of concerns allows FenLiu to focus on curation (filtering, approval, quality) while Zhongli handles distribution (reblogging, rate limiting, retries).


Next Steps