API Errors, Pagination & Rate Limiting
Practical reference for handling API errors, paginating through results, and staying within rate limits. All values below are verified against the production source code as of 2026-07-12.
Base URL:
https://api.owning.pro
Error Codes
Error Response Format
All errors follow a consistent JSON shape:
{
"error": {
"code": "not_found",
"message": "Listing not found",
"details": {}
}
}
| Field | Type | Always present | Description |
|---|---|---|---|
error.code |
string | ✅ | Machine-readable error code (snake_case) |
error.message |
string | ✅ | Human-readable error message |
error.details |
object | ❌ | Additional context (e.g. Zod validation field errors) |
HTTP Status Code Reference
| HTTP Status | Error Code | Description | Common Causes |
|---|---|---|---|
| 400 | bad_request |
Invalid request body or query parameters | Malformed JSON, missing required fields, invalid category ID, unrecognized enum value |
| 400 | validation_error |
Zod schema validation failed | Field constraint violations (min/max length, type mismatch, invalid enum). details contains formErrors and fieldErrors |
| 400 | moderation_rejected |
Listing rejected by automated moderation | Title or description contains banned words |
| 401 | unauthorized |
Missing or invalid authentication | No Authorization header, expired JWT, revoked API key, malformed token |
| 403 | forbidden |
Authenticated but not authorized | Attempting to update/delete a listing you don't own, accessing admin endpoints without admin role |
| 404 | not_found |
Resource does not exist | Invalid listing ID/slug, category ID not found, API key ID not found |
| 409 | conflict |
Request conflicts with current state | Duplicate email on register, already flagged a listing, email already in use |
| 409 | duplicate_listing |
Same seller already has an identical active listing | Creating a listing with the same title+price+category as an existing active listing |
| 429 | rate_limited |
Rate limit exceeded | See Rate Limiting below. Response includes Retry-After header |
| 500 | internal_error |
Unexpected server error | Unhandled exception. Message is masked in production ("Internal server error") |
Validation Error Details
When a Zod validation fails (HTTP 400, code validation_error), the details field contains the flattened Zod error:
{
"error": {
"code": "validation_error",
"message": "Request validation failed",
"details": {
"formErrors": [],
"fieldErrors": {
"title": ["String must contain at least 5 character(s)"],
"price": ["Required"]
}
}
}
}
| Field | Type | Description |
|---|---|---|
details.formErrors |
string[] | Errors not tied to a specific field |
details.fieldErrors |
object | Map of field name → array of error messages |
Examples — Triggering Common Errors
400 — Invalid query parameter:
curl "https://api.owning.pro/api/listings?condition=invalid"
{
"error": {
"code": "validation_error",
"message": "Invalid query parameters",
"details": {
"formErrors": [],
"fieldErrors": {
"condition": ["Invalid enum value(s). Expected 'new' | 'like_new' | 'good' | 'fair' | 'poor' | 'refurbished', received 'invalid'"]
}
}
}
}
400 — Invalid category:
curl "https://api.owning.pro/api/listings?category=invalidcategory"
{
"error": {
"code": "bad_request",
"message": "Invalid category: \"invalidcategory\"",
"details": {
"valid_categories": [
"electronics", "computers", "laptops", "phones",
"audio", "cameras", "gaming", "wearables",
"home-garden", "furniture", "appliances",
"garden", "tools", "home-decor",
"fashion", "women", "men", "shoes",
"bags", "accessories", "jewelry",
"boats", "sailboats", "motorboats",
"yachts", "dinghies", "kayaks",
"engines", "trailers",
"other"
]
}
}
}
Note: Category validation was added in R17 (commit44bc526). Previously, invalid categories returned 200 with empty results. Thevalid_categoriesarray lists all accepted category IDs.
401 — Missing authentication:
curl -X POST "https://api.owning.pro/api/listings" \
-H "Content-Type: application/json" \
-d '{"title":"Test","description":"..."}'
{
"error": {
"code": "unauthorized",
"message": "Authentication required"
}
}
404 — Listing not found:
curl "https://api.owning.pro/api/listings/lst_nonexistent"
{
"error": {
"code": "not_found",
"message": "Listing not found"
}
}
429 — Rate limit exceeded:
# Burst 201 requests in under a minute from the same IP
for i in $(seq 1 201); do curl -s "https://api.owning.pro/api/listings" > /dev/null; done
{
"error": {
"code": "rate_limited",
"message": "Rate limit exceeded for read endpoints. Limit: 200/min. Try again in 45s."
}
}
Response headers on 429:
HTTP/1.1 429 Too Many Requests
Retry-After: 45
X-RateLimit-Limit: 200
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 45
Error Handling Best Practices
- Always check
error.code, not just the HTTP status. The code is stable and machine-readable — safe for switch/case logic. - Handle
validation_errorspecially — parsedetails.fieldErrorsto show per-field feedback to users. - Respect
Retry-Afteron 429 responses — wait the indicated seconds before retrying. - Don't expose 500 error messages to end users — they may contain internal details in non-production environments.
- Use exponential backoff for retry logic on 429 and 500 errors.
Pagination Guide
Overview
All collection endpoints (listings, reviews, messages, user listings, etc.) use the same pagination format: page-based with page and limit query parameters.
Query Parameters
| Parameter | Type | Default | Constraints | Description |
|---|---|---|---|---|
page |
integer | 1 |
min: 1 | Page number (1-based) |
limit |
integer | 20 |
min: 1, max: 100 | Items per page. Values above 100 are clamped to 100. |
Response Shape
Every paginated endpoint returns:
{
"results": [ ... ],
"pagination": {
"page": 1,
"limit": 20,
"total": 712,
"pages": 36
}
}
| Field | Type | Description |
|---|---|---|
results |
array | The items for the current page |
pagination.page |
integer | Current page number |
pagination.limit |
integer | Items per page (after clamping) |
pagination.total |
integer | Total number of matching items across all pages |
pagination.pages |
integer | Total number of pages (Math.ceil(total / limit)) |
Paginated Endpoints
| Endpoint | Default limit | Max limit |
|---|---|---|
GET /api/listings |
20 | 100 |
GET /api/listings/{id}/reviews |
10 | 100 |
GET /api/users/{id}/reviews |
10 | 100 |
GET /api/me/listings |
20 | 100 |
GET /api/conversations |
20 | 100 |
GET /api/conversations/{id}/messages |
50 | 100 |
GET /api/users/{id}/listings |
20 | 100 |
GET /api/favorites |
20 | 100 |
Examples — curl
Basic pagination — first page:
curl "https://api.owning.pro/api/listings?page=1&limit=20"
Fetch page 3 with 50 items per page:
curl "https://api.owning.pro/api/listings?page=3&limit=50"
Paginate with filters:
curl "https://api.owning.pro/api/listings?category=boats&min_price=100000&sort=price_asc&page=2&limit=20"
Get the last page (use pagination.pages from the first response):
# First request tells us total pages
curl -s "https://api.owning.pro/api/listings?limit=100" | jq '.pagination.pages'
# → 77 (for 7,669 listings at 100/page)
# Fetch the last page
curl "https://api.owning.pro/api/listings?page=77&limit=100"
Iterate all pages (Bash):
#!/bin/bash
# Fetch all active listings, paginating through every page
page=1
while true; do
response=$(curl -s "https://api.owning.pro/api/listings?status=active&limit=100&page=$page")
total_pages=$(echo "$response" | jq '.pagination.pages')
echo "$response" | jq '.results[] | {id, title, price: .price.amount}'
if [ "$page" -ge "$total_pages" ]; then
break
fi
page=$((page + 1))
# Be polite — don't exceed rate limits
sleep 0.5
done
Iterate all pages (Python):
import requests
API_BASE = "https://api.owning.pro"
page = 1
all_listings = []
while True:
resp = requests.get(
f"{API_BASE}/api/listings",
params={"status": "active", "limit": 100, "page": page},
)
resp.raise_for_status()
data = resp.json()
all_listings.extend(data["results"])
total_pages = data["pagination"]["pages"]
if page >= total_pages:
break
page += 1
print(f"Fetched {len(all_listings)} listings across {page} pages")
Pagination Tips
- Use
limit=100when bulk-fetching to minimize the number of requests (and stay within rate limits). - Always read
pagination.pagesfrom the response — don't estimate it yourself, as filters change the total. - The
totalfield reflects filtered results — if you pass?category=boats&min_price=100000,totalis the count of matching listings, not all listings. - Pages beyond the range return empty
results— requestingpage=999when there are 36 pages returns{"results": [], "pagination": {"page": 999, "limit": 20, "total": 712, "pages": 36}}. No error is thrown. limitis clamped server-side — requestinglimit=500silently returns 100 items. Thepagination.limitin the response reflects the actual limit used.
Rate Limiting
The API enforces rate limits at two layers: a per-minute sliding-window middleware (applies to all requests) and a per-action daily/hourly database counter (applies to specific resource-intensive actions).
Layer 1 — Per-Minute Sliding Window (All Requests)
Every API request is classified into an endpoint type and rate-limited accordingly. The window is 60 seconds, sliding, tracked in-memory.
| Endpoint type | Scope | Limit | Keyed by | Applies to |
|---|---|---|---|---|
| read | Public (unauthenticated) | 200 req/min | IP address | GET /api/listings, GET /api/categories, GET /api/schema/*, GET /.well-known/*, etc. |
| read | Authenticated | 600 req/min | User ID or API key ID | Same as above, when a valid Authorization header is provided |
| write | Authenticated | 30 req/min | User ID or API key ID | POST/PUT/PATCH/DELETE on /api/listings, /api/categories, etc. |
| auth | Per IP (always) | 10 req/min | IP address | POST /api/auth/login, POST /api/auth/register, POST /api/auth/magic-link, POST /api/auth/oauth/link, GET /api/auth/oauth/{provider}/callback |
| upload | Authenticated | 20 req/min | User ID or API key ID | POST /api/upload, POST /api/listings/{id}/images |
| admin | Admin | 100 req/min | Admin user ID | Any /api/admin/* request |
Note: Auth endpoints are always keyed by IP address, even if a token is present. This prevents brute-force attacks on login/register.
Rate Limit Response Headers
All responses (success and error) include these headers:
| Header | Description |
|---|---|
X-RateLimit-Limit |
Maximum requests allowed per window for this endpoint type |
X-RateLimit-Remaining |
Remaining requests in the current window |
X-RateLimit-Reset |
Seconds until the window resets |
When the limit is exceeded (HTTP 429), an additional header is set:
| Header | Description |
|---|---|
Retry-After |
Seconds to wait before retrying |
Example — Inspecting Rate Limit Headers
curl -I "https://api.owning.pro/api/listings?limit=1"
HTTP/1.1 200 OK
X-RateLimit-Limit: 200
X-RateLimit-Remaining: 199
X-RateLimit-Reset: 59
Content-Type: application/json
Layer 2 — Per-Action Daily/Hourly Limits (Specific Actions)
In addition to the per-minute middleware, specific resource-intensive actions are tracked in the database with longer windows:
| Action | Limit | Window | Scope | Applies to |
|---|---|---|---|---|
listing_create |
10 | per day (UTC midnight to midnight) | Per user | POST /api/listings |
image_upload |
50 | per day (UTC midnight to midnight) | Per user | POST /api/upload, POST /api/listings/{id}/images |
ai_generate |
5 | per hour (top of UTC hour) | Per user | POST /api/listings/ai-generate |
When a per-action limit is exceeded, the API returns:
{
"error": {
"code": "rate_limited",
"message": "Limit exceeded for listing_create. Limit: 10/day. You have used 11 this day."
}
}
Handling 429 Responses
1. Read the Retry-After header:
# Check the Retry-After header on a 429 response
curl -s -o /dev/null -w "%{http_code}" -D - "https://api.owning.pro/api/listings" 2>&1 | grep -i retry-after
2. Implement exponential backoff (Python):
import time
import requests
def fetch_with_retry(url, max_retries=5):
for attempt in range(max_retries):
resp = requests.get(url)
if resp.status_code == 429:
retry_after = int(resp.headers.get("Retry-After", 60))
print(f"Rate limited. Waiting {retry_after}s before retry...")
time.sleep(retry_after)
continue
resp.raise_for_status()
return resp.json()
raise Exception(f"Max retries ({max_retries}) exceeded")
3. Proactive rate limit checking (TypeScript):
// Check remaining quota before making a burst of requests
const resp = await fetch("https://api.owning.pro/api/listings?limit=1");
const remaining = parseInt(resp.headers.get("X-RateLimit-Remaining") ?? "0");
const reset = parseInt(resp.headers.get("X-RateLimit-Reset") ?? "60");
if (remaining < 10) {
console.log(`Only ${remaining} requests left. Window resets in ${reset}s.`);
// Wait or throttle before continuing
}
Rate Limit Configuration
All rate limits are configurable via environment variables and can be adjusted by the infrastructure team without code changes:
| Env Variable | Default | Description |
|---|---|---|
RATE_LIMIT_READ_PUBLIC |
200 | Read endpoints, unauthenticated |
RATE_LIMIT_READ_AUTH |
600 | Read endpoints, authenticated |
RATE_LIMIT_WRITE |
30 | Write endpoints, authenticated |
RATE_LIMIT_AUTH |
10 | Auth endpoints, per IP |
RATE_LIMIT_UPLOAD |
20 | Upload endpoints, authenticated |
RATE_LIMIT_ADMIN |
100 | Admin endpoints |
Note: The in-memory sliding window is per-process. In a multi-instance deployment, the effective limit is multiplied by the number of instances. When Redis is added in a future phase, the rate limiter can be swapped for a Redis-backed implementation without changing the API interface.
Additional Resources
- API Reference — Complete endpoint documentation with verified curl examples.
- Developer Quickstart — Zero to first listing in 10 minutes.
- Developer Examples — Runnable scripts in Python, TypeScript, and Bash.
- MCP Quickstart — Connect AI clients via Model Context Protocol.
- Interactive API Docs — Swagger UI with live try-it-out.
- OpenAPI Spec — Machine-readable API specification.