Skip to content

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 (commit 44bc526). Previously, invalid categories returned 200 with empty results. The valid_categories array 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

  1. Always check error.code, not just the HTTP status. The code is stable and machine-readable — safe for switch/case logic.
  2. Handle validation_error specially — parse details.fieldErrors to show per-field feedback to users.
  3. Respect Retry-After on 429 responses — wait the indicated seconds before retrying.
  4. Don't expose 500 error messages to end users — they may contain internal details in non-production environments.
  5. 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

  1. Use limit=100 when bulk-fetching to minimize the number of requests (and stay within rate limits).
  2. Always read pagination.pages from the response — don't estimate it yourself, as filters change the total.
  3. The total field reflects filtered results — if you pass ?category=boats&min_price=100000, total is the count of matching listings, not all listings.
  4. Pages beyond the range return empty results — requesting page=999 when there are 36 pages returns {"results": [], "pagination": {"page": 999, "limit": 20, "total": 712, "pages": 36}}. No error is thrown.
  5. limit is clamped server-side — requesting limit=500 silently returns 100 items. The pagination.limit in 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