REST API reference (sdp/v1)

Every public sdp/v1 endpoint in Smart Directory Pro 1.9.6: methods, permissions, parameters, limits, rate limiting and response shapes, with curl examples.

Smart Directory Pro registers its REST API under the sdp/v1 namespace, so every endpoint lives at https://example.com/wp-json/sdp/v1/.... Read endpoints for listings, reviews, categories, search and facets are public. Writing a review is public but validated and rate limited. Everything under /admin/, /settings, /import/ and /seo/ needs the manage_options capability and exists to power the plugin’s own admin app. This reference covers version 1.9.6.

  • Public list endpoints clamp per_page to 1 to 50: /listings defaults to 12 and /listings/{id}/reviews defaults to 10.
  • AI search accepts up to 300 characters and allows 30 requests per visitor per hour: extra requests get HTTP 429 with "error": "rate_limited".
  • Review creation runs through the same validation as the on-site form: rating 1 to 5, text up to 5,000 characters, spam check, 5 reviews per visitor per hour, one review per listing.
  • Logged-in requests from JavaScript need the X-WP-Nonce header: server-to-server scripts use WordPress application passwords instead.
  • Two filters tune throttling: sdp_rate_limit changes limits per bucket and sdp_client_ip supplies the real IP behind a trusted proxy.

Base URL and conventions

All routes sit under /wp-json/sdp/v1. Sites without pretty permalinks use /?rest_route=/sdp/v1/..., which is standard WordPress behaviour.

  • Methods. GET reads, POST creates or triggers an action, PUT or PATCH updates (WordPress treats EDITABLE as POST, PUT and PATCH), DELETE removes.
  • Pagination headers. Paged collections return X-WP-Total and X-WP-TotalPages, matching core WordPress.
  • Body format. Send JSON with Content-Type: application/json, or form fields. WordPress merges both into the request parameters.

Authentication

Public routes need no credentials. Routes marked logged-in, owner or admin below need a WordPress user, supplied in one of two ways.

Cookie plus nonce (browser JavaScript)

Code running on the same site sends the WordPress login cookie automatically. WordPress only honours that cookie for REST calls when the request also carries a nonce for the wp_rest action in the X-WP-Nonce header. Create it in PHP with wp_create_nonce( 'wp_rest' ) and pass it to your script. Without the header, the request runs as a logged-out visitor.

Application passwords (scripts and other servers)

WordPress 5.6 and later supports application passwords under Users, Profile, Application Passwords. Send them with HTTP Basic auth over HTTPS: curl -u "username:xxxx xxxx xxxx xxxx xxxx xxxx". The request then runs with that user’s capabilities, so an administrator’s application password can reach the admin routes.

Who is making the request? Browser on the same site Login cookie + X-WP-Nonce: wp_rest Script or remote server Authorization: Basic (application password) WordPress user permission_callback __return_true: anyone must_be_logged_in: 401 if not must_be_admin: 403 if not manage_options is the admin capability
Every route’s permission check runs after WordPress resolves the user from the cookie and nonce or the application password.

Error format

Most routes return a standard WordPress error object. The HTTP status matches data.status:

{
  "code": "missing_rating",
  "message": "Please select a star rating.",
  "data": { "status": 400 }
}

Three routes use their own body shape instead:

  • POST /search/ai returns {"error":"empty_query"} with 400, or {"error":"rate_limited","message":"..."} with 429.
  • POST /submit-listing returns {"success":false,"message":"..."}, or {"success":false,"errors":{...}} with 422 for field validation.
  • Failed permission checks use core codes such as rest_forbidden (403), rest_not_logged_in (401) or sdp_login_required (401).

Rate limiting

Version 1.9.6 adds a shared throttle, SmartDirectoryProCoreRateLimiter, for public endpoints that cost the owner money or write data. Each bucket has two limits in a fixed window: one per visitor (keyed on a hash of the IP) and one site-wide ceiling. Users with manage_options are exempt. A blocked request gets HTTP 429 and the message “Too many requests. Please wait a few minutes and try again.”

Bucket Per visitor Site-wide Window Used by
ai_search 30 600 1 hour POST /search/ai
review 5 200 1 hour POST /listings/{id}/reviews
claim_verify 10 500 1 hour POST /claims/{id}/verify
ai_suggest, geocode, enquiry 10, 30, 5 200, 600, 300 1 hour Other front-end features

