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_pageto 1 to 50:/listingsdefaults to 12 and/listings/{id}/reviewsdefaults 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-Nonceheader: server-to-server scripts use WordPress application passwords instead. - Two filters tune throttling:
sdp_rate_limitchanges limits per bucket andsdp_client_ipsupplies 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
EDITABLEas POST, PUT and PATCH), DELETE removes. - Pagination headers. Paged collections return
X-WP-TotalandX-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.
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/aireturns{"error":"empty_query"}with 400, or{"error":"rate_limited","message":"..."}with 429.POST /submit-listingreturns{"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) orsdp_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.
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.
Search, AI search and facets
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 |
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. Bodytype(defaultdescription) anddata(object). Returns{"content": ...}; AI errors come back with status 422. - GET /audit/{listing_id} and POST /audit/{listing_id}/run: users who can
edit_poston the listing (400sdp_audit_invalid_id, 403sdp_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.