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 reserved → delivered.
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:
- Navigate to Queue page
- View counts by status (pending, reserved, delivered, error)
- See post details and error reasons
- 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:
- Calls
GET /api/v1/curated/nextperiodically - Reblogs the post to Mastodon if it passes its own filters
- Calls
POST /api/v1/curated/{post_id}/ackon success - Calls
POST /api/v1/curated/{post_id}/nackon transient errors - Calls
POST /api/v1/curated/{post_id}/erroron 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¶
- API Overview - Other API endpoints
- Reblog Controls API - Export filters
- Queue Preview UI - Monitor the queue