A bucket without a default uses 20 per visitor, 500 site-wide, 1 hour. Change the numbers with the sdp_rate_limit filter, which receives [ per_visitor, site_wide, window_seconds ] and the bucket name. The limiter enforces a minimum of 1 per visitor and a 60 second window, and raises the site-wide figure to at least the per-visitor figure.

add_filter( 'sdp_rate_limit', function ( $limits, $bucket ) {
    if ( 'ai_search' === $bucket ) {
        return [ 60, 1200, HOUR_IN_SECONDS ];
    }
    return $limits;
}, 10, 2 );

The limiter trusts only REMOTE_ADDR, because forwarded headers are client controlled. Behind a trusted proxy or CDN, supply the real address through sdp_client_ip. Invalid values fall back to 0.0.0.0.

add_filter( 'sdp_client_ip', function ( $ip ) {
    // Only when your proxy always overwrites this header.
    return isset( $_SERVER['HTTP_CF_CONNECTING_IP'] )
        ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) )
        : $ip;
} );

The front-end listing submission route keeps its own separate limiter and also answers 429 when it trips.

Listings

GET /listings

Permission: public. Returns published sdp_listing posts only.

Parameter Type Default Notes
per_page integer 12 Clamped to 1 to 50
page integer 1 Minimum 1
category string empty Category slug (sdp_category)
orderby string date date (newest first), title (A to Z), rating (average rating, high first), popular (view count, high first)

Response: an array of listing objects, plus X-WP-Total and X-WP-TotalPages. Each object has these keys:

Group Keys
Post id, title, content (rendered HTML), excerpt, status, permalink, thumbnail (large image URL or empty), author (user ID), date, modified
Location address, city, state, country, zip, lat (float), lng (float)
Contact phone, website, email
Ratings rating (float), review_count (integer)
Flags featured, claimed (booleans), expires
SEO seo_title, seo_description
Taxonomies categories (full term objects), tags (array of names)

POST /listings

Permission: logged-in users, or anyone when the site enables guest submission (allow_guest_submission). The post is created as pending when require_approval is on, otherwise publish.

Body: title, content, and optional phone, website, email, address, city, state, country, zip, seo_title, seo_description (text), lat, lng (numeric), featured, claimed (boolean). Response: 201 with the listing object.

GET /listings/{id}

Permission: public. Returns one listing object, or 404 sdp_not_found when the ID is not a listing. Each call increments the listing’s view count and logs a view analytics event (deduplicated per visitor), so use /listings for bulk reads that should not count as views.

PUT or PATCH /listings/{id} and DELETE /listings/{id}

Permission: users who can edit_post for that listing (the author or an editor or administrator). Updates accept the same fields as creation and return the listing object. Delete moves the listing to the bin and returns {"deleted":true,"id":123}.

GET /listings/{id}/stats

Permission: edit_post on the listing. Parameter: period (default 30days). The response is the analytics summary for that listing; its keys depend on the analytics module, so treat the shape as dynamic.

Reviews

GET /listings/{id}/reviews

Permission: public. Returns approved reviews only, newest first.

Parameter Type Default Notes
page integer 1
per_page integer 10 Clamped to 1 to 50

Response: an array of review objects with id, listing_id, rating, title, content, author_name, created_at, helpful_count and owner_reply (an object with content and created_at, or empty). Headers: X-WP-Total, X-WP-TotalPages, and X-SDP-Rating-Breakdown, a JSON object of review counts keyed by star rating 1 to 5.

POST /listings/{id}/reviews

Permission: public. The request is handed to ReviewManager::submit(), the same code path as the on-site review form, so the REST route cannot skip any check.

Field Type Rules
rating integer Required, 1 to 5
content string Required, up to 5,000 characters
title string Optional, trimmed to 200 characters
author_name string Required for guests, trimmed to 100 characters
author_email string Required for guests, valid email

For logged-in users the name and email come from their account and any submitted values are replaced.

POST /sdp/v1/listings/{id}/reviews Checks run in this order. The first failure returns. 1. Reviews enabled in settings? 403 reviews_disabled 2. Listing exists and is published? 404 invalid_listing 3. Rating 1 to 5, text present, 5,000 chars max 400 missing_rating / missing_content / too_long 4. Guest: name and valid email present 400 missing_name / missing_email 5. Spam heuristics (links, known payloads) 400 spam 6. RateLimiter bucket “review” (5 per hour) 429 rate_limited 7. Not already reviewed by this visitor 409 duplicate 201 Created { “review_id”: 481, “status”: “pending”, “message”: “Thank you! Your review has been submitted for approval.” }
Status is approved when the site has auto-approve reviews switched on, otherwise pending. A save failure returns 500 save_failed.

POST /reviews/{id}/reply

Permission: administrators, or the author of the listing the review belongs to. Body: content (required, plain text). Response: 200 with {"content","author_id","created_at"}. Posting again replaces the existing reply. Missing content returns 400 missing_content.

GET /search

Permission: public. The keyword and filter search that drives the directory’s search page. Searches with a text query are logged for the Analytics dashboard’s top search terms.

Parameter Type Default Notes
q string empty Search text
category, tag, location_term string or array empty Term slugs
location string empty Free-text place
lat, lng number 0 Centre point for radius search
radius integer 25 In unit
unit string km
min_rating number 0
featured, verified, open_now boolean false open_now is applied after the query using each listing’s timezone
facilities array or comma list empty
per_page integer 12 Capped at 100
page integer 1
orderby string relevance
scores string 1 Send 0 to skip the score breakdown and scoring sort

Response: an array of search rows with pagination headers. Rows come from the search module’s formatter and, with scores on, carry a score_breakdown. The exact keys follow the search module, so treat each row as a dynamic object.

POST /search/ai

Permission: public, throttled by the ai_search bucket. Each call spends the site owner’s AI credit.

Field Type Default Notes
query string required Trimmed; longer than 300 characters is cut to 300; empty returns 400
user_lat, user_lng number none Visitor position
max_radius_km integer 50
limit integer 12 Clamped to 1 to 50
200 OK POST /wp-json/sdp/v1/search/ai { “parsed”: { intent breakdown, dynamic }, “results”: [ { …listing row fields from the search module… “ai_relevance”: 0.9123, “ai_reasoning”: “Open late, strong reviews for families” } ], “meta”: { “ai_status”: “ok”, “candidates_considered”: 24, “reranked”: true, “reranked_count”: 12, … } } Illustrative values. ai_status is ok, disabled, no_key or no_manager.
The top-level keys parsed, results and meta are stable. Row fields and parsed contents are dynamic.

When AI is off or unavailable, results keep their baseline order with ai_relevance: null and ai_reasoning: "AI disabled". Rows the AI did not score are appended with "not scored". When nothing matches, results is an empty array and meta.candidates_considered is 0.

GET /discovery/facets

Permission: public. Accepts the same filter parameters as /search and returns counts for the filter sidebar:

{
  "total": 42,
  "categories": [ { "name": "...", "count": 17, ... } ],
  "ratings": { ... },
  "badges": { ... },
  "facilities": [ { "name": "Parking", "count": 9 } ],
  "facilities_filtered": false
}

facilities_filtered is true when one category is selected and that category has a facilities allowlist. Entries inside categories, ratings and badges are built at runtime, so read them defensively.

Categories

GET /categories

Permission: public. Parameter: hide_empty (boolean, off by default). Response: an array of sdp_category terms with id, name, slug, count, parent, description, icon and color. A taxonomy error returns an empty array.

Claims

Method Path Permission Behaviour
POST /listings/{id}/claim Logged in Validates email and returns a pointer to the AJAX action sdp_submit_claim, which performs the real submission
POST /claims/{id}/verify Logged in, own claim Body code. Throttled by claim_verify. Success returns {"message":"Claim verified. Awaiting admin approval."}
POST /claims/{id}/approve manage_options Optional notes. Returns {"approved":true}
POST /claims/{id}/reject manage_options Optional notes. Returns {"rejected":true}
GET /claims manage_options status, per_page (default 20, max 100), page

Front-end submission and analytics events

POST /submit-listing

Permission: anyone when guest submission is on, otherwise logged-in users (401 sdp_login_required). This route backs the front-end submission form and expects that form’s fields, including a _wpnonce for the sdp_submit_listing action, a honeypot field and a form timestamp. Submissions sent less than 3 seconds after the form loaded are rejected. For programmatic creation, POST /listings is the better fit.

POST /analytics/event

Permission: public. Body: event_type (required), listing_id (integer), metadata. Allowed event types: view, click_website, click_phone, click_email, click_directions, click_map, click_share, click_directory, click_social and impression. Unknown types return 400 sdp_invalid_event. Repeat events of the same type from the same visitor for the same listing within 5 minutes are dropped, and the response is {"logged":true} or {"logged":false}.

AI generation and listing audits

  • POST /ai/generate: same permission as POST /listings. Body type (default description) and data (object). Returns {"content": ...}; AI errors come back with status 422.
  • GET /audit/{listing_id} and POST /audit/{listing_id}/run: users who can edit_post on the listing (400 sdp_audit_invalid_id, 403 sdp_audit_forbidden). Read the latest audit or run a new one.
  • POST /audit/{listing_id}/proposal and GET /audit/{listing_id}/checkout-link: manage_options.

Admin app routes

These routes exist for the plugin’s own React admin screens and setup wizard. All require manage_options. Their request and response shapes can change between releases, so build integrations on the public routes above.

Method Path Purpose
GET, POST /settings Read or save plugin settings (API keys are redacted on read)
POST /wizard/complete, /wizard/save-step Setup wizard
POST /ai/test, /ai/models Test an AI key; fetch a provider’s live model list
POST /admin/flush-cache Clear plugin caches
GET /admin/stats, /admin/recent-listings, /admin/ai-usage, /admin/analytics, /admin/top-listings, /admin/revenue Dashboard data
GET, POST /admin/listings, /admin/listings/bulk Listings table and bulk actions
GET, PATCH, DELETE /admin/reviews, /admin/reviews/{id} Review moderation
GET, PATCH /admin/claims, /admin/claims/{id} Claim moderation
GET /admin/export Data export
GET, DELETE, POST /seo/pages, /seo/pages/{id}, /seo/pages/{id}/regenerate, /seo/sync-taxonomies Local Roundups management
POST /import/upload, /import/upload-chunk, /import/batch, /import/detect-fields CSV importer (files up to 25 MB)
GET, POST, PATCH /admin/packages/settings, /admin/subscriptions, /admin/subscriptions/{id}, /admin/packages/setup-edd Listing packages and subscriptions
GET, POST /admin/audit/settings, /admin/audit/listings, /admin/audit/edd-products Listing audit settings and products

curl examples

Top-rated listings in a category

curl -s "https://example.com/wp-json/sdp/v1/listings?category=restaurants&orderby=rating&per_page=20" -D -

The -D - flag prints headers so you can read X-WP-Total and X-WP-TotalPages.

Leave a review as a guest

curl -s -X POST "https://example.com/wp-json/sdp/v1/listings/123/reviews" 
  -H "Content-Type: application/json" 
  -d '{"rating":5,"title":"Brilliant service","content":"Booked on Tuesday, fixed by Wednesday.","author_name":"Sam","author_email":"sam@example.com"}'

AI search

curl -s -X POST "https://example.com/wp-json/sdp/v1/search/ai" 
  -H "Content-Type: application/json" 
  -d '{"query":"quiet cafe with wifi near the station","user_lat":52.0406,"user_lng":-0.7594,"limit":6}'

Approve a claim with an application password

curl -s -X POST "https://example.com/wp-json/sdp/v1/claims/42/approve" 
  -u "admin:xxxx xxxx xxxx xxxx xxxx xxxx" 
  -H "Content-Type: application/json" 
  -d '{"notes":"Verified by phone"}'

FAQ

Can I fetch more than 50 listings in one request?

No single request returns more than 50 from /listings. Page through results with page and stop when it reaches X-WP-TotalPages. The clamp stops anyone dumping the whole directory in a few calls.

Why does my logged-in fetch() call act as a guest?

WordPress ignores the login cookie on REST requests unless the X-WP-Nonce header carries a valid wp_rest nonce. Add the header and the request runs as the logged-in user.

Do administrators hit the rate limits?

No. Users with manage_options skip the shared limiter entirely, which keeps testing and moderation smooth.

My site sits behind Cloudflare and every visitor shares one limit. How do I fix it?

The limiter sees the proxy’s address in REMOTE_ADDR. Return the real visitor IP from the sdp_client_ip filter, reading a header your proxy always sets and overwrites.

Can a review posted over REST go live straight away?

Yes, when the site has auto-approve reviews switched on. Otherwise it is saved as pending and appears in GET /listings/{id}/reviews after an administrator approves it.

Are the admin routes safe to build on?

They are stable enough for the plugin’s own admin app, but their shapes can change between releases. Use the public listing, review, search and category routes for integrations.

In section: Advanced Updated September 26, 2026
AI Search Optimized by AEO God Mode (opens in a new tab